@frockbot/configuration-core 0.3.10 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/configuration-core",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,8 +12,8 @@
12
12
  "typecheck": "tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "dependencies": {
15
- "@frockbot/connection-core": "0.3.10",
16
- "@frockbot/kernel-composition": "0.3.10"
15
+ "@frockbot/connection-core": "0.3.12",
16
+ "@frockbot/kernel-composition": "0.3.12"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/bun": "1.3.6",
@@ -182,4 +182,40 @@ describe("bot/set-profile", () => {
182
182
  ),
183
183
  ).toEqual({ name: "Housework" });
184
184
  });
185
+
186
+ test("pins with an instant and unpins with the empty string", () => {
187
+ const at = "2026-09-03T10:15:00.000Z";
188
+ expect(
189
+ decodeBotSettingsViewV1(settings({ name: "Housework", pinnedAt: at })),
190
+ ).toMatchObject({ profile: { pinnedAt: at } });
191
+ expect(() =>
192
+ decodeBotSettingsViewV1(
193
+ settings({ name: "Housework", pinnedAt: "whenever" }),
194
+ ),
195
+ ).toThrow("profile.pinnedAt is invalid");
196
+
197
+ const current: BotProfile = { name: "Housework", pinnedAt: at };
198
+ // A later save must not reshuffle the pinned row: the instant is durable,
199
+ // so only an explicit change moves it.
200
+ expect(
201
+ applyBotProfilePatchV1(current, { title: "Chief" }, "user").pinnedAt,
202
+ ).toBe(at);
203
+ expect(applyBotProfilePatchV1(current, { pinnedAt: "" }, "user")).toEqual({
204
+ name: "Housework",
205
+ });
206
+ expect(
207
+ applyBotProfilePatchV1({ name: "Housework" }, { pinnedAt: at }, "user")
208
+ .pinnedAt,
209
+ ).toBe(at);
210
+ expect(() =>
211
+ decodeConfigurationCommandV1({
212
+ schemaVersion: 1,
213
+ type: "bot/set-profile",
214
+ commandId: "command-1",
215
+ botId: "primary",
216
+ expectedRevision: 3,
217
+ profile: { pinnedAt: "soon" },
218
+ }),
219
+ ).toThrow("profile.pinnedAt is invalid");
220
+ });
185
221
  });
