@frockbot/plugin-shell 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 (73) hide show
  1. package/frockbot.json +68 -0
  2. package/package.json +87 -6
  3. package/src/agent.test.ts +372 -0
  4. package/src/agent.ts +335 -0
  5. package/src/approvals.test.ts +224 -0
  6. package/src/approvals.ts +530 -0
  7. package/src/backend-assignment.test.ts +161 -0
  8. package/src/backend-assignment.ts +274 -0
  9. package/src/backend-authoring.test.ts +518 -0
  10. package/src/backend-authoring.ts +531 -0
  11. package/src/backend-bot-identity.test.ts +215 -0
  12. package/src/backend-completion.test.ts +289 -0
  13. package/src/backend-completion.ts +95 -0
  14. package/src/backend-composition.ts +242 -0
  15. package/src/backend-computer.ts +76 -0
  16. package/src/backend-configuration.test.ts +1757 -0
  17. package/src/backend-contracts.test.ts +189 -0
  18. package/src/backend-contracts.ts +44 -0
  19. package/src/backend-debug.test.ts +202 -0
  20. package/src/backend-execution.ts +55 -0
  21. package/src/backend-flock.ts +96 -0
  22. package/src/backend-image.test.ts +115 -0
  23. package/src/backend-image.ts +180 -0
  24. package/src/backend-isolate.test.ts +238 -0
  25. package/src/backend-isolate.ts +409 -0
  26. package/src/backend-machine.ts +144 -0
  27. package/src/backend-memory.ts +89 -0
  28. package/src/backend-recovery-integration.test.ts +1575 -0
  29. package/src/backend-recovery.ts +106 -0
  30. package/src/backend-routines.ts +375 -0
  31. package/src/backend-runner.ts +251 -0
  32. package/src/backend-skills.test.ts +126 -0
  33. package/src/backend-skills.ts +198 -0
  34. package/src/backend-stop.test.ts +356 -0
  35. package/src/backend-subagents.ts +459 -0
  36. package/src/backend.ts +6035 -0
  37. package/src/client/FrockBotApp.vue +1026 -0
  38. package/src/client/SendPayloadView.vue +337 -0
  39. package/src/client/composer-draft.test.ts +31 -0
  40. package/src/client/composer-draft.ts +35 -0
  41. package/src/client/cordis-client-shim.d.ts +15 -0
  42. package/src/client/index.test.ts +2548 -0
  43. package/src/client/index.ts +2346 -0
  44. package/src/client/model-presentation.test.ts +35 -0
  45. package/src/client/model-presentation.ts +19 -0
  46. package/src/client/notify.test.ts +89 -0
  47. package/src/client/notify.ts +101 -0
  48. package/src/client/skill-invocation.test.ts +143 -0
  49. package/src/client/skill-invocation.ts +175 -0
  50. package/src/client/styles.css +1043 -0
  51. package/src/composition-views.ts +118 -0
  52. package/src/debug-protocol.test.ts +80 -0
  53. package/src/debug-protocol.ts +165 -0
  54. package/src/env.d.ts +10 -0
  55. package/src/history.test.ts +163 -0
  56. package/src/history.ts +108 -0
  57. package/src/host.ts +20 -0
  58. package/src/index.ts +2 -0
  59. package/src/manifest.ts +3 -0
  60. package/src/run-cursor.ts +28 -0
  61. package/src/run-protocol.test.ts +1281 -0
  62. package/src/run-protocol.ts +1417 -0
  63. package/src/settings-links.test.ts +106 -0
  64. package/src/settings-links.ts +289 -0
  65. package/src/shared.ts +338 -0
  66. package/src/skill-protocol.ts +117 -0
  67. package/src/terminal-records.test.ts +217 -0
  68. package/src/terminal-records.ts +150 -0
  69. package/src/unread.test.ts +362 -0
  70. package/src/unread.ts +675 -0
  71. package/tsconfig.json +18 -0
  72. package/vite.config.ts +32 -0
  73. package/README.md +0 -3
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { modelRuntimeLabel } from "./model-presentation.js";
3
+
4
+ describe("model runtime presentation", () => {
5
+ test("names the model and its provider Package", () => {
6
+ expect(
7
+ modelRuntimeLabel({
8
+ modelDisplayName: "Llama 3",
9
+ providerModelId: "llama-3:cloud",
10
+ packageDisplayName: "Ollama Cloud",
11
+ connectionDisplayName: "Work",
12
+ hasModel: true,
13
+ }),
14
+ ).toBe("Llama 3 · Ollama Cloud");
15
+ expect(
16
+ modelRuntimeLabel({
17
+ providerModelId: "llama-3:cloud",
18
+ connectionDisplayName: "Custom provider",
19
+ hasModel: true,
20
+ }),
21
+ ).toBe("llama-3:cloud · Custom provider");
22
+ });
23
+
24
+ test("reads the same whether the model is the Bot's or the User default", () => {
25
+ const label = {
26
+ modelDisplayName: "Llama 3",
27
+ packageDisplayName: "Ollama Cloud",
28
+ hasModel: true,
29
+ };
30
+ expect(modelRuntimeLabel(label)).toBe("Llama 3 · Ollama Cloud");
31
+ expect(modelRuntimeLabel({ ...label, hasModel: false })).toBe(
32
+ "No default model",
33
+ );
34
+ });
35
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The model line the shell shows above the composer. It names the model the
3
+ * Bot actually runs on, whether that model is the Bot's own override or the
4
+ * User's default: a Bot that "just works" does not advertise where its
5
+ * settings came from.
6
+ */
7
+ export function modelRuntimeLabel(input: {
8
+ modelDisplayName?: string;
9
+ providerModelId?: string;
10
+ packageDisplayName?: string;
11
+ connectionDisplayName?: string;
12
+ hasModel: boolean;
13
+ }): string {
14
+ if (!input.hasModel) return "No default model";
15
+ const model =
16
+ input.modelDisplayName ?? input.providerModelId ?? "Connected model";
17
+ const provider = input.packageDisplayName ?? input.connectionDisplayName;
18
+ return provider ? `${model} · ${provider}` : model;
19
+ }
@@ -0,0 +1,89 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { showClientNotificationV1 } from "./notify.js";
3
+
4
+ const globals = globalThis as unknown as {
5
+ window?: Record<string, unknown>;
6
+ Notification?: unknown;
7
+ };
8
+ const original = { window: globals.window, Notification: globals.Notification };
9
+
10
+ afterEach(() => {
11
+ globals.window = original.window;
12
+ globals.Notification = original.Notification;
13
+ });
14
+
15
+ function shellBridge(commandId: string, calls: unknown[]) {
16
+ return {
17
+ list: () => [{ id: commandId }],
18
+ invoke: (request: unknown) => {
19
+ calls.push(request);
20
+ return Promise.resolve({ status: "ok" });
21
+ },
22
+ };
23
+ }
24
+
25
+ function webNotification(shown: unknown[]) {
26
+ class FakeNotification {
27
+ static permission = "granted";
28
+ constructor(title: string, options?: { body?: string }) {
29
+ shown.push({ title, body: options?.body });
30
+ }
31
+ }
32
+ return FakeNotification;
33
+ }
34
+
35
+ describe("the client notification seam", () => {
36
+ test("prefers the desktop Package when the shell exposes it", async () => {
37
+ const calls: unknown[] = [];
38
+ const shown: unknown[] = [];
39
+ globals.window = {
40
+ frockbotDesktop: shellBridge("desktop.notifications.show", calls),
41
+ Notification: webNotification(shown),
42
+ };
43
+ globals.Notification = globals.window.Notification;
44
+ expect(
45
+ await showClientNotificationV1({ title: "Alpha replied", body: "hi" }),
46
+ ).toBe("desktop");
47
+ expect(calls).toEqual([
48
+ {
49
+ schemaVersion: 1,
50
+ action: "invoke",
51
+ commandId: "desktop.notifications.show",
52
+ input: { title: "Alpha replied", body: "hi", urgency: "normal" },
53
+ },
54
+ ]);
55
+ expect(shown).toEqual([]);
56
+ });
57
+
58
+ test("uses the mobile Package when that is the shell", async () => {
59
+ const calls: unknown[] = [];
60
+ globals.window = {
61
+ // The desktop bridge exists but exposes no commands, exactly as the
62
+ // Electron preload does today.
63
+ frockbotDesktop: { request: () => Promise.resolve(undefined) },
64
+ frockbotMobile: shellBridge("mobile.notifications.show", calls),
65
+ };
66
+ globals.Notification = undefined;
67
+ expect(await showClientNotificationV1({ title: "Beta", body: "" })).toBe(
68
+ "mobile",
69
+ );
70
+ expect(calls).toHaveLength(1);
71
+ });
72
+
73
+ test("falls back to the web API, and reports when nothing can show it", async () => {
74
+ const shown: unknown[] = [];
75
+ const Notification = webNotification(shown);
76
+ globals.window = { Notification };
77
+ globals.Notification = Notification;
78
+ expect(await showClientNotificationV1({ title: "Gamma", body: "b" })).toBe(
79
+ "web",
80
+ );
81
+ expect(shown).toEqual([{ title: "Gamma", body: "b" }]);
82
+
83
+ Notification.permission = "denied";
84
+ expect(await showClientNotificationV1({ title: "Gamma", body: "b" })).toBe(
85
+ "unavailable",
86
+ );
87
+ expect(shown).toHaveLength(1);
88
+ });
89
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The one place the hosted client raises a notification.
3
+ *
4
+ * Progressive enhancement, in the constitution's sense: when the shell around
5
+ * the WebUI exposes its notifications Package — `desktop.notifications.show`
6
+ * or `mobile.notifications.show` — the intent goes there, because the platform
7
+ * shows it the way the platform shows notifications. When it does not, the web
8
+ * `Notification` API carries it. Neither Package changes for this; the seam
9
+ * only asks what the host already exposes.
10
+ */
11
+
12
+ /** What a shell exposes to the hosted page. Detected structurally. */
13
+ interface HostedCapabilityBridge {
14
+ list(): readonly { id: string }[];
15
+ invoke(request: unknown, signal?: AbortSignal): Promise<{ status: string }>;
16
+ }
17
+
18
+ export interface ClientNotificationIntentV1 {
19
+ title: string;
20
+ body: string;
21
+ urgency?: "normal" | "critical";
22
+ }
23
+
24
+ export type ClientNotificationDeliveryV1 =
25
+ "desktop" | "mobile" | "web" | "unavailable";
26
+
27
+ const DESKTOP_SHOW_COMMAND = "desktop.notifications.show";
28
+ const MOBILE_SHOW_COMMAND = "mobile.notifications.show";
29
+
30
+ function bridge(candidate: unknown): HostedCapabilityBridge | undefined {
31
+ if (typeof candidate !== "object" || candidate === null) return undefined;
32
+ const value = candidate as Partial<HostedCapabilityBridge>;
33
+ return typeof value.list === "function" && typeof value.invoke === "function"
34
+ ? (value as HostedCapabilityBridge)
35
+ : undefined;
36
+ }
37
+
38
+ function exposes(host: HostedCapabilityBridge, commandId: string): boolean {
39
+ try {
40
+ return host.list().some((command) => command.id === commandId);
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ async function invokeShow(
47
+ host: HostedCapabilityBridge,
48
+ commandId: string,
49
+ intent: ClientNotificationIntentV1,
50
+ ): Promise<boolean> {
51
+ try {
52
+ const result = await host.invoke({
53
+ schemaVersion: 1,
54
+ action: "invoke",
55
+ commandId,
56
+ input: {
57
+ title: intent.title,
58
+ body: intent.body,
59
+ urgency: intent.urgency ?? "normal",
60
+ },
61
+ });
62
+ return result.status === "ok";
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Shows one notification through the best surface available. Never throws: a
70
+ * notification that cannot be shown reports how far it got, and the caller
71
+ * decides what to say about it.
72
+ */
73
+ export async function showClientNotificationV1(
74
+ intent: ClientNotificationIntentV1,
75
+ ): Promise<ClientNotificationDeliveryV1> {
76
+ const host = globalThis.window as unknown as
77
+ Record<string, unknown> | undefined;
78
+ if (host) {
79
+ const desktop = bridge(host.frockbotDesktop);
80
+ if (desktop && exposes(desktop, DESKTOP_SHOW_COMMAND)) {
81
+ if (await invokeShow(desktop, DESKTOP_SHOW_COMMAND, intent)) {
82
+ return "desktop";
83
+ }
84
+ }
85
+ const mobile = bridge(host.frockbotMobile);
86
+ if (mobile && exposes(mobile, MOBILE_SHOW_COMMAND)) {
87
+ if (await invokeShow(mobile, MOBILE_SHOW_COMMAND, intent)) {
88
+ return "mobile";
89
+ }
90
+ }
91
+ }
92
+ if (
93
+ typeof window === "undefined" ||
94
+ !("Notification" in window) ||
95
+ Notification.permission !== "granted"
96
+ ) {
97
+ return "unavailable";
98
+ }
99
+ new Notification(intent.title, { body: intent.body });
100
+ return "web";
101
+ }
@@ -0,0 +1,143 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
3
+ import {
4
+ nextSkillHighlightV1,
5
+ rankSkillCandidatesV1,
6
+ SkillAttachmentStore,
7
+ skillPopoverForV1,
8
+ textWithoutSkillTriggerV1,
9
+ } from "./skill-invocation.js";
10
+
11
+ function entry(
12
+ slug: string,
13
+ name: string,
14
+ description = "Use this when something happens.",
15
+ ): ClientSkillCatalogEntryV1 {
16
+ return {
17
+ ref: `bot/${slug}`,
18
+ skill: { schemaVersion: 1, source: "bot", slug },
19
+ name,
20
+ description,
21
+ path: `skills/${slug}/SKILL.md`,
22
+ };
23
+ }
24
+
25
+ const catalog = [
26
+ entry("daily-standup", "Daily standup"),
27
+ entry("standup-notes", "Standup notes"),
28
+ entry("release", "Release", "Use this when cutting a release standup."),
29
+ entry("weekly-report", "Weekly report"),
30
+ ];
31
+
32
+ describe("the popover's ranking", () => {
33
+ test("offers the whole catalog with no query", () => {
34
+ const ranked = rankSkillCandidatesV1(catalog, "");
35
+ expect(ranked).toHaveLength(4);
36
+ expect(ranked.every((candidate) => candidate.rank === 0)).toBe(true);
37
+ });
38
+
39
+ test("ranks an exact slug above a prefix, a prefix above a substring, and a name above a description", () => {
40
+ const ranked = rankSkillCandidatesV1(catalog, "standup");
41
+ expect(ranked.map((candidate) => candidate.entry.ref)).toEqual([
42
+ // exact slug is nothing here, so: prefix, then substring, then description
43
+ "bot/standup-notes",
44
+ "bot/daily-standup",
45
+ "bot/release",
46
+ ]);
47
+ });
48
+
49
+ test("matches case-insensitively and drops what does not match", () => {
50
+ const ranked = rankSkillCandidatesV1(catalog, "WEEKLY");
51
+ expect(ranked.map((candidate) => candidate.entry.ref)).toEqual([
52
+ "bot/weekly-report",
53
+ ]);
54
+ });
55
+
56
+ test("does not offer a Skill that is already attached", () => {
57
+ const ranked = rankSkillCandidatesV1(catalog, "standup", {
58
+ exclude: [{ schemaVersion: 1, source: "bot", slug: "standup-notes" }],
59
+ });
60
+ expect(ranked.map((candidate) => candidate.entry.ref)).not.toContain(
61
+ "bot/standup-notes",
62
+ );
63
+ });
64
+ });
65
+
66
+ describe("reading the popover out of the composer", () => {
67
+ test("opens on / and on @ at the start of the message", () => {
68
+ expect(skillPopoverForV1("/stand", 6)).toEqual({
69
+ trigger: "/",
70
+ at: 0,
71
+ query: "stand",
72
+ });
73
+ expect(skillPopoverForV1("@stand", 6)?.trigger).toBe("@");
74
+ });
75
+
76
+ test("opens after whitespace but not inside a word", () => {
77
+ expect(skillPopoverForV1("write up /stand", 15)?.query).toBe("stand");
78
+ // An email address or a path in prose is not a Skill picker.
79
+ expect(skillPopoverForV1("tim@futuredirectors", 19)).toBeUndefined();
80
+ expect(skillPopoverForV1("docs/architecture", 17)).toBeUndefined();
81
+ });
82
+
83
+ test("closes once whitespace follows the trigger", () => {
84
+ expect(skillPopoverForV1("/stand up", 9)).toBeUndefined();
85
+ });
86
+
87
+ test("removes the trigger and its query on selection, keeping the rest", () => {
88
+ const popover = skillPopoverForV1("morning /stand", 14)!;
89
+ expect(textWithoutSkillTriggerV1("morning /stand", popover, 14)).toEqual({
90
+ text: "morning ",
91
+ caret: 8,
92
+ });
93
+ });
94
+ });
95
+
96
+ describe("the attached refs", () => {
97
+ test("attaches up to three and refuses the fourth", () => {
98
+ const store = new SkillAttachmentStore();
99
+ expect(store.attach(catalog[0]!)).toBe(true);
100
+ expect(store.attach(catalog[1]!)).toBe(true);
101
+ expect(store.attach(catalog[2]!)).toBe(true);
102
+ expect(store.full()).toBe(true);
103
+ expect(store.attach(catalog[3]!)).toBe(false);
104
+ expect(store.refs()).toHaveLength(3);
105
+ });
106
+
107
+ test("refuses the same Skill twice", () => {
108
+ const store = new SkillAttachmentStore();
109
+ expect(store.attach(catalog[0]!)).toBe(true);
110
+ expect(store.attach(catalog[0]!)).toBe(false);
111
+ });
112
+
113
+ test("detaches by ref and hands the list over on submission", () => {
114
+ const store = new SkillAttachmentStore();
115
+ store.attach(catalog[0]!);
116
+ store.attach(catalog[1]!);
117
+ store.detach("bot/daily-standup");
118
+ expect(store.take()).toEqual([
119
+ { schemaVersion: 1, source: "bot", slug: "standup-notes" },
120
+ ]);
121
+ expect(store.attached()).toEqual([]);
122
+ });
123
+
124
+ test("gives the refs back when a submission is refused", () => {
125
+ const store = new SkillAttachmentStore();
126
+ store.attach(catalog[0]!);
127
+ const held = [...store.attached()];
128
+ store.take();
129
+ store.restore(held);
130
+ expect(store.refs()).toEqual([
131
+ { schemaVersion: 1, source: "bot", slug: "daily-standup" },
132
+ ]);
133
+ });
134
+ });
135
+
136
+ describe("keyboard navigation", () => {
137
+ test("wraps at both ends and stays at zero on an empty list", () => {
138
+ expect(nextSkillHighlightV1(0, 3, 1)).toBe(1);
139
+ expect(nextSkillHighlightV1(2, 3, 1)).toBe(0);
140
+ expect(nextSkillHighlightV1(0, 3, -1)).toBe(2);
141
+ expect(nextSkillHighlightV1(0, 0, 1)).toBe(0);
142
+ });
143
+ });
@@ -0,0 +1,175 @@
1
+ // The composer's Skill-invocation state, as a pure store.
2
+ //
3
+ // GrokBot's users invoke a Skill with `/` or `@`
4
+ // (`docs/research/grokbot-computer.md` §2.8). Selecting one does *not* paste
5
+ // its text into the message: it attaches a ref, and the backend expands the
6
+ // body it resolves at the exact generation the Turn loads. That distinction is
7
+ // the whole point — a pasted body is a message the User could edit into
8
+ // something the Skill never said, while a ref is a name the Bot resolves.
9
+ //
10
+ // Everything here is framework-free so the ranking, the keyboard model and the
11
+ // three-chip bound are testable without mounting a component. The Vue
12
+ // component owns focus and rendering; it owns no rules.
13
+ import {
14
+ formatSkillRefV1,
15
+ MAX_INVOKED_SKILLS_V1,
16
+ type SkillRefV1,
17
+ } from "@frockbot/kernel-contracts";
18
+ import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
19
+
20
+ /** The characters that open the popover, in GrokBot's shape. */
21
+ export const SKILL_TRIGGER_CHARACTERS_V1 = ["/", "@"] as const;
22
+
23
+ /** The open popover: which trigger opened it, and what has been typed since. */
24
+ export interface SkillPopoverStateV1 {
25
+ trigger: "/" | "@";
26
+ /** Index in the text where the trigger character sits. */
27
+ at: number;
28
+ query: string;
29
+ }
30
+
31
+ /** One ranked candidate the popover offers. */
32
+ export interface SkillCandidateV1 {
33
+ entry: ClientSkillCatalogEntryV1;
34
+ /** Lower sorts first. Exposed so a test can assert the ordering's reason. */
35
+ rank: number;
36
+ }
37
+
38
+ function matchScore(entry: ClientSkillCatalogEntryV1, query: string): number {
39
+ // 0 is "no query": everything matches and the catalog's own order stands.
40
+ if (!query) return 0;
41
+ const needle = query.toLowerCase();
42
+ const slug = entry.skill.slug.toLowerCase();
43
+ const name = entry.name.toLowerCase();
44
+ const description = entry.description.toLowerCase();
45
+ if (slug === needle || name === needle) return 1;
46
+ if (slug.startsWith(needle) || name.startsWith(needle)) return 2;
47
+ if (slug.includes(needle) || name.includes(needle)) return 3;
48
+ if (description.includes(needle)) return 4;
49
+ return Number.POSITIVE_INFINITY;
50
+ }
51
+
52
+ /**
53
+ * The candidates a query offers, best first.
54
+ *
55
+ * Ties break on the canonical ref rather than on catalog order, so the list a
56
+ * User navigates with the arrow keys does not reshuffle when the backend
57
+ * happens to enumerate the instruction root in a different order.
58
+ */
59
+ export function rankSkillCandidatesV1(
60
+ catalog: readonly ClientSkillCatalogEntryV1[],
61
+ query: string,
62
+ options: { exclude?: readonly SkillRefV1[] } = {},
63
+ ): SkillCandidateV1[] {
64
+ const excluded = new Set(
65
+ (options.exclude ?? []).map((ref) => formatSkillRefV1(ref)),
66
+ );
67
+ return catalog
68
+ .filter((entry) => !excluded.has(entry.ref))
69
+ .map((entry) => ({ entry, rank: matchScore(entry, query) }))
70
+ .filter((candidate) => Number.isFinite(candidate.rank))
71
+ .sort(
72
+ (left, right) =>
73
+ left.rank - right.rank || left.entry.ref.localeCompare(right.entry.ref),
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Reads the open popover out of the composer's text and caret.
79
+ *
80
+ * A trigger opens the popover only at the start of the message or after
81
+ * whitespace, so an email address or a path in prose does not turn into a Skill
82
+ * picker, and any whitespace after the trigger closes it again.
83
+ */
84
+ export function skillPopoverForV1(
85
+ text: string,
86
+ caret: number,
87
+ ): SkillPopoverStateV1 | undefined {
88
+ const position = Math.max(0, Math.min(caret, text.length));
89
+ for (let index = position - 1; index >= 0; index -= 1) {
90
+ const character = text[index] ?? "";
91
+ if (/\s/u.test(character)) return undefined;
92
+ if (character === "/" || character === "@") {
93
+ const before = index === 0 ? "" : (text[index - 1] ?? "");
94
+ if (before !== "" && !/\s/u.test(before)) return undefined;
95
+ return {
96
+ trigger: character,
97
+ at: index,
98
+ query: text.slice(index + 1, position),
99
+ };
100
+ }
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /** The text after a selection: the trigger and its query are removed. */
106
+ export function textWithoutSkillTriggerV1(
107
+ text: string,
108
+ popover: SkillPopoverStateV1,
109
+ caret: number,
110
+ ): { text: string; caret: number } {
111
+ const end = Math.max(popover.at, Math.min(caret, text.length));
112
+ return {
113
+ text: `${text.slice(0, popover.at)}${text.slice(end)}`,
114
+ caret: popover.at,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * The attached refs, bounded and ordered.
120
+ *
121
+ * Deep and small: `attach`, `detach` and `take` are the only ways the list
122
+ * changes, so the composer can never submit more refs than the decoder admits
123
+ * and can never submit the same one twice.
124
+ */
125
+ export class SkillAttachmentStore {
126
+ #attached: ClientSkillCatalogEntryV1[] = [];
127
+
128
+ attached(): readonly ClientSkillCatalogEntryV1[] {
129
+ return this.#attached;
130
+ }
131
+
132
+ refs(): SkillRefV1[] {
133
+ return this.#attached.map((entry) => entry.skill);
134
+ }
135
+
136
+ full(): boolean {
137
+ return this.#attached.length >= MAX_INVOKED_SKILLS_V1;
138
+ }
139
+
140
+ /** True when the entry was attached; false when it was full or a duplicate. */
141
+ attach(entry: ClientSkillCatalogEntryV1): boolean {
142
+ if (this.full()) return false;
143
+ if (this.#attached.some((existing) => existing.ref === entry.ref)) {
144
+ return false;
145
+ }
146
+ this.#attached = [...this.#attached, entry];
147
+ return true;
148
+ }
149
+
150
+ detach(ref: string): void {
151
+ this.#attached = this.#attached.filter((entry) => entry.ref !== ref);
152
+ }
153
+
154
+ /** Empties the store and hands back what it held, for one submission. */
155
+ take(): SkillRefV1[] {
156
+ const refs = this.refs();
157
+ this.#attached = [];
158
+ return refs;
159
+ }
160
+
161
+ /** Puts a rejected submission's refs back, so nothing is lost on failure. */
162
+ restore(entries: readonly ClientSkillCatalogEntryV1[]): void {
163
+ this.#attached = entries.slice(0, MAX_INVOKED_SKILLS_V1);
164
+ }
165
+ }
166
+
167
+ /** Moves the popover's highlight, wrapping at both ends. */
168
+ export function nextSkillHighlightV1(
169
+ highlighted: number,
170
+ count: number,
171
+ direction: 1 | -1,
172
+ ): number {
173
+ if (count <= 0) return 0;
174
+ return (highlighted + direction + count) % count;
175
+ }