@frockbot/configuration-core 0.3.9 → 0.3.11

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.9",
3
+ "version": "0.3.11",
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.9",
16
- "@frockbot/kernel-composition": "0.3.9"
15
+ "@frockbot/connection-core": "0.3.11",
16
+ "@frockbot/kernel-composition": "0.3.11"
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
@@ -26,6 +26,7 @@ import {
26
26
  resolveEffectiveBotModelV1,
27
27
  type ExecutionPackageDefinition,
28
28
  type ModelBindingV1,
29
+ type UserSettingsViewV1,
29
30
  } from "./index.js";
30
31
  import {
31
32
  isApplicationDeploymentHash,
@@ -1657,6 +1658,144 @@ describe("effective Bot model resolution", () => {
1657
1658
  expect(effective.model).toBeUndefined();
1658
1659
  });
1659
1660
 
1661
+ /**
1662
+ * The real account shape: a platform provider that is always there, and a
1663
+ * separate provider Package the User switched on and bound their model to.
1664
+ */
1665
+ const flockModel: ModelBindingV1 = {
1666
+ connectionId: "flock-ai",
1667
+ providerModelId: "@flock/auto",
1668
+ };
1669
+ const flockPackage: ExecutionPackageDefinition = {
1670
+ packageId: "provider-flock-ai",
1671
+ version: "0.0.1",
1672
+ settings: [],
1673
+ capabilities: [
1674
+ {
1675
+ id: "flock-ai-models",
1676
+ kind: "model",
1677
+ connectionTypes: ["flock-ai-account"],
1678
+ },
1679
+ ],
1680
+ connectionTypes: [
1681
+ { id: "flock-ai-account", capabilities: ["flock-ai-models"] },
1682
+ ],
1683
+ };
1684
+ function withFlock(): UserSettingsViewV1 {
1685
+ const base = user();
1686
+ return {
1687
+ ...base,
1688
+ packages: [
1689
+ ...base.packages,
1690
+ {
1691
+ packageId: "provider-flock-ai",
1692
+ version: "0.0.1",
1693
+ state: "installed",
1694
+ },
1695
+ {
1696
+ packageId: "custom-models",
1697
+ version: "0.0.1",
1698
+ state: "installed",
1699
+ values: { model: accountModel },
1700
+ },
1701
+ ],
1702
+ connections: [
1703
+ ...base.connections,
1704
+ {
1705
+ connectionId: "flock-ai",
1706
+ packageId: "provider-flock-ai",
1707
+ connectionTypeId: "flock-ai-account",
1708
+ displayName: "Flock AI",
1709
+ state: "ready",
1710
+ providerType: "flock-ai",
1711
+ modelCatalog: {
1712
+ schemaVersion: 1,
1713
+ generation: "flock-catalog-1",
1714
+ state: "fresh",
1715
+ models: [
1716
+ {
1717
+ providerModelId: "@flock/auto",
1718
+ displayName: "Auto",
1719
+ capabilities: { tools: true, vision: false, reasoning: true },
1720
+ source: "discovered",
1721
+ },
1722
+ ],
1723
+ },
1724
+ safeMetadata: {},
1725
+ },
1726
+ ],
1727
+ platformModel: flockModel,
1728
+ };
1729
+ }
1730
+ const flockPackages = [...modelPackages, flockPackage];
1731
+
1732
+ test("falls back to the platform model when the bound provider is switched off", () => {
1733
+ const providerOff = (() => {
1734
+ const settings = withFlock();
1735
+ return {
1736
+ ...settings,
1737
+ packages: settings.packages.map((pkg) =>
1738
+ pkg.packageId === "provider-ollama-cloud"
1739
+ ? { ...pkg, state: "disabled" as const }
1740
+ : pkg,
1741
+ ),
1742
+ };
1743
+ })();
1744
+ const effective = resolveEffectiveBotModelV1({
1745
+ bot: { packageValues: {} },
1746
+ user: providerOff,
1747
+ packages: flockPackages,
1748
+ });
1749
+ expect(effective.source).toBe("platform");
1750
+ expect(effective.model).toEqual(flockModel);
1751
+ expect(effective.binding?.state).toBe("ready");
1752
+ expect(effective.fallback).toMatchObject({
1753
+ from: "account",
1754
+ model: accountModel,
1755
+ });
1756
+ });
1757
+
1758
+ test("falls back when a Bot's model has lost its Connection", () => {
1759
+ const settings = withFlock();
1760
+ const connectionGone: UserSettingsViewV1 = {
1761
+ ...settings,
1762
+ connections: settings.connections.filter(
1763
+ (connection) => connection.connectionId !== "ollama-work",
1764
+ ),
1765
+ };
1766
+ const effective = resolveEffectiveBotModelV1({
1767
+ bot: { packageValues: { "custom-models": { model: accountModel } } },
1768
+ user: connectionGone,
1769
+ packages: flockPackages,
1770
+ });
1771
+ expect(effective).toMatchObject({ source: "platform", model: flockModel });
1772
+ expect(effective.fallback?.from).toBe("bot");
1773
+ expect(effective.binding?.state).toBe("ready");
1774
+ });
1775
+
1776
+ test("keeps the failure when the platform model cannot stand in either", () => {
1777
+ // Nothing to degrade to: the platform bootstrap is bound to the same
1778
+ // switched-off Package, so the User still gets the repair sentence.
1779
+ const settings = withFlock();
1780
+ const bothOff: UserSettingsViewV1 = {
1781
+ ...settings,
1782
+ packages: settings.packages.map((pkg) =>
1783
+ pkg.packageId === "provider-ollama-cloud"
1784
+ ? { ...pkg, state: "disabled" as const }
1785
+ : pkg,
1786
+ ),
1787
+ platformModel,
1788
+ };
1789
+ const effective = resolveEffectiveBotModelV1({
1790
+ bot: { packageValues: {} },
1791
+ user: bothOff,
1792
+ packages: flockPackages,
1793
+ });
1794
+ expect(effective.source).toBe("account");
1795
+ expect(effective.binding?.state).toBe("unavailable");
1796
+ expect(effective.fallback).toBeUndefined();
1797
+ });
1798
+
1660
1799
  test("reports none only when no Package or platform model supplies one", () => {
1661
1800
  const withoutPlatform = user();
1662
1801
  delete withoutPlatform.platformModel;
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 {
@@ -604,6 +612,17 @@ export interface EffectiveBotModelV1 {
604
612
  source: "bot" | "account" | "platform" | "none";
605
613
  model?: ModelBindingV1;
606
614
  binding?: ResolvedModelBindingV1;
615
+ /**
616
+ * Set when a Bot or account choice was present but could not bind — its
617
+ * provider Package was switched off, or its Connection is gone — and the
618
+ * platform bootstrap answered in its place. The chosen scope and the reason
619
+ * it failed survive so the client can say the Bot is running on the default.
620
+ */
621
+ fallback?: {
622
+ from: "bot" | "account";
623
+ model: ModelBindingV1;
624
+ failure: string;
625
+ };
607
626
  }
608
627
 
609
628
  /**
@@ -684,23 +703,68 @@ export function resolveEffectiveBotModelV1(input: {
684
703
  };
685
704
  };
686
705
 
706
+ /**
707
+ * A chosen model whose provider Package has been switched off, or whose
708
+ * Connection is gone, must not stop the Bot answering: the platform
709
+ * bootstrap stands in for it. Only a binding failure degrades this way — a
710
+ * scope conflict is the User's own contradiction and still fails closed,
711
+ * because there is no single choice to stand in for.
712
+ */
713
+ const platformStandIn = (
714
+ from: "bot" | "account",
715
+ model: ModelBindingV1,
716
+ binding: ResolvedModelBindingV1,
717
+ ): EffectiveBotModelV1 | undefined => {
718
+ const platform = input.user.platformModel;
719
+ if (!platform) return undefined;
720
+ if (
721
+ platform.connectionId === model.connectionId &&
722
+ platform.providerModelId === model.providerModelId
723
+ ) {
724
+ return undefined;
725
+ }
726
+ const platformBinding = resolveBotModelBindingV1({
727
+ model: platform,
728
+ user: input.user,
729
+ packages: input.packages,
730
+ });
731
+ if (platformBinding.state === "unavailable") return undefined;
732
+ return {
733
+ source: "platform",
734
+ model: structuredClone(platform),
735
+ binding: platformBinding,
736
+ fallback: {
737
+ from,
738
+ model: structuredClone(model),
739
+ failure:
740
+ binding.failure ?? "This Bot's model isn't available right now.",
741
+ },
742
+ };
743
+ };
744
+
687
745
  for (const scope of ["bot", "user"] as const) {
688
746
  const resolved = fromScope(scope);
747
+ const source = scope === "bot" ? "bot" : "account";
689
748
  if (resolved?.conflict) {
690
749
  return {
691
- source: scope === "bot" ? "bot" : "account",
750
+ source,
692
751
  binding: { state: "unavailable", failure: resolved.conflict },
693
752
  };
694
753
  }
695
754
  if (resolved?.model) {
755
+ const binding = resolveBotModelBindingV1({
756
+ model: resolved.model,
757
+ user: input.user,
758
+ packages: input.packages,
759
+ });
760
+ if (binding.state === "unavailable") {
761
+ const standIn = platformStandIn(source, resolved.model, binding);
762
+ if (standIn) return standIn;
763
+ }
696
764
  return {
697
- source: scope === "bot" ? "bot" : "account",
765
+ source,
698
766
  model: structuredClone(resolved.model),
699
- binding: resolveBotModelBindingV1({
700
- model: resolved.model,
701
- user: input.user,
702
- packages: input.packages,
703
- }),
767
+ binding,
704
768
  };
705
769
  }
706
770
  }
@@ -1009,6 +1073,7 @@ const BOT_PROFILE_OPTIONAL_FIELDS = [
1009
1073
  "title",
1010
1074
  "namedBy",
1011
1075
  "hiddenFromSidebar",
1076
+ "pinnedAt",
1012
1077
  ] as const;
1013
1078
 
1014
1079
  function botProfile(value: unknown): BotProfile {
@@ -1040,9 +1105,21 @@ function botProfile(value: unknown): BotProfile {
1040
1105
  "profile.hiddenFromSidebar",
1041
1106
  ),
1042
1107
  }),
1108
+ ...(profile.pinnedAt === undefined
1109
+ ? {}
1110
+ : { pinnedAt: profileTimestamp(profile.pinnedAt, "profile.pinnedAt") }),
1043
1111
  };
1044
1112
  }
1045
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
+
1046
1123
  /**
1047
1124
  * A patch field that carries text: a non-empty string sets it, and the empty
1048
1125
  * string clears it. A partial update has no other way to say "remove this".
@@ -1067,7 +1144,7 @@ function botProfilePatch(value: unknown): BotProfilePatchV1 {
1067
1144
  value,
1068
1145
  "profile",
1069
1146
  [],
1070
- ["name", "label", "description", "title", "hiddenFromSidebar"],
1147
+ ["name", "label", "description", "title", "hiddenFromSidebar", "pinnedAt"],
1071
1148
  );
1072
1149
  if (Reflect.ownKeys(patch).length === 0) {
1073
1150
  throw new ConfigurationDecodeError("profile has invalid fields");
@@ -1092,6 +1169,15 @@ function botProfilePatch(value: unknown): BotProfilePatchV1 {
1092
1169
  "profile.hiddenFromSidebar",
1093
1170
  ),
1094
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
+ }),
1095
1181
  };
1096
1182
  }
1097
1183
 
@@ -1111,7 +1197,7 @@ export function applyBotProfilePatchV1(
1111
1197
  next.name = patch.name;
1112
1198
  next.namedBy = namedBy;
1113
1199
  }
1114
- for (const key of ["label", "description", "title"] as const) {
1200
+ for (const key of ["label", "description", "title", "pinnedAt"] as const) {
1115
1201
  const value = patch[key];
1116
1202
  if (value === undefined) continue;
1117
1203
  if (value === "") delete next[key];