@frockbot/plugin-flock 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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/user.ts ADDED
@@ -0,0 +1,465 @@
1
+ import type {
2
+ CapabilityAssignmentView,
3
+ ExecutionPackageDefinition,
4
+ ModelAssignment,
5
+ UserSettingsViewV1,
6
+ } from "@frockbot/configuration-core";
7
+ import type { Plugin } from "cordis";
8
+ import {
9
+ BotNotFoundError,
10
+ FLOCK_DIRECTORY_LIMIT,
11
+ FlockConflictError,
12
+ FlockDecodeError,
13
+ decodeBotLifecycleCommandV1,
14
+ decodeBotLifecycleReceiptV1,
15
+ decodeBotLifecycleViewV1,
16
+ decodeDirectoryViewV1,
17
+ decodeStoredBotLifecycleReceiptV1,
18
+ decodeStoredFlockReceiptV1,
19
+ flockCommandFingerprint,
20
+ randomSheepRecipeV1,
21
+ type BotDirectoryViewV1,
22
+ type BotLifecycleCommandV1,
23
+ type BotLifecycleDirectoryViewV1,
24
+ type BotLifecycleReceiptV1,
25
+ type BotLifecycleViewV1,
26
+ type BotRegistrationV1,
27
+ type CreateBotCommandV1,
28
+ type FlockReceiptV1,
29
+ } from "./shared.js";
30
+
31
+ const DIRECTORY_KEY = "flock:directory:v1";
32
+ const RECEIPT_PREFIX = "flock:create-receipt:";
33
+ const LIFECYCLE_PREFIX = "flock:lifecycle:";
34
+ const LIFECYCLE_RECEIPT_PREFIX = "flock:lifecycle-receipt:";
35
+ const LIFECYCLE_SAGA_PREFIX = "flock:lifecycle-saga:";
36
+ const LIFECYCLE_OPERATION_PREFIX = "flock:lifecycle-operation:";
37
+ export interface FlockUserTransaction {
38
+ get<T>(key: string): Promise<T | undefined>;
39
+ put<T>(key: string, value: T): Promise<void>;
40
+ put(entries: Record<string, unknown>): Promise<void>;
41
+ delete(key: string): Promise<boolean>;
42
+ setAlarm(timestamp: number | Date): Promise<void>;
43
+ }
44
+ interface Storage extends FlockUserTransaction {
45
+ transaction<T>(
46
+ callback: (storage: FlockUserTransaction) => Promise<T>,
47
+ ): Promise<T>;
48
+ list<T>(options: { prefix: string }): Promise<Map<string, T>>;
49
+ delete(key: string): Promise<boolean>;
50
+ setAlarm(timestamp: number | Date): Promise<void>;
51
+ }
52
+ export interface FlockUserBackendHost {
53
+ storage: Storage;
54
+ /**
55
+ * Retained for hosts that materialize a Bot from User state. A new Bot no
56
+ * longer copies the User default model, so neither seam is called here.
57
+ */
58
+ readUserSettings(
59
+ storage: FlockUserTransaction,
60
+ userId: string,
61
+ ): Promise<UserSettingsViewV1>;
62
+ /**
63
+ * Immutable Package definitions from the application manifest. Omitted only
64
+ * by narrow test adapters that intentionally expose no Package authority.
65
+ */
66
+ availablePackages?: readonly ExecutionPackageDefinition[];
67
+ claimInitialModelBinding?(
68
+ storage: FlockUserTransaction,
69
+ input: {
70
+ userId: string;
71
+ botId: string;
72
+ generation: string;
73
+ model: ModelAssignment;
74
+ },
75
+ ): Promise<CapabilityAssignmentView | undefined>;
76
+ now?: () => Date;
77
+ random?: () => number;
78
+ commandBotLifecycle(
79
+ userId: string,
80
+ command: BotLifecycleCommandV1,
81
+ ): Promise<BotLifecycleReceiptV1>;
82
+ readBotLifecycle(userId: string, botId: string): Promise<BotLifecycleViewV1>;
83
+ }
84
+
85
+ interface StoredLifecycleSagaV1 {
86
+ schemaVersion: 1;
87
+ userId: string;
88
+ fingerprint: string;
89
+ command: BotLifecycleCommandV1;
90
+ }
91
+
92
+ function decodeStoredLifecycleSagaV1(input: unknown): StoredLifecycleSagaV1 {
93
+ if (typeof input !== "object" || input === null || Array.isArray(input))
94
+ throw new FlockDecodeError("stored lifecycle saga must be an object");
95
+ const value = input as Record<string, unknown>;
96
+ const keys = Reflect.ownKeys(value);
97
+ if (
98
+ keys.length !== 4 ||
99
+ keys.some(
100
+ (key) =>
101
+ typeof key !== "string" ||
102
+ !["schemaVersion", "userId", "fingerprint", "command"].includes(key),
103
+ ) ||
104
+ value.schemaVersion !== 1 ||
105
+ typeof value.userId !== "string" ||
106
+ !isFinite(value.userId.length) ||
107
+ value.userId.length < 1 ||
108
+ value.userId.length > 200 ||
109
+ typeof value.fingerprint !== "string"
110
+ )
111
+ throw new FlockDecodeError("stored lifecycle saga is invalid");
112
+ return {
113
+ schemaVersion: 1,
114
+ userId: value.userId,
115
+ fingerprint: value.fingerprint,
116
+ command: decodeBotLifecycleCommandV1(value.command),
117
+ };
118
+ }
119
+
120
+ function initialDirectory(): BotDirectoryViewV1 {
121
+ return { schemaVersion: 1, revision: 0, bots: [] };
122
+ }
123
+
124
+ export class FlockUserBackendContribution {
125
+ constructor(private readonly host: FlockUserBackendHost) {}
126
+
127
+ async listBots(): Promise<BotDirectoryViewV1> {
128
+ const stored = await this.host.storage.get<unknown>(DIRECTORY_KEY);
129
+ return structuredClone(
130
+ stored === undefined ? initialDirectory() : decodeDirectoryViewV1(stored),
131
+ );
132
+ }
133
+
134
+ async registration(botId: string): Promise<BotRegistrationV1> {
135
+ const found = (await this.listBots()).bots.find(
136
+ (bot) => bot.botId === botId,
137
+ );
138
+ if (!found) throw new BotNotFoundError(botId);
139
+ return found;
140
+ }
141
+
142
+ async hasBot(botId: string): Promise<boolean> {
143
+ return (await this.listBots()).bots.some((bot) => bot.botId === botId);
144
+ }
145
+
146
+ async createBot(
147
+ userId: string,
148
+ command: CreateBotCommandV1,
149
+ ): Promise<FlockReceiptV1> {
150
+ const fingerprint = flockCommandFingerprint(command);
151
+ return this.host.storage.transaction(async (storage) => {
152
+ const receiptKey = `${RECEIPT_PREFIX}${command.commandId}`;
153
+ const storedValue = await storage.get<unknown>(receiptKey);
154
+ const stored =
155
+ storedValue === undefined
156
+ ? undefined
157
+ : decodeStoredFlockReceiptV1(storedValue);
158
+ if (stored) {
159
+ if (stored.fingerprint !== fingerprint)
160
+ throw new FlockDecodeError(
161
+ `command ID collision: ${command.commandId}`,
162
+ );
163
+ return structuredClone(stored.receipt);
164
+ }
165
+ const currentValue = await storage.get<unknown>(DIRECTORY_KEY);
166
+ const current =
167
+ currentValue === undefined
168
+ ? initialDirectory()
169
+ : decodeDirectoryViewV1(currentValue);
170
+ if (current.revision !== command.expectedRevision)
171
+ throw new FlockConflictError(current.revision);
172
+ let receipt: FlockReceiptV1;
173
+ if (current.bots.some((bot) => bot.botId === command.botId)) {
174
+ receipt = {
175
+ schemaVersion: 1,
176
+ commandId: command.commandId,
177
+ status: "rejected",
178
+ revision: current.revision,
179
+ failure: `Bot "${command.botId}" already exists`,
180
+ };
181
+ } else if (current.bots.length >= FLOCK_DIRECTORY_LIMIT) {
182
+ receipt = {
183
+ schemaVersion: 1,
184
+ commandId: command.commandId,
185
+ status: "rejected",
186
+ revision: current.revision,
187
+ failure: "Bot directory limit reached",
188
+ };
189
+ } else {
190
+ const user = await this.host.readUserSettings(storage, userId);
191
+ const installedVersions = new Map(
192
+ user.packages.flatMap((pkg) =>
193
+ pkg.state === "installed" ? [[pkg.packageId, pkg.version]] : [],
194
+ ),
195
+ );
196
+ const initialAssignments = (this.host.availablePackages ?? []).flatMap(
197
+ (pkg, packageIndex) =>
198
+ installedVersions.get(pkg.packageId) === pkg.version
199
+ ? pkg.capabilities.flatMap((capability, capabilityIndex) =>
200
+ capability.connectionTypes.length === 0 &&
201
+ capability.kind !== "model"
202
+ ? [
203
+ {
204
+ assignmentId: `default-${packageIndex}-${capabilityIndex}`,
205
+ packageId: pkg.packageId,
206
+ capabilityId: capability.id,
207
+ state: "enabled" as const,
208
+ },
209
+ ]
210
+ : [],
211
+ )
212
+ : [],
213
+ );
214
+ // A new Bot carries no model of its own: it follows the User's
215
+ // default model dynamically, and claims the Connection's model
216
+ // Capability the first time it resolves its execution context. Only a
217
+ // Bot that overrides the default owns a durable `model`.
218
+ const registration: BotRegistrationV1 = {
219
+ schemaVersion: 1,
220
+ botId: command.botId,
221
+ registeredAt: (this.host.now?.() ?? new Date()).toISOString(),
222
+ initialName: command.name,
223
+ ...(command.description === undefined
224
+ ? {}
225
+ : { initialDescription: command.description }),
226
+ initialModel: undefined,
227
+ initialModelBinding: undefined,
228
+ initialAssignments,
229
+ // The creator is durable history: a Bot the Flock made on another
230
+ // Bot's behalf says so in the registration seed itself.
231
+ ...(command.createdBy
232
+ ? { createdBy: structuredClone(command.createdBy) }
233
+ : {}),
234
+ sheep: structuredClone(
235
+ command.sheep ?? randomSheepRecipeV1(this.host.random),
236
+ ),
237
+ };
238
+ const next = {
239
+ ...current,
240
+ revision: current.revision + 1,
241
+ bots: [...current.bots, registration],
242
+ } satisfies BotDirectoryViewV1;
243
+ await storage.put(DIRECTORY_KEY, next);
244
+ await storage.put(`${LIFECYCLE_PREFIX}${command.botId}`, {
245
+ schemaVersion: 1,
246
+ botId: command.botId,
247
+ status: "active",
248
+ revision: 0,
249
+ } satisfies BotLifecycleViewV1);
250
+ receipt = {
251
+ schemaVersion: 1,
252
+ commandId: command.commandId,
253
+ status: "applied",
254
+ revision: next.revision,
255
+ };
256
+ }
257
+ await storage.put(receiptKey, { fingerprint, receipt });
258
+ return structuredClone(receipt);
259
+ });
260
+ }
261
+
262
+ async listBotLifecycles(): Promise<BotLifecycleDirectoryViewV1> {
263
+ const directory = await this.listBots();
264
+ const lifecycles = await Promise.all(
265
+ directory.bots.map(async (bot) => {
266
+ const value = await this.host.storage.get<unknown>(
267
+ `${LIFECYCLE_PREFIX}${bot.botId}`,
268
+ );
269
+ if (value === undefined)
270
+ throw new FlockDecodeError(
271
+ `Bot lifecycle projection is missing for "${bot.botId}"`,
272
+ );
273
+ return decodeBotLifecycleViewV1(value);
274
+ }),
275
+ );
276
+ return { schemaVersion: 1, lifecycles };
277
+ }
278
+
279
+ async executeLifecycle(
280
+ userId: string,
281
+ input: unknown,
282
+ ): Promise<BotLifecycleReceiptV1> {
283
+ const command = decodeBotLifecycleCommandV1(input);
284
+ const fingerprint = flockCommandFingerprint(command);
285
+ const receiptKey = `${LIFECYCLE_RECEIPT_PREFIX}${command.commandId}`;
286
+ const admitted = await this.host.storage.transaction(async (storage) => {
287
+ const existingValue = await storage.get<unknown>(receiptKey);
288
+ if (existingValue !== undefined) {
289
+ const existing = decodeStoredBotLifecycleReceiptV1(existingValue);
290
+ if (existing.fingerprint !== fingerprint)
291
+ throw new FlockDecodeError(
292
+ `command ID collision: ${command.commandId}`,
293
+ );
294
+ return existing.receipt;
295
+ }
296
+ const activeOperation = await storage.get<unknown>(
297
+ `${LIFECYCLE_OPERATION_PREFIX}${command.botId}`,
298
+ );
299
+ if (activeOperation !== undefined)
300
+ throw new FlockDecodeError(
301
+ `Bot "${command.botId}" already has a lifecycle operation retrying`,
302
+ );
303
+ const directoryValue = await storage.get<unknown>(DIRECTORY_KEY);
304
+ const directory =
305
+ directoryValue === undefined
306
+ ? initialDirectory()
307
+ : decodeDirectoryViewV1(directoryValue);
308
+ if (!directory.bots.some((bot) => bot.botId === command.botId))
309
+ throw new BotNotFoundError(command.botId);
310
+ const lifecycleValue = await storage.get<unknown>(
311
+ `${LIFECYCLE_PREFIX}${command.botId}`,
312
+ );
313
+ if (lifecycleValue === undefined)
314
+ throw new FlockDecodeError(
315
+ `Bot lifecycle projection is missing for "${command.botId}"`,
316
+ );
317
+ const lifecycle = decodeBotLifecycleViewV1(lifecycleValue);
318
+ const receipt = {
319
+ schemaVersion: 1,
320
+ commandId: command.commandId,
321
+ botId: command.botId,
322
+ status: "pending",
323
+ lifecycle,
324
+ } satisfies BotLifecycleReceiptV1;
325
+ const saga = {
326
+ schemaVersion: 1,
327
+ userId,
328
+ fingerprint,
329
+ command,
330
+ } satisfies StoredLifecycleSagaV1;
331
+ await storage.put({
332
+ [receiptKey]: { fingerprint, receipt },
333
+ [`${LIFECYCLE_SAGA_PREFIX}${command.commandId}`]: saga,
334
+ [`${LIFECYCLE_OPERATION_PREFIX}${command.botId}`]: command.commandId,
335
+ });
336
+ await storage.setAlarm(Date.now());
337
+ return receipt;
338
+ });
339
+ if (admitted.status !== "pending") return admitted;
340
+ return this.settleLifecycle(command.commandId);
341
+ }
342
+
343
+ async alarm(): Promise<void> {
344
+ const sagas = await this.host.storage.list<unknown>({
345
+ prefix: LIFECYCLE_SAGA_PREFIX,
346
+ });
347
+ let pending = false;
348
+ for (const key of sagas.keys()) {
349
+ const receipt = await this.settleLifecycle(
350
+ key.slice(LIFECYCLE_SAGA_PREFIX.length),
351
+ );
352
+ if (receipt.status === "pending") pending = true;
353
+ }
354
+ if (pending) await this.host.storage.setAlarm(Date.now() + 1_000);
355
+ }
356
+
357
+ private async settleLifecycle(
358
+ commandId: string,
359
+ ): Promise<BotLifecycleReceiptV1> {
360
+ const sagaValue = await this.host.storage.get<unknown>(
361
+ `${LIFECYCLE_SAGA_PREFIX}${commandId}`,
362
+ );
363
+ if (sagaValue === undefined) {
364
+ const receipt = await this.host.storage.get<unknown>(
365
+ `${LIFECYCLE_RECEIPT_PREFIX}${commandId}`,
366
+ );
367
+ if (receipt === undefined)
368
+ throw new Error("lifecycle saga is unavailable");
369
+ return decodeStoredBotLifecycleReceiptV1(receipt).receipt;
370
+ }
371
+ const saga = decodeStoredLifecycleSagaV1(sagaValue);
372
+ const target = saga.command.type === "bot/archive" ? "archived" : "active";
373
+ let outcome: BotLifecycleReceiptV1 | undefined;
374
+ let reconcileMarker = false;
375
+ try {
376
+ const candidate = decodeBotLifecycleReceiptV1(
377
+ await this.host.commandBotLifecycle(saga.userId, saga.command),
378
+ );
379
+ const correlated =
380
+ candidate.commandId === saga.command.commandId &&
381
+ candidate.botId === saga.command.botId &&
382
+ candidate.lifecycle.botId === saga.command.botId;
383
+ if (!correlated || candidate.status === "pending") {
384
+ reconcileMarker = true;
385
+ } else if (candidate.status === "applied") {
386
+ if (candidate.lifecycle.status === target) outcome = candidate;
387
+ else reconcileMarker = true;
388
+ } else {
389
+ outcome = candidate;
390
+ }
391
+ } catch {
392
+ reconcileMarker = true;
393
+ }
394
+ if (reconcileMarker) {
395
+ try {
396
+ const lifecycle = decodeBotLifecycleViewV1(
397
+ await this.host.readBotLifecycle(saga.userId, saga.command.botId),
398
+ );
399
+ if (
400
+ lifecycle.botId === saga.command.botId &&
401
+ lifecycle.status === target
402
+ ) {
403
+ outcome = {
404
+ schemaVersion: 1,
405
+ commandId: saga.command.commandId,
406
+ botId: saga.command.botId,
407
+ status: "applied",
408
+ lifecycle,
409
+ };
410
+ }
411
+ } catch {
412
+ // The durable pending receipt and alarm own retry after an uncertain call.
413
+ }
414
+ }
415
+ if (!outcome) {
416
+ const stored = await this.host.storage.get<unknown>(
417
+ `${LIFECYCLE_RECEIPT_PREFIX}${commandId}`,
418
+ );
419
+ if (stored === undefined)
420
+ throw new Error("lifecycle receipt is unavailable");
421
+ return decodeStoredBotLifecycleReceiptV1(stored).receipt;
422
+ }
423
+ return this.host.storage.transaction(async (storage) => {
424
+ const currentSagaValue = await storage.get<unknown>(
425
+ `${LIFECYCLE_SAGA_PREFIX}${commandId}`,
426
+ );
427
+ if (currentSagaValue === undefined) {
428
+ const settled = await storage.get<unknown>(
429
+ `${LIFECYCLE_RECEIPT_PREFIX}${commandId}`,
430
+ );
431
+ if (settled === undefined)
432
+ throw new Error("lifecycle receipt is unavailable");
433
+ return decodeStoredBotLifecycleReceiptV1(settled).receipt;
434
+ }
435
+ const currentSaga = decodeStoredLifecycleSagaV1(currentSagaValue);
436
+ if (currentSaga.fingerprint !== saga.fingerprint)
437
+ throw new FlockDecodeError(`command ID collision: ${commandId}`);
438
+ await storage.put({
439
+ [`${LIFECYCLE_PREFIX}${saga.command.botId}`]: outcome!.lifecycle,
440
+ [`${LIFECYCLE_RECEIPT_PREFIX}${commandId}`]: {
441
+ fingerprint: saga.fingerprint,
442
+ receipt: outcome,
443
+ },
444
+ });
445
+ await storage.delete(`${LIFECYCLE_SAGA_PREFIX}${commandId}`);
446
+ await storage.delete(
447
+ `${LIFECYCLE_OPERATION_PREFIX}${saga.command.botId}`,
448
+ );
449
+ return outcome!;
450
+ });
451
+ }
452
+ }
453
+
454
+ export function createFlockUserBackendContribution(
455
+ host: FlockUserBackendHost,
456
+ ): FlockUserBackendContribution {
457
+ return new FlockUserBackendContribution(host);
458
+ }
459
+
460
+ export function createFlockUserBackendPlugin(
461
+ host: FlockUserBackendHost,
462
+ lifecycle: { mount(value: FlockUserBackendContribution): () => void },
463
+ ): Plugin {
464
+ return () => lifecycle.mount(createFlockUserBackendContribution(host));
465
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "resolveJsonModule": true,
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
12
+ "types": ["bun", "vite/client"]
13
+ },
14
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
15
+ }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+ """Validate that every promoted layer survives deterministic 32/64/256 composition."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from PIL import Image, ImageChops, ImageStat
8
+
9
+ ROOT = Path(__file__).parent
10
+ ASSETS = ROOT / "assets"
11
+ SIZES = (32, 64, 256)
12
+ RESAMPLE = Image.Resampling.LANCZOS
13
+
14
+ manifest = json.loads((ASSETS / "manifest.json").read_text())
15
+ asset_by_id = {item["id"]: item for item in manifest["assets"]}
16
+ node_by_id = {
17
+ node["id"]: node
18
+ for band in ("upper", "middle", "lower")
19
+ for node in manifest["trees"][band]
20
+ }
21
+
22
+ def image(asset_id: str) -> Image.Image:
23
+ return Image.open(ASSETS / asset_by_id[asset_id]["file"]).convert("RGBA")
24
+
25
+ def path_for(node_id: str) -> list[str]:
26
+ result: list[str] = []
27
+ node = node_by_id[node_id]
28
+ while node["parent"] is not None:
29
+ result.insert(0, node["id"])
30
+ node = node_by_id[node["parent"]]
31
+ return result
32
+
33
+ def compose(layer_ids: list[str], size: int) -> Image.Image:
34
+ output = Image.new("RGBA", (256, 256))
35
+ for layer_id in layer_ids:
36
+ output.alpha_composite(image(layer_id))
37
+ return output.resize((size, size), RESAMPLE)
38
+
39
+ base_ids = ["background-hot-pink", "canonical"]
40
+ checked = 0
41
+ for size in SIZES:
42
+ base = compose(base_ids, size)
43
+ assert base.getbbox(), f"empty canonical composition at {size}px"
44
+ assert sum(ImageStat.Stat(base.convert("RGB")).var) > 1, f"flat canonical at {size}px"
45
+ for background in manifest["backgrounds"]:
46
+ candidate = compose([f"background-{background['id']}", "canonical"], size)
47
+ assert candidate.getbbox(), f"background {background['id']} is empty at {size}px"
48
+ if background["id"] != "hot-pink":
49
+ assert ImageChops.difference(base, candidate).convert("RGB").getbbox(), (
50
+ f"background {background['id']} is indistinguishable at {size}px"
51
+ )
52
+ checked += 1
53
+ for band in ("upper", "middle", "lower"):
54
+ for node in manifest["trees"][band]:
55
+ if node["parent"] is None:
56
+ continue
57
+ layers = base_ids + path_for(node["id"])
58
+ candidate = compose(layers, size)
59
+ assert ImageChops.difference(base, candidate).convert("RGB").getbbox(), (
60
+ f"wearable {node['id']} disappears at {size}px"
61
+ )
62
+ checked += 1
63
+
64
+ print(f"validated {checked} background/wearable compositions at 32/64/256px")
package/vite.config.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import vue from "@vitejs/plugin-vue";
3
+ import { defineConfig } from "vite";
4
+
5
+ export default defineConfig({
6
+ plugins: [vue()],
7
+ build: {
8
+ outDir: fileURLToPath(new URL("./dist", import.meta.url)),
9
+ emptyOutDir: true,
10
+ manifest: "manifest.json",
11
+ assetsInlineLimit: Number.POSITIVE_INFINITY,
12
+ lib: {
13
+ entry: fileURLToPath(new URL("./src/client/index.ts", import.meta.url)),
14
+ formats: ["es"],
15
+ },
16
+ rollupOptions: {
17
+ external: [
18
+ "vue",
19
+ "@cordisjs/client",
20
+ "@frockbot/client-core",
21
+ "@frockbot/plugin-shell/shared",
22
+ ],
23
+ output: {
24
+ entryFileNames: "assets/flock-[hash].js",
25
+ chunkFileNames: "assets/chunk-[hash].js",
26
+ assetFileNames: "assets/[name]-[hash][extname]",
27
+ },
28
+ },
29
+ },
30
+ });