@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
@@ -0,0 +1,71 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { randomSheepRecipeV1 } from "../shared.js";
3
+ import {
4
+ clearPendingSheep,
5
+ isDefinitiveFlockFailure,
6
+ pendingCreateKey,
7
+ pendingSheepKey,
8
+ readPendingCreate,
9
+ readPendingSheep,
10
+ writePendingCreate,
11
+ writePendingSheep,
12
+ } from "./pending-create.js";
13
+
14
+ function memoryStorage() {
15
+ const values = new Map<string, string>();
16
+ return {
17
+ values,
18
+ getItem: (key: string) => values.get(key) ?? null,
19
+ setItem: (key: string, value: string) => values.set(key, value),
20
+ removeItem: (key: string) => values.delete(key),
21
+ };
22
+ }
23
+
24
+ const sheep = randomSheepRecipeV1(() => 0);
25
+ const command = {
26
+ schemaVersion: 1 as const,
27
+ type: "bot/create" as const,
28
+ commandId: "create-1",
29
+ expectedRevision: 0,
30
+ botId: "alpha",
31
+ name: "Alpha",
32
+ sheep,
33
+ };
34
+
35
+ describe("pending Flock commands", () => {
36
+ test("binds uncertain Bot creation to the authenticated User", () => {
37
+ const storage = memoryStorage();
38
+ writePendingCreate("user-a", command, storage);
39
+ expect(readPendingCreate("user-a", storage)).toEqual(command);
40
+ expect(readPendingCreate("user-b", storage)).toBeUndefined();
41
+ expect(storage.values.get(pendingCreateKey("user-a"))).toBe(
42
+ JSON.stringify(command),
43
+ );
44
+ });
45
+
46
+ test("persists the exact sheep command for lost-response replay", () => {
47
+ const storage = memoryStorage();
48
+ const update = {
49
+ schemaVersion: 1 as const,
50
+ type: "bot/update-sheep" as const,
51
+ commandId: "sheep-1",
52
+ expectedRevision: 3,
53
+ botId: "alpha",
54
+ sheep,
55
+ };
56
+ writePendingSheep("user-a", update, storage);
57
+ expect(readPendingSheep("user-a", "alpha", storage)).toEqual(update);
58
+ expect(readPendingSheep("user-b", "alpha", storage)).toBeUndefined();
59
+ clearPendingSheep("user-a", "alpha", storage);
60
+ expect(storage.values.has(pendingSheepKey("user-a", "alpha"))).toBe(false);
61
+ });
62
+
63
+ test("discards malformed state and classifies only explicit failures", () => {
64
+ const storage = memoryStorage();
65
+ storage.values.set(pendingCreateKey("user-a"), "{}");
66
+ expect(readPendingCreate("user-a", storage)).toBeUndefined();
67
+ expect(storage.values.has(pendingCreateKey("user-a"))).toBe(false);
68
+ expect(isDefinitiveFlockFailure({ definitive: true })).toBe(true);
69
+ expect(isDefinitiveFlockFailure(new Error("network"))).toBe(false);
70
+ });
71
+ });
@@ -0,0 +1,93 @@
1
+ import {
2
+ decodeCreateBotCommandV1,
3
+ decodeUpdateSheepCommandV1,
4
+ type CreateBotCommandV1,
5
+ type UpdateSheepCommandV1,
6
+ } from "../shared.js";
7
+
8
+ export const PENDING_CREATE_KEY = "frockbot.flock.pending-create.v1";
9
+ export const PENDING_SHEEP_KEY = "frockbot.flock.pending-sheep.v1";
10
+
11
+ export function pendingCreateKey(userId: string): string {
12
+ return `${PENDING_CREATE_KEY}:${encodeURIComponent(userId)}`;
13
+ }
14
+
15
+ export function pendingSheepKey(userId: string, botId: string): string {
16
+ return `${PENDING_SHEEP_KEY}:${encodeURIComponent(userId)}:${encodeURIComponent(botId)}`;
17
+ }
18
+
19
+ export function readPendingCreate(
20
+ userId: string,
21
+ storage: Pick<Storage, "getItem" | "removeItem"> = localStorage,
22
+ ): CreateBotCommandV1 | undefined {
23
+ const key = pendingCreateKey(userId);
24
+ const value = storage.getItem(key);
25
+ if (!value) return undefined;
26
+ try {
27
+ return decodeCreateBotCommandV1(JSON.parse(value));
28
+ } catch {
29
+ storage.removeItem(key);
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ export function writePendingCreate(
35
+ userId: string,
36
+ command: CreateBotCommandV1,
37
+ storage: Pick<Storage, "setItem"> = localStorage,
38
+ ): void {
39
+ storage.setItem(pendingCreateKey(userId), JSON.stringify(command));
40
+ }
41
+
42
+ export function clearPendingCreate(
43
+ userId: string,
44
+ storage: Pick<Storage, "removeItem"> = localStorage,
45
+ ): void {
46
+ storage.removeItem(pendingCreateKey(userId));
47
+ }
48
+
49
+ export function readPendingSheep(
50
+ userId: string,
51
+ botId: string,
52
+ storage: Pick<Storage, "getItem" | "removeItem"> = localStorage,
53
+ ): UpdateSheepCommandV1 | undefined {
54
+ const key = pendingSheepKey(userId, botId);
55
+ const value = storage.getItem(key);
56
+ if (!value) return undefined;
57
+ try {
58
+ const command = decodeUpdateSheepCommandV1(JSON.parse(value));
59
+ if (command.botId !== botId) throw new Error("pending Bot mismatch");
60
+ return command;
61
+ } catch {
62
+ storage.removeItem(key);
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ export function writePendingSheep(
68
+ userId: string,
69
+ command: UpdateSheepCommandV1,
70
+ storage: Pick<Storage, "setItem"> = localStorage,
71
+ ): void {
72
+ storage.setItem(
73
+ pendingSheepKey(userId, command.botId),
74
+ JSON.stringify(command),
75
+ );
76
+ }
77
+
78
+ export function clearPendingSheep(
79
+ userId: string,
80
+ botId: string,
81
+ storage: Pick<Storage, "removeItem"> = localStorage,
82
+ ): void {
83
+ storage.removeItem(pendingSheepKey(userId, botId));
84
+ }
85
+
86
+ export function isDefinitiveFlockFailure(error: unknown): boolean {
87
+ return (
88
+ typeof error === "object" &&
89
+ error !== null &&
90
+ "definitive" in error &&
91
+ error.definitive === true
92
+ );
93
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatSidebarMessageTimeV1, groupSidebarBotsV1 } from "./sidebar.js";
3
+
4
+ const bots = [
5
+ { botId: "alpha" },
6
+ { botId: "beta" },
7
+ { botId: "gamma" },
8
+ { botId: "delta" },
9
+ ];
10
+
11
+ describe("sidebar Bot groups", () => {
12
+ test("renders one plain list until a visible Bot has a label", () => {
13
+ expect(groupSidebarBotsV1(bots.slice(0, 2), {})).toEqual({
14
+ showHeadings: false,
15
+ groups: [{ key: "all", label: "", bots: bots.slice(0, 2) }],
16
+ });
17
+ });
18
+
19
+ test("groups case-insensitively, keeps first spelling, and puts Unassigned last", () => {
20
+ const grouped = groupSidebarBotsV1(bots, {
21
+ alpha: { label: " Personal " },
22
+ beta: {},
23
+ gamma: { label: "personal" },
24
+ delta: { label: "Work" },
25
+ });
26
+ expect(grouped.showHeadings).toBe(true);
27
+ expect(
28
+ grouped.groups.map((group) => ({
29
+ label: group.label,
30
+ bots: group.bots.map((bot) => bot.botId),
31
+ })),
32
+ ).toEqual([
33
+ { label: "Personal", bots: ["alpha", "gamma"] },
34
+ { label: "Work", bots: ["delta"] },
35
+ { label: "Unassigned", bots: ["beta"] },
36
+ ]);
37
+ });
38
+
39
+ test("treats whitespace-only labels as Unassigned", () => {
40
+ const grouped = groupSidebarBotsV1(bots.slice(0, 2), {
41
+ alpha: { label: "\t" },
42
+ beta: { label: "Work" },
43
+ });
44
+ expect(grouped.groups.map((group) => group.label)).toEqual([
45
+ "Work",
46
+ "Unassigned",
47
+ ]);
48
+ });
49
+ });
50
+
51
+ describe("sidebar preview times", () => {
52
+ const now = new Date(2026, 8, 1, 15, 30);
53
+
54
+ test("shows a time today, a weekday this week, and M/D when older", () => {
55
+ expect(
56
+ formatSidebarMessageTimeV1(
57
+ new Date(2026, 8, 1, 7, 34).toISOString(),
58
+ now,
59
+ "en-US",
60
+ ),
61
+ ).toBe("7:34 AM");
62
+ expect(
63
+ formatSidebarMessageTimeV1(
64
+ new Date(2026, 7, 27, 12).toISOString(),
65
+ now,
66
+ "en-US",
67
+ ),
68
+ ).toBe("Thursday");
69
+ expect(
70
+ formatSidebarMessageTimeV1(
71
+ new Date(2026, 7, 22, 12).toISOString(),
72
+ now,
73
+ "en-US",
74
+ ),
75
+ ).toBe("8/22");
76
+ });
77
+ });
@@ -0,0 +1,77 @@
1
+ export interface SidebarBotGroupV1<T> {
2
+ key: string;
3
+ label: string;
4
+ bots: T[];
5
+ }
6
+
7
+ export interface GroupedSidebarBotsV1<T> {
8
+ showHeadings: boolean;
9
+ groups: SidebarBotGroupV1<T>[];
10
+ }
11
+
12
+ /**
13
+ * Groups labels by their case-insensitive trimmed value while preserving the
14
+ * spelling of the first Bot in each group. Unassigned is always last. Until a
15
+ * visible Bot has a label, the sidebar remains one plain list with no heading.
16
+ */
17
+ export function groupSidebarBotsV1<T extends { botId: string }>(
18
+ bots: readonly T[],
19
+ profiles: Readonly<Record<string, { label?: string } | undefined>>,
20
+ ): GroupedSidebarBotsV1<T> {
21
+ const labelled = new Map<string, SidebarBotGroupV1<T>>();
22
+ const unassigned: T[] = [];
23
+ for (const bot of bots) {
24
+ const label = profiles[bot.botId]?.label?.trim() ?? "";
25
+ if (!label) {
26
+ unassigned.push(bot);
27
+ continue;
28
+ }
29
+ const key = label.toLowerCase();
30
+ const group = labelled.get(key);
31
+ if (group) group.bots.push(bot);
32
+ else labelled.set(key, { key: `label:${key}`, label, bots: [bot] });
33
+ }
34
+ if (labelled.size === 0) {
35
+ return {
36
+ showHeadings: false,
37
+ groups: [{ key: "all", label: "", bots: [...bots] }],
38
+ };
39
+ }
40
+ return {
41
+ showHeadings: true,
42
+ groups: [
43
+ ...labelled.values(),
44
+ ...(unassigned.length === 0
45
+ ? []
46
+ : [{ key: "unassigned", label: "Unassigned", bots: unassigned }]),
47
+ ],
48
+ };
49
+ }
50
+
51
+ /** GrokBot-style local time label for the sidebar's latest message. */
52
+ export function formatSidebarMessageTimeV1(
53
+ at: string,
54
+ now: Date = new Date(),
55
+ locale?: string,
56
+ ): string {
57
+ const message = new Date(at);
58
+ if (!Number.isFinite(message.getTime())) return "";
59
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
60
+ const tomorrow = new Date(today);
61
+ tomorrow.setDate(tomorrow.getDate() + 1);
62
+ if (message >= today && message < tomorrow) {
63
+ return new Intl.DateTimeFormat(locale, {
64
+ hour: "numeric",
65
+ minute: "2-digit",
66
+ }).format(message);
67
+ }
68
+ const week = new Date(today);
69
+ week.setDate(week.getDate() - 6);
70
+ if (message >= week && message < tomorrow) {
71
+ return new Intl.DateTimeFormat(locale, { weekday: "long" }).format(message);
72
+ }
73
+ return new Intl.DateTimeFormat(locale, {
74
+ month: "numeric",
75
+ day: "numeric",
76
+ }).format(message);
77
+ }
@@ -0,0 +1,53 @@
1
+ import type { InjectionKey, Ref } from "vue";
2
+ // Shell selection is injected through its public hosted-client interface.
3
+ import type { FrockBotWebData } from "@frockbot/plugin-shell/shared";
4
+ import type { BotUnreadViewV1 } from "@frockbot/plugin-shell/unread";
5
+ import type {
6
+ BotDirectoryViewV1,
7
+ BotIdentityViewV1,
8
+ BotLifecycleStatusV1,
9
+ SheepIdentityViewV1,
10
+ SheepRecipeV1,
11
+ } from "../shared.js";
12
+
13
+ export interface FlockWebData {
14
+ directory: BotDirectoryViewV1;
15
+ identities: Record<string, SheepIdentityViewV1>;
16
+ /** Live Bot identity — the current name, title, avatar and hidden flag. */
17
+ profiles: Record<string, BotIdentityViewV1>;
18
+ /** Reveals Bots their own settings hide from the list. */
19
+ showHidden: boolean;
20
+ /** Per-Bot unread, as the Bot Durable Objects derive it. Never computed here. */
21
+ unread: Record<string, BotUnreadViewV1>;
22
+ loading: boolean;
23
+ error?: string;
24
+ overlay?: "create" | "edit" | "archive";
25
+ lifecycles: Record<string, BotLifecycleStatusV1>;
26
+ showArchived: boolean;
27
+ lifecyclePending?: string;
28
+ draftName: string;
29
+ draftSheep: SheepRecipeV1;
30
+ bindShell(shell: Ref<FrockBotWebData>): void;
31
+ load(): Promise<void>;
32
+ /** Re-reads the bounded unread fan-out for the whole sidebar. */
33
+ refreshUnread(): Promise<void>;
34
+ /** The authenticated read receipt. Never fired from a poll. */
35
+ markRead(botId: string): Promise<void>;
36
+ /** User intent: keep this Bot bold until it is opened again. */
37
+ markUnread(botId: string): Promise<void>;
38
+ select(botId: string): Promise<void>;
39
+ openCreate(): void;
40
+ openEdit(): Promise<void>;
41
+ toggleArchived(): void;
42
+ toggleHidden(): void;
43
+ openArchive(botId: string): void;
44
+ archive(): Promise<void>;
45
+ restore(botId: string): Promise<void>;
46
+ closeOverlay(): void;
47
+ reroll(): void;
48
+ create(): Promise<void>;
49
+ saveSheep(): Promise<void>;
50
+ }
51
+
52
+ export const flockWebDataKey: InjectionKey<Ref<FlockWebData>> =
53
+ Symbol("flock-web-data");