package/src/index.test.ts CHANGED
@@ -1664,7 +1664,7 @@ describe("effective Bot model resolution", () => {
1664
1664
  */
1665
1665
  const flockModel: ModelBindingV1 = {
1666
1666
  connectionId: "flock-ai",
1667
- providerModelId: "@flock/auto",
1667
+ providerModelId: "@frock/auto",
1668
1668
  };
1669
1669
  const flockPackage: ExecutionPackageDefinition = {
1670
1670
  packageId: "provider-flock-ai",
@@ -1705,7 +1705,7 @@ describe("effective Bot model resolution", () => {
1705
1705
  connectionId: "flock-ai",
1706
1706
  packageId: "provider-flock-ai",
1707
1707
  connectionTypeId: "flock-ai-account",
1708
- displayName: "Flock AI",
1708
+ displayName: "Frock AI",
1709
1709
  state: "ready",
1710
1710
  providerType: "flock-ai",
1711
1711
  modelCatalog: {
@@ -1714,7 +1714,7 @@ describe("effective Bot model resolution", () => {
1714
1714
  state: "fresh",
1715
1715
  models: [
1716
1716
  {
1717
- providerModelId: "@flock/auto",
1717
+ providerModelId: "@frock/auto",
1718
1718
  displayName: "Auto",
1719
1719
  capabilities: { tools: true, vision: false, reasoning: true },
1720
1720
  source: "discovered",
package/src/index.ts CHANGED
@@ -99,6 +99,12 @@ export interface BotProfile {
99
99
  namedBy?: BotNameProvenanceV1;
100
100
  /** Keeps the Bot out of the default sidebar list without archiving it. */
101
101
  hiddenFromSidebar?: boolean;
102
+ /**
103
+ * When the User pinned this Bot, as an ISO 8601 instant. Absent means not
104
+ * pinned. The instant rather than a flag, so the pinned row keeps a stable
105
+ * order — earliest pin first — without a second ordering field.
106
+ */
107
+ pinnedAt?: string;
102
108
  }
103
109
 
104
110
  /**
@@ -112,6 +118,8 @@ export interface BotProfilePatchV1 {
112
118
  description?: string;
113
119
  title?: string;
114
120
  hiddenFromSidebar?: boolean;
121
+ /** An ISO 8601 instant pins the Bot; the empty string unpins it. */
122
+ pinnedAt?: string;
115
123
  }
116
124
 
117
125
  export interface BotNotificationPolicy {
@@ -1065,6 +1073,7 @@ const BOT_PROFILE_OPTIONAL_FIELDS = [
1065
1073
  "title",
1066
1074
  "namedBy",
1067
1075
  "hiddenFromSidebar",
1076
+ "pinnedAt",
1068
1077
  ] as const;
1069
1078
 
1070
1079
  function botProfile(value: unknown): BotProfile {
@@ -1096,9 +1105,21 @@ function botProfile(value: unknown): BotProfile {
1096
1105
  "profile.hiddenFromSidebar",
1097
1106
  ),
1098
1107
  }),
1108
+ ...(profile.pinnedAt === undefined
1109
+ ? {}
1110
+ : { pinnedAt: profileTimestamp(profile.pinnedAt, "profile.pinnedAt") }),
1099
1111
  };
1100
1112
  }
1101
1113
 
1114
+ /** An ISO 8601 instant a profile field carries. Never a duration or a count. */
1115
+ function profileTimestamp(value: unknown, label: string): string {
1116
+ const candidate = text(value, label, 64);
1117
+ if (!Number.isFinite(Date.parse(candidate))) {
1118
+ throw new ConfigurationDecodeError(`${label} is invalid`);
1119
+ }
1120
+ return candidate;
1121
+ }
1122
+
1102
1123
  /**
1103
1124
  * A patch field that carries text: a non-empty string sets it, and the empty
1104
1125
  * string clears it. A partial update has no other way to say "remove this".
@@ -1123,7 +1144,7 @@ function botProfilePatch(value: unknown): BotProfilePatchV1 {
1123
1144
  value,
1124
1145
  "profile",
1125
1146
  [],
1126
- ["name", "label", "description", "title", "hiddenFromSidebar"],
1147
+ ["name", "label", "description", "title", "hiddenFromSidebar", "pinnedAt"],
1127
1148
  );
1128
1149
  if (Reflect.ownKeys(patch).length === 0) {
1129
1150
  throw new ConfigurationDecodeError("profile has invalid fields");
@@ -1148,6 +1169,15 @@ function botProfilePatch(value: unknown): BotProfilePatchV1 {
1148
1169
  "profile.hiddenFromSidebar",
1149
1170
  ),
1150
1171
  }),
1172
+ // An instant pins; the empty string unpins, the same way text clears.
1173
+ ...(patch.pinnedAt === undefined
1174
+ ? {}
1175
+ : {
1176
+ pinnedAt:
1177
+ patchText(patch.pinnedAt, "profile.pinnedAt", 64) === ""
1178
+ ? ""
1179
+ : profileTimestamp(patch.pinnedAt, "profile.pinnedAt"),
1180
+ }),
1151
1181
  };
1152
1182
  }
1153
1183
 
@@ -1167,7 +1197,7 @@ export function applyBotProfilePatchV1(
1167
1197
  next.name = patch.name;
1168
1198
  next.namedBy = namedBy;
1169
1199
  }
1170
- for (const key of ["label", "description", "title"] as const) {
1200
+ for (const key of ["label", "description", "title", "pinnedAt"] as const) {
1171
1201
  const value = patch[key];
1172
1202
  if (value === undefined) continue;
1173
1203
  if (value === "") delete next[key];
@@ -99,6 +99,24 @@ function boundedText(value: unknown, label: string, maximum: number): string {
99
99
  return normalized;
100
100
  }
101
101
 
102
+ /**
103
+ * The built-in provider was named Flock AI before it was named Frock AI, and
104
+ * its model ids carried the provider's name. Bindings written before the
105
+ * rename are still in production storage, so a stored `@flock/…` id is read as
106
+ * the `@frock/…` id it has always meant. This is the one place that
107
+ * translation happens: every binding crosses this decoder, so resolution,
108
+ * catalog lookup, presentation and the provider request all see one spelling,
109
+ * and nothing writes the old one back.
110
+ */
111
+ const LEGACY_FROCK_MODEL_PREFIX = "@flock/";
112
+ const FROCK_MODEL_PREFIX = "@frock/";
113
+
114
+ function normalizedProviderModelId(id: string): string {
115
+ return id.startsWith(LEGACY_FROCK_MODEL_PREFIX)
116
+ ? `${FROCK_MODEL_PREFIX}${id.slice(LEGACY_FROCK_MODEL_PREFIX.length)}`
117
+ : id;
118
+ }
119
+
102
120
  /** The exact model binding DTO, decoded wherever it crosses a durable seam. */
103
121
  export function decodeModelBindingV1(value: unknown): ModelBindingV1 {
104
122
  const binding = record(value, "model");
@@ -115,10 +133,8 @@ export function decodeModelBindingV1(value: unknown): ModelBindingV1 {
115
133
  }
116
134
  return {
117
135
  connectionId: binding.connectionId,
118
- providerModelId: boundedText(
119
- binding.providerModelId,
120
- "model.providerModelId",
121
- 256,
136
+ providerModelId: normalizedProviderModelId(
137
+ boundedText(binding.providerModelId, "model.providerModelId", 256),
122
138
  ),
123
139
  };
124
140
  }