@bettercms-ai/mcp 0.84.5 → 0.85.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.
package/dist/index.js CHANGED
@@ -2504,6 +2504,72 @@ var DOCK_ICON_NAMES = [
2504
2504
  ];
2505
2505
  var DOCK_ICON_NAME_SET = new Set(DOCK_ICON_NAMES);
2506
2506
 
2507
+ // ../types/src/kit-v2.ts
2508
+ var OPEN_MODES = ["inline", "drill", "popover", "dialog"];
2509
+ var SHOW_IF_SCOPES = ["parent", "root"];
2510
+ var V2_SHAPES = ["block", "page", "entry", "global", "object"];
2511
+ var V2_PLACEMENTS = ["inline", "section", "page"];
2512
+ var KIT_CATEGORIES = [
2513
+ "Navigation & chrome",
2514
+ "Heroes",
2515
+ "Features & storytelling",
2516
+ "Social proof",
2517
+ "Media & galleries",
2518
+ "Commerce",
2519
+ "Pricing & plans",
2520
+ "Conversion & forms",
2521
+ "Listings & feeds",
2522
+ "Long-form & docs",
2523
+ "Company & people",
2524
+ "Events & places",
2525
+ "Data & interactive",
2526
+ "Advanced patterns"
2527
+ ];
2528
+ var UI_REFUSAL_CODES = {
2529
+ schema: [
2530
+ "facet_unclassified",
2531
+ "excluded_facet",
2532
+ "depth_exceeded",
2533
+ "validation_failed",
2534
+ "hash_mismatch",
2535
+ "non_finite_number",
2536
+ "shape_kind_mismatch",
2537
+ "block_field_unrenderable",
2538
+ // rich text (richtext-features.ts richTextRefusal): `richtext.<code>:<detail>`
2539
+ "block_not_accepted",
2540
+ "derived_field",
2541
+ "feature_unknown"
2542
+ ],
2543
+ build: [
2544
+ "peer_missing",
2545
+ "kit_major_unsupported",
2546
+ "compile_error",
2547
+ "nondeterministic",
2548
+ "size_exceeded",
2549
+ // CMS-stored UI (dock-ui.ts): an import off the allow-list; one name in both the repo and the CMS
2550
+ "import_denied",
2551
+ "name_conflict"
2552
+ ],
2553
+ protocol: ["unsupported", "handshake_timeout", "origin_mismatch", "write_denied"],
2554
+ runtime: ["crashed", "blank", "csp_violation"],
2555
+ // not_approved: publish_dock_ui on a version no one approved
2556
+ policy: ["confirmation_required", "flag_off", "not_approved"]
2557
+ };
2558
+ var UI_REFUSAL_IDS = Object.keys(UI_REFUSAL_CODES).flatMap(
2559
+ (f) => UI_REFUSAL_CODES[f].map((c) => `${f}.${c}`)
2560
+ );
2561
+ var KEY_RESERVED = /* @__PURE__ */ new Set(["derived", "layout", "page"]);
2562
+ var DERIVE_MINTED_RESERVED = /* @__PURE__ */ new Set([...KEY_RESERVED, "id"]);
2563
+
2564
+ // ../types/src/dock-ui.ts
2565
+ var DOCK_UI_LIMITS = {
2566
+ sourcesPerProject: 64,
2567
+ bytesPerProject: 4 * 1024 * 1024,
2568
+ bytesPerVersion: 256 * 1024,
2569
+ bytesPerBundle: 512 * 1024
2570
+ };
2571
+ var DOCK_UI_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
2572
+
2507
2573
  // src/tools.ts
2508
2574
  import { BetterCMSError } from "@bettercms-ai/sdk";
2509
2575
 
@@ -2742,9 +2808,10 @@ var MODEL_UPDATE_KEYS = [
2742
2808
  "fieldOrder",
2743
2809
  "fieldLabel",
2744
2810
  "fieldHelpText",
2745
- "override"
2811
+ "override",
2812
+ "v2"
2746
2813
  ];
2747
- var NOTHING_TO_UPDATE = "Nothing to update: update_content_model changes the model's name, slug, description, urlPattern, fieldsets and field chrome (fieldUi, fieldFieldset, fieldOrder, fieldLabel, fieldHelpText). `fields` is not an argument: add a field with add_field, change one with update_field, delete with removeFields (remove_page_fields for a page).";
2814
+ var NOTHING_TO_UPDATE = "Nothing to update: update_content_model changes the model's name, slug, description, urlPattern, fieldsets, its V2 block (v2) and field chrome (fieldUi, fieldFieldset, fieldOrder, fieldLabel, fieldHelpText). `fields` is not an argument: add a field with add_field, change one with update_field, delete with removeFields (remove_page_fields for a page).";
2748
2815
  var updatesNothing = (args) => args.removeFields === void 0 && args.allowFieldRemoval === void 0 && !MODEL_UPDATE_KEYS.some((k) => args[k] !== void 0);
2749
2816
  function planFieldRemoval(model, keys, callerIfMatch) {
2750
2817
  const fields = model.fields ?? [];
@@ -2761,6 +2828,19 @@ function planFieldRemoval(model, keys, callerIfMatch) {
2761
2828
  const kept = fields.filter((f) => !drop.has(f.key));
2762
2829
  return ifMatch === void 0 ? { ok: true, fields: kept } : { ok: true, fields: kept, ifMatch };
2763
2830
  }
2831
+ var SOURCES_SERVER_OWNED = "sources_server_owned: `sources` is stamped by the server on every write (who last set ui, input and ai) and is never taken from a client. Drop it and send the facet itself.";
2832
+ function sourcesIn(fields, at = "") {
2833
+ for (const f of Array.isArray(fields) ? fields : []) {
2834
+ if (!f || typeof f !== "object") continue;
2835
+ const path = at ? `${at}.${String(f.key)}` : String(f.key);
2836
+ if (f.sources !== void 0) return path;
2837
+ for (const list2 of CHILD_LISTS) {
2838
+ const hit = sourcesIn(getIn(f, list2), path);
2839
+ if (hit) return hit;
2840
+ }
2841
+ }
2842
+ return null;
2843
+ }
2764
2844
  var CHILD_LISTS = [
2765
2845
  ["fields"],
2766
2846
  ["config", "zones", "nonRepeatable"],
@@ -2793,16 +2873,17 @@ function fieldAt(fields, keys) {
2793
2873
  patchAt(fields, keys, (f) => (found = f, f));
2794
2874
  return found;
2795
2875
  }
2796
- function allKeys(fields) {
2797
- return fields.flatMap((f) => [
2798
- String(f.key),
2799
- ...CHILD_LISTS.flatMap((list2) => {
2800
- const kids = getIn(f, list2);
2801
- return Array.isArray(kids) ? allKeys(kids) : [];
2802
- })
2803
- ]);
2876
+ function siblingKeys(fields, keys) {
2877
+ if (keys.length <= 1) return fields.map((f) => String(f.key));
2878
+ const parent = fieldAt(fields, keys.slice(0, -1));
2879
+ const self = keys[keys.length - 1];
2880
+ for (const list2 of CHILD_LISTS) {
2881
+ const kids = parent ? getIn(parent, list2) : void 0;
2882
+ if (Array.isArray(kids) && kids.some((k) => k.key === self)) return kids.map((k) => String(k.key));
2883
+ }
2884
+ return [];
2804
2885
  }
2805
- var CLEARABLE = ["helpText", "showIf", "requiredIf", "compare", "ui", "placement", "readOnly", "lockReason", "helpMore", "advanced", "recommendedMax", "openMode", "localized"];
2886
+ var CLEARABLE = ["helpText", "showIf", "requiredIf", "compare", "ui", "placement", "readOnly", "lockReason", "helpMore", "advanced", "recommendedMax", "openMode", "localized", "ai"];
2806
2887
  var SETTABLE = ["label", "required", ...CLEARABLE];
2807
2888
  function mergeConfig(current, patch) {
2808
2889
  const out = { ...current ?? {} };
@@ -2821,6 +2902,7 @@ function helpTextEcho(helpText, field) {
2821
2902
  return echoes ? `helpText repeats the field's label or value, which the editor already shows. Say what belongs here or its format in under 60 characters ("Whole number, no currency symbol."), or send null for none.` : null;
2822
2903
  }
2823
2904
  function planFieldEdit(model, edit, callerIfMatch) {
2905
+ if (edit.sources !== void 0) return { ok: false, error: SOURCES_SERVER_OWNED };
2824
2906
  const fields = model.fields ?? [];
2825
2907
  const keys = typeof edit.path === "string" && edit.path ? edit.path.split(".") : [];
2826
2908
  const current = keys.length ? fieldAt(fields, keys) : void 0;
@@ -2835,12 +2917,12 @@ function planFieldEdit(model, edit, callerIfMatch) {
2835
2917
  };
2836
2918
  }
2837
2919
  const rename = typeof edit.key === "string" && edit.key !== current.key ? edit.key : void 0;
2838
- if (rename !== void 0 && allKeys(fields).includes(rename)) {
2839
- return { ok: false, error: `The key '${rename}' is already used in this schema; keys share one flat namespace, nested fields included.` };
2920
+ if (rename !== void 0 && siblingKeys(fields, keys).includes(rename)) {
2921
+ return { ok: false, error: `The key '${rename}' is already used by a field beside '${keys.join(".")}'. Two siblings can't share a key (the same key in a different group is fine).` };
2840
2922
  }
2841
2923
  const changes = SETTABLE.filter((k) => edit[k] !== void 0);
2842
2924
  if (!retype && rename === void 0 && edit.config === void 0 && changes.length === 0) {
2843
- return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, requiredIf, compare, ui, placement, readOnly, lockReason, helpMore, advanced, recommendedMax, openMode, localized, type or key." };
2925
+ return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, requiredIf, compare, ui, placement, readOnly, lockReason, helpMore, advanced, recommendedMax, openMode, localized, ai, type or key." };
2844
2926
  }
2845
2927
  const echo = helpTextEcho(edit.helpText, { label: edit.label ?? current.label, defaultValue: current.defaultValue });
2846
2928
  if (echo) return { ok: false, error: echo };
@@ -2869,6 +2951,7 @@ function planFieldEdit(model, edit, callerIfMatch) {
2869
2951
  };
2870
2952
  }
2871
2953
  function planFieldAdd(model, field, after, callerIfMatch) {
2954
+ if (field.sources !== void 0 || sourcesIn(field.fields)) return { ok: false, error: SOURCES_SERVER_OWNED };
2872
2955
  const fields = model.fields ?? [];
2873
2956
  const keys = field.key.split(".");
2874
2957
  const childKey = keys.pop();
@@ -2879,8 +2962,8 @@ function planFieldAdd(model, field, after, callerIfMatch) {
2879
2962
  if (lists.length > 1) return { ok: false, error: `'${parentPath}' holds two lists of fields (a fixed part and a repeating part); add this field in the dashboard, where you pick which.` };
2880
2963
  const list2 = lists[0] ?? (parent.type === "group" || parent.type === "repeater" ? ["fields"] : void 0);
2881
2964
  if (!list2) return { ok: false, error: `'${parentPath}' is a ${String(parent.type)} field: only a group or a repeater holds fields.` };
2882
- if (allKeys(fields).includes(childKey)) return { ok: false, error: `The key '${childKey}' is already used in this schema; keys share one flat namespace, nested fields included.` };
2883
2965
  const kids = getIn(parent, list2) ?? [];
2966
+ if (kids.some((k) => k.key === childKey)) return { ok: false, error: `The key '${childKey}' is already used in '${parentPath}'. Two siblings can't share a key (the same key in a different group is fine).` };
2884
2967
  const at = after === void 0 ? kids.length : kids.findIndex((f) => f.key === after) + 1;
2885
2968
  if (after !== void 0 && at === 0) return { ok: false, error: `after: '${after}' is not a field of '${parentPath}'. Its fields: ${kids.map((f) => `'${String(f.key)}'`).join(", ") || "none"}.` };
2886
2969
  const next = patchAt(fields, keys, (p) => setIn(p, list2, [...kids.slice(0, at), { ...field, key: childKey }, ...kids.slice(at)]));
@@ -2959,6 +3042,14 @@ var LISTING_TAIL = /* @__PURE__ */ new Set([
2959
3042
  "start_integration_signin",
2960
3043
  "save_integration_template",
2961
3044
  "install_integration_template",
3045
+ // CMS-stored custom dock UI: reached for once the stock controls and ui keys fall short.
3046
+ "write_dock_ui",
3047
+ "approve_dock_ui",
3048
+ "validate_dock_ui",
3049
+ "publish_dock_ui",
3050
+ "list_dock_ui",
3051
+ // A whole V2 schema at once: reached for when the field-by-field tools would take dozens of calls.
3052
+ "write_schema",
2962
3053
  // The ABM campaign lane — also hidden from the default listing (ABM_TOOLS below).
2963
3054
  "research_account",
2964
3055
  "build_abm_page",
@@ -3010,7 +3101,7 @@ var toolMeta = (name) => ALWAYS_LOADED.has(name) ? { "anthropic/alwaysLoad": tru
3010
3101
  var TOOL_LOADING_INSTRUCTION = `TOOL SCHEMAS: a client with tool search lists most BetterCMS tools by name only. Before the first call to a tool whose schema you have not been shown, load it with the client's tool search, and load every tool the task needs in ONE search (for a renaming pass: update_field, update_content_model, update_component, list_components). A call made without the schema is refused by the client before it reaches BetterCMS ("Invalid tool parameters"): that is this, never a BetterCMS error, and never a reason to change the arguments.`;
3011
3102
 
3012
3103
  // src/tool-text.ts
3013
- var ADD_FIELD_DESCRIPTION = "Append ONE field to a content model (`modelId`) or to a page's schema (`pageId`) \u2014 additive, never overwrites; a key already in use is refused. For a section/zone add ONE 'group' (fixed block) or 'repeater' (repeating list) carrying its child `fields`, not the zone's inner fields as separate top-level fields. Pass `after` to insert it after an existing top-level field instead of at the end; key 'a.b' adds b inside group/repeater a (after: a sibling); `ui`, `helpText`, `showIf`, `placement` and `fieldsetId` declare how it presents in the editor \u2014 read them back with get_content_model or get_page. On a page a 'group' is stored as type 'array' with `config.zones.nonRepeatable`: entry and delivery data carry `<key>.nonRepeatable.<sub>`, while data-bcms-field paths are `<key>.<sub>`. Schema only \u2014 never places a section; use componentize_sections to turn a group into a section. `multiselect` stores a string[] of `config.options`; `icon` is a BetterCMS icon name or an uploaded icon. To change a field that exists, use update_field. V2 keys (object, openMode, advanced, helpMore, recommendedMax, config.scale): \xA714 of bettercms://playbook/schema. Cross-field: `requiredIf` (showIf's shape) and `compare` [{op:gt|gte|lt|lte|eq|neq, field, message?}].";
3104
+ var ADD_FIELD_DESCRIPTION = "Append ONE field to a content model (`modelId`) or to a page's schema (`pageId`) \u2014 additive, never overwrites; a key already in use is refused. For a section/zone add ONE 'group' (fixed block) or 'repeater' (repeating list) carrying its child `fields`, not the zone's inner fields as separate top-level fields. Pass `after` to insert it after an existing top-level field instead of at the end; key 'a.b' adds b inside group/repeater a (after: a sibling); `ui`, `helpText`, `showIf`, `placement` and `fieldsetId` declare how it presents in the editor \u2014 read them back with get_content_model or get_page. On a page a 'group' is stored as type 'array' with `config.zones.nonRepeatable`: entry and delivery data carry `<key>.nonRepeatable.<sub>`, while data-bcms-field paths are `<key>.<sub>`. Schema only \u2014 never places a section; use componentize_sections to turn a group into a section. `multiselect` stores a string[] of `config.options`; `icon` is a BetterCMS icon name or an uploaded icon. To change a field that exists, use update_field. V2 keys (object, openMode, advanced, helpMore, recommendedMax, ai, config.scale): \xA714 of bettercms://playbook/schema. Cross-field: `requiredIf` (showIf's shape) and `compare` [{op:gt|gte|lt|lte|eq|neq, field, message?}].";
3014
3105
  var V2_LEAF_TYPES_NOTE = "`tags`: string[] taxonomy (config.options suggestions, max, allowCustom). `rating`: 0..config.max (5), step 1|0.5. `code`: {language?, code} or a string (config.languages).";
3015
3106
  var MOVE_STAGE_DESCRIPTION = "Move a content entry (`entryId`) or a page (`pageId`) to another editorial stage \u2014 this is how you take it OUT of review so it can be published. `workflowStage` is a stage key from get_workflow_board; pass `fromStage` to 409 if someone moved it first. Moving into the publish-gate stage is an approval and needs a publish-enabled connection. Publish afterwards with update_content_entry or update_page status:'published' \u2014 approving and publishing are deliberately two separate calls.";
3016
3107
  var WINDOW_NOTE = "{from?, until?}, ISO 8601 with offset: shown only between the two instants; null clears (skill publishing).";
@@ -3056,7 +3147,15 @@ var NEW_TOOL_TEXT = {
3056
3147
  start_integration_signin: "Start the vendor sign-in of an integration with `oauth`: a `url` the USER opens to sign in with their own OAuth app (register `redirectUri` in it first). Tokens stay on the server.",
3057
3148
  save_integration_template: "Save an integration as a workspace template: its shape, never a secret value or its settings, with copies of the collections its syncs write into.",
3058
3149
  install_integration_template: "Install a workspace template on this project: missing collections are created and the integration waits for approval (`approval.approveUrl`); `secretsNeeded` lists the env vars to set.",
3059
- test_integration_fetch: "Call the vendor once through the server, as the integration: an approved host only, with the integration's `inject` secrets added server-side and every secret redacted from the answer. The request itself takes no {{...}} templates, and the token endpoint cannot be called. Use it to read the response shape before writing a sync: `items`, `externalKey` and `map` are JSON Pointers into it. Bodies over 1 MB come back truncated. 409 integration_not_approved: an admin must approve it first."
3150
+ test_integration_fetch: "Call the vendor once through the server, as the integration: an approved host only, with the integration's `inject` secrets added server-side and every secret redacted from the answer. The request itself takes no {{...}} templates, and the token endpoint cannot be called. Use it to read the response shape before writing a sync: `items`, `externalKey` and `map` are JSON Pointers into it. Bodies over 1 MB come back truncated. 409 integration_not_approved: an admin must approve it first.",
3151
+ // CMS-stored custom dock UI: write → approve (the user's yes) → validate → publish. Recipe: skill dock-input.
3152
+ write_dock_ui: "Save a custom dock input or panel (one .tsx `source`) as a new DRAFT version of `name` (a letter, then [a-z0-9-], \u226448), what `cms:<name>` imports resolve. Imports: react, react-dom, @bettercms-ai/kit/*, @bettercms-ai/dock-input, cms:<name>. Same bytes write nothing. Skill dock-input",
3153
+ approve_dock_ui: "Approve the newest `version` for publishing. Show the user the source first; userApproved: true only after their yes. A later write drops it",
3154
+ validate_dock_ui: "Check one version (default newest) without publishing: allowed imports, size caps, a name the repo also has. Answers the build receipt",
3155
+ publish_dock_ui: "Publish an APPROVED version (default newest): validates first, refusing policy.not_approved, build.name_conflict or the build's refusals, then rebuilds the project's dock UI release",
3156
+ list_dock_ui: "The project's dock UI sources, with usage against the limits",
3157
+ // A V2 TypeSchema into a model (lane fields, through a change set) or a component's props (lane props).
3158
+ write_schema: "Write a kit V2 TypeSchema into a model (lane fields, as a change set; model: null creates) or a component's props (lane props). Run dryRun first: a write whose `loss` is not all in acceptLoss is refused schema.loss_not_accepted"
3060
3159
  };
3061
3160
  var DELETE_PAGE_RUNTIME_BUNDLE = " Or, with no pageId, `runtimeBundle`: 'report' lists the duplicate pages derived from a server app's own bundle (`app/` folders); 'apply' retires them and their empty folders.";
3062
3161
  var FIELD_LOCKED_NOTE = "A readOnly 'developer' field keeps its stored value: changing or omitting it without schema access is 403 FIELD_LOCKED.";
@@ -3069,8 +3168,10 @@ var PARAM_TEXT = {
3069
3168
  readOnly: "'developer' = the value and this lock change only with schema access (needs lockReason); true = an editor marker",
3070
3169
  lockReason: "which system owns the value (\u2264 200 chars)",
3071
3170
  lockClears: "as on add_field; null clears it",
3072
- helpText: "shown under the field. Most fields get none: write it only for a format or unit, a place the canvas cannot show, a real constraint or a fallback, as one plain sentence under 90 characters with no semicolons, no dashes and no values from the page, never its label or value. Omitted: the platform writes it where one of those reasons holds.",
3171
+ helpText: "shown under the field. Most fields get none: write it only for a format or unit, a place the canvas cannot show, a real constraint or a fallback, as one plain sentence of at most 160 characters with no semicolons, no dashes and no values from the page, never its label or value. Omitted: the platform writes it where one of those reasons holds.",
3073
3172
  helpMore: "longer help behind the \u24D8 next to the label, when one helpText line cannot carry it",
3173
+ fieldAi: "true = the editor offers its AI assist on this field",
3174
+ modelV2: "the V2 block {shape?, placement?, category?, variants?: {slug: {icon?, description?}}}; null clears it",
3074
3175
  advanced: "true = a rare or developer setting: it collects in a closed 'Advanced' section",
3075
3176
  recommendedMax: "a SOFT limit (characters, items or value): the editor warns past it, never refuses",
3076
3177
  openMode: "object/array/group/repeater: how the value (or each row) opens: 'inline', 'drill', 'popover' or 'dialog'",
@@ -3106,7 +3207,12 @@ var PARAM_TEXT = {
3106
3207
  focalX: "focal point, 0-1 across from the left edge; null resets to the centre",
3107
3208
  focalY: "focal point, 0-1 down from the top edge; null resets to the centre",
3108
3209
  mergeHref: "pageId only: the link field paired with `path` (the label). Both become ONE `button` field keyed `key` (default: their stem, primaryCtaHref \u2192 primaryCta). Each entry's DRAFT gets {label, href} and keeps the old values; the live copy changes on publish. ifMatch is the page's version. 409 CODE_RENDERED_ROUTE: do its `next` recipe in code",
3109
- confirmedInput: "true only after the user explicitly approved adding this custom input (`ui.input`); else setting or changing one is refused input_needs_confirmation. Removing one needs no approval"
3210
+ confirmedInput: "true only after the user explicitly approved adding this custom input (`ui.input`); else setting or changing one is refused input_needs_confirmation. Removing one needs no approval",
3211
+ dockUiUserApproved: "true only after the user said yes to this version",
3212
+ dockUiTarget: "per scope: field {modelId|pageId|componentId, path}, section {componentId}, page {pageId}, layout {}",
3213
+ writeSchemaModel: "model or component id/slug; null creates a model",
3214
+ writeSchemaAcceptLoss: "loss keys or paths you accept",
3215
+ writeSchemaIfMatch: "the dry run's `version`; needed to write over a stored row"
3110
3216
  };
3111
3217
  var INTEGRATION_PARAM_TEXT = {
3112
3218
  integration: "the integration's id or slug (from list_integrations)",
@@ -3136,7 +3242,7 @@ var INTEGRATION_PARAM_TEXT = {
3136
3242
  var INPUT_CONFIRMED_HEADER = "x-bcms-confirmed-input";
3137
3243
  var BUTTON_FIELD_NOTE = "'button' is ONE call to action, value {label, href, variant? (a Button family look), size?, arrow?, icon?, newTab?, rel?, fullWidth?} (arrow:false, icon:null off): never a text field beside a link field.";
3138
3244
  var LINK_VALUE_NOTE = "A link or button href: a URL string or {type: url|page|entry|email|phone|anchor|file, href, title?, newTab?, rel?, section?, ref: page/entry id}.";
3139
- var UPDATE_FIELD_DESCRIPTION = "Change ONE existing field of a content model (`modelId`) or of a page's schema (`pageId`), addressed by `path`: its key, or dot-separated keys through groups and repeaters ('hero.title'). Set its label, helpText, required, validation `config` (merged by key; null deletes one), showIf, requiredIf, compare, ui, placement, readOnly, lockReason, or the V2 helpMore, advanced, recommendedMax and openMode (null clears those). The rest of the schema is written back unchanged at the version read; a 409 means it changed since, so read again. `type` changes how every stored value is read and needs allowRetype: true. `key` renames it: stored values stay under the old key, so the route answers 409 with the entry count unless allowFieldRemoval: true discards them \u2014 migrate the values first. Ask the user before a retype or a rename. A page's label + link pair (primaryCtaText + primaryCtaHref) becomes one button with `mergeHref`.";
3245
+ var UPDATE_FIELD_DESCRIPTION = "Change ONE existing field of a content model (`modelId`) or of a page's schema (`pageId`), addressed by `path`: its key, or dot-separated keys through groups and repeaters ('hero.title'). Set its label, helpText, required, validation `config` (merged by key; null deletes one), showIf, requiredIf, compare, ui, placement, readOnly, lockReason, or the V2 helpMore, advanced, recommendedMax, openMode and ai (null clears those; `sources` is server-owned and refused). The rest of the schema is written back unchanged at the version read; a 409 means it changed since, so read again. `type` changes how every stored value is read and needs allowRetype: true. `key` renames it: stored values stay under the old key, so the route answers 409 with the entry count unless allowFieldRemoval: true discards them \u2014 migrate the values first. Ask the user before a retype or a rename. A page's label + link pair (primaryCtaText + primaryCtaHref) becomes one button with `mergeHref`.";
3140
3246
  var GET_SEO_DESCRIPTION = "Get the connected project's site-wide SEO settings \u2014 every key update_seo writes: seoDefaults (metaTitle, metaDescription, ogImage, twitterHandle, og, twitter, collectionSchemaTypes), siteSchema (the site's JSON-LD), robotsConfig, sitemapConfig, rssConfig, indexnowConfig and searchConfig.";
3141
3247
  var UPDATE_SEO_DESCRIPTION = "Set the connected project's site-wide SEO defaults and config. Each key you send is REPLACED whole (not deep-merged), so read get_seo and send the complete object. `seoDefaults` applies to every page that does not override it, and its `collectionSchemaTypes` maps a collection's model id to the schema.org type of its entries. `siteSchema` is the site's own JSON-LD (Organization/WebSite, emitted on the home page). `robotsConfig`, `sitemapConfig` and `rssConfig` shape robots.txt, sitemap.xml and the feed; `indexnowConfig` pings search engines on publish; `searchConfig` is site search. A key the plan does not include answers 403 FEATURE_LOCKED. Rebuilds the site.";
3142
3248
  var SEO_KEY_TEXT = {
@@ -3402,13 +3508,14 @@ var OPEN_MODE_INVALID = "openMode_invalid: openMode must be one of 'inline', 'dr
3402
3508
  var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
3403
3509
  var fieldKey = z.string().regex(/^[a-zA-Z0-9_-]+$/, "letters, numbers, underscores, and hyphens only");
3404
3510
  var dockIconArg = z.string().min(1).max(40).refine((v) => DOCK_ICON_SET.has(v), `not a dock icon, see ${DOCK_ICONS_URI}`);
3405
- var fieldCondition = z.object({
3511
+ var fieldCondition = z.strictObject({
3406
3512
  match: z.enum(["all", "any"]),
3407
3513
  rules: z.array(
3408
- z.object({
3514
+ z.strictObject({
3409
3515
  field: z.string().describe("the BARE KEY of another field on this same model"),
3410
3516
  op: z.enum(["eq", "neq", "in", "notIn", "gt", "lt", "isSet", "notEmpty", "empty"]),
3411
- value: z.union([z.string(), z.array(z.string())]).optional().describe(`omit for 'isSet'/'notEmpty' (the source holds any value) and 'empty' (it holds none); 'gt'/'lt' take one number as a string ("10"); required for every other op`)
3517
+ value: z.union([z.string(), z.array(z.string())]).optional().describe(`omit for 'isSet'/'notEmpty' (the source holds any value) and 'empty' (it holds none); 'gt'/'lt' take one number as a string ("10"); required for every other op`),
3518
+ scope: z.enum(SHOW_IF_SCOPES).optional().describe("where `field` is looked up: absent = a sibling, 'parent' = the object holding the siblings, 'root' = the top of the entry or component")
3412
3519
  })
3413
3520
  )
3414
3521
  });
@@ -3444,7 +3551,7 @@ var uiObject = z.strictObject({
3444
3551
  reference: z.object({ integration: z.string(), key: z.string() }).strict().optional().describe("a json field holding a live vendor item: the integration's slug and reference key."),
3445
3552
  access: z.strictObject({ view: z.literal("schema.view").optional(), edit: z.literal("schema.manage").optional() }).optional().describe("who sees (view: developer-only) and who changes (edit) it, gated in the editor. The enforced lock is readOnly:'developer'."),
3446
3553
  hidden: z.boolean().optional().describe("true: never shown in the form (a system value). A conditional hide is showIf."),
3447
- blocks: z.record(z.string(), z.strictObject({ icon: dockIconArg.optional(), preview: z.record(z.string(), z.string()).optional() })).optional().describe("modular only: per block slug, {icon?, preview?} for its rows."),
3554
+ blocks: z.record(z.string(), z.strictObject({ icon: dockIconArg.optional(), preview: z.record(z.string(), z.string()).optional(), openMode: z.enum(OPEN_MODES).optional() })).optional().describe("modular only: per block slug, {icon?, preview?, openMode?} for its rows."),
3448
3555
  input: z.object({
3449
3556
  name: z.string().regex(/^[a-z][a-z0-9-]{0,47}$/),
3450
3557
  writes: z.array(z.string().min(1).max(64)).max(16).optional()
@@ -3475,7 +3582,7 @@ var propUiObject = uiObject.extend({
3475
3582
  var choiceOption = z.union([z.string(), z.object({ label: z.string(), value: z.string(), icon: z.string().optional(), description: z.string().optional() })]);
3476
3583
  var fieldShape = {
3477
3584
  key: fieldKey.describe(
3478
- "machine field key. \u{1F534} UNIQUE ACROSS THE WHOLE MODEL \u2014 API IDs share ONE FLAT NAMESPACE, so a field nested inside a group must NOT reuse a key used by another field or group. Every section's heading cannot be 'title'. Prefix with the section: 'hero_title', 'pricing_title', 'faq_title'. Reusing a key is refused, and if it slips through it leaves permanent errors in the editor and breaks conditional-visibility rules, which reference fields by bare key. Keys derived from an imported site may contain hyphens ('hero-h1-welcome'): address them exactly as returned."
3585
+ "machine field key, unique among its SIBLINGS (the top level, or one group's children): two siblings with one key are refused, the same key in two groups ('hero.title', 'faq.title') is fine. Keys derived from an imported site may contain hyphens ('hero-h1-welcome'): address them exactly as returned."
3479
3586
  ),
3480
3587
  label: z.string().min(1).describe("human label shown in the editor"),
3481
3588
  type: fieldType.describe(`field type. ${BUTTON_FIELD_NOTE} ${LINK_VALUE_NOTE}`),
@@ -3532,10 +3639,20 @@ var fieldShape = {
3532
3639
  helpMore: z.string().max(2e3).optional().describe(PARAM_TEXT.helpMore),
3533
3640
  advanced: z.boolean().optional().describe(PARAM_TEXT.advanced),
3534
3641
  recommendedMax: z.number().int().positive().optional().describe(PARAM_TEXT.recommendedMax),
3535
- openMode: z.enum(["inline", "drill", "popover", "dialog"], { error: OPEN_MODE_INVALID }).optional().describe(PARAM_TEXT.openMode),
3536
- localized: z.boolean().optional()
3642
+ openMode: z.enum(OPEN_MODES, { error: OPEN_MODE_INVALID }).optional().describe(PARAM_TEXT.openMode),
3643
+ localized: z.boolean().optional(),
3644
+ ai: z.boolean().optional().describe(PARAM_TEXT.fieldAi),
3645
+ // Server-owned provenance (types DefSources): declared only so a client that sends it reads why.
3646
+ sources: z.never({ error: SOURCES_SERVER_OWNED }).optional()
3537
3647
  };
3538
- var fieldObject = z.object(fieldShape);
3648
+ var fieldObject = z.strictObject(fieldShape);
3649
+ var modelV2Arg = z.strictObject({
3650
+ shape: z.enum(V2_SHAPES).optional(),
3651
+ placement: z.enum(V2_PLACEMENTS).optional(),
3652
+ category: z.enum(KIT_CATEGORIES).optional(),
3653
+ variants: z.record(z.string(), z.strictObject({ icon: dockIconArg.optional(), description: z.string().optional() })).optional()
3654
+ }).nullable().optional().describe(PARAM_TEXT.modelV2);
3655
+ var dockUiName = z.string().regex(DOCK_UI_NAME).describe("[a-z0-9-], \u226464");
3539
3656
  var urlPatternArg = z.string().nullable().optional().describe(
3540
3657
  "the public URL of one entry, e.g. '/blog/:slug'. Must start with '/' and contain ':slug' exactly once ('{slug}' is refused). Drives RSS item links and the automatic 301 when an entry's slug changes. null clears it."
3541
3658
  );
@@ -3590,31 +3707,29 @@ var seoPatchShape = {
3590
3707
  searchConfig: z.object({ enabled: z.boolean(), placeholder: z.string().max(120), aiEnabled: z.boolean().optional() }).optional().describe(SEO_KEY_TEXT.searchConfig)
3591
3708
  };
3592
3709
  var PAGES_NOT_COLLECTIONS_NOTE = "A collection is only for items one template route repeats (/blog/:slug: set urlPattern). A single page's copy (home, about, contact, pricing, legal) is page fields in sections (add_field with pageId / set_page_content), never a one-entry collection.";
3593
- function flatFieldKeys(fields) {
3710
+ function siblingDuplicateKeys(fields) {
3594
3711
  const out = [];
3595
- const childrenOf = (f) => {
3712
+ const levelsOf = (f) => {
3596
3713
  const zones = f.config?.zones;
3597
- if (zones?.nonRepeatable) return zones.nonRepeatable;
3598
- if (zones?.repeatable?.fields) return zones.repeatable.fields;
3599
- return f.fields ?? [];
3714
+ return [f.fields, zones?.nonRepeatable, zones?.repeatable?.fields].filter((l) => Array.isArray(l));
3600
3715
  };
3601
- for (const raw of Array.isArray(fields) ? fields : []) {
3602
- const f = raw;
3603
- if (typeof f?.key === "string" && f.key.trim()) out.push(f.key.trim());
3604
- for (const raw2 of childrenOf(f)) {
3605
- const c = raw2;
3606
- if (typeof c?.key === "string" && c.key.trim()) out.push(c.key.trim());
3716
+ const walk = (level) => {
3717
+ const seen = /* @__PURE__ */ new Set();
3718
+ for (const raw of level) {
3719
+ const f = raw;
3720
+ const key = typeof f?.key === "string" ? f.key.trim() : "";
3721
+ if (key) {
3722
+ if (seen.has(key) && !out.includes(key)) out.push(key);
3723
+ seen.add(key);
3724
+ }
3725
+ if (f && typeof f === "object") for (const child of levelsOf(f)) walk(child);
3607
3726
  }
3608
- }
3727
+ };
3728
+ walk(Array.isArray(fields) ? fields : []);
3609
3729
  return out;
3610
3730
  }
3611
- function duplicateKeys(keys) {
3612
- const seen = /* @__PURE__ */ new Map();
3613
- for (const k of keys) seen.set(k, (seen.get(k) ?? 0) + 1);
3614
- return [...new Set(keys)].filter((k) => (seen.get(k) ?? 0) > 1);
3615
- }
3616
3731
  function duplicateKeyFailure(dupes) {
3617
- return `Duplicate API ID${dupes.length === 1 ? "" : "s"}: ${dupes.map((k) => `'${k}'`).join(", ")}. API IDs share ONE flat namespace across the whole model \u2014 a field inside a group must not reuse a key used by another field or group. Prefix each key with its section and retry (e.g. 'hero_title' and 'pricing_title', never 'title' in both).`;
3732
+ return `Duplicate API ID${dupes.length === 1 ? "" : "s"}: ${dupes.map((k) => `'${k}'`).join(", ")}. Two fields at the same level (the top level, or one group's children) share it, and a value is stored at its key's path, so one would overwrite the other. Rename one (e.g. 'title' and 'subtitle'); the same key in DIFFERENT groups is fine ('hero.title', 'pricing.title').`;
3618
3733
  }
3619
3734
  function toFields(fs) {
3620
3735
  return (fs ?? []).map(toField);
@@ -3689,7 +3804,8 @@ function toField(f) {
3689
3804
  ...f.helpMore !== void 0 ? { helpMore: f.helpMore } : {},
3690
3805
  ...f.advanced !== void 0 ? { advanced: f.advanced } : {},
3691
3806
  ...f.recommendedMax !== void 0 ? { recommendedMax: f.recommendedMax } : {},
3692
- ...f.openMode !== void 0 ? { openMode: f.openMode } : {}
3807
+ ...f.openMode !== void 0 ? { openMode: f.openMode } : {},
3808
+ ...f.ai !== void 0 ? { ai: f.ai } : {}
3693
3809
  };
3694
3810
  if (f.type === "object") {
3695
3811
  return { ...base, type: "object", fields: toFields(f.fields) };
@@ -4700,6 +4816,72 @@ ${d.outline}` : "Proposed Content structure.", d);
4700
4816
  z.object({ pageId: z.string().min(1), blockId: z.string().min(1).describe("a top-level section id (get_page)"), name: z.string().min(1).describe("80 characters at most; a longer one is refused as SAVED_BLOCK_NAME_TOO_LONG") }).shape,
4701
4817
  async (c, a) => ok("Saved the section as a reusable block.", await data(c, "POST", `/management/saved-blocks`, { pageId: a.pageId, blockId: a.blockId, name: a.name }))
4702
4818
  ),
4819
+ // ── CMS-stored custom dock UI (write → approve → validate → publish) ──
4820
+ def(
4821
+ "write_dock_ui",
4822
+ "Write custom dock UI",
4823
+ NEW_TOOL_TEXT.write_dock_ui,
4824
+ z.object({
4825
+ name: dockUiName,
4826
+ scope: z.enum(["field", "section", "page", "layout"]),
4827
+ kind: z.enum(["input", "panel"]),
4828
+ target: z.record(z.string(), z.unknown()).describe(PARAM_TEXT.dockUiTarget),
4829
+ source: z.string().min(1).describe(".tsx, \u2264256 KiB")
4830
+ }).shape,
4831
+ async (c, a) => ok("Saved the dock UI version as a draft.", await data(c, "POST", `/management/dock-ui`, { name: a.name, scope: a.scope, kind: a.kind, target: a.target, source: a.source }))
4832
+ ),
4833
+ def(
4834
+ "approve_dock_ui",
4835
+ "Approve custom dock UI",
4836
+ NEW_TOOL_TEXT.approve_dock_ui,
4837
+ z.object({ name: dockUiName, version: z.number().int().min(1), userApproved: z.boolean().optional().describe(PARAM_TEXT.dockUiUserApproved) }).shape,
4838
+ async (c, a) => ok("Approved the dock UI version.", (await c.fetchJSON(c.url(`/management/dock-ui/${encodeURIComponent(s(a.name))}/approve`), {
4839
+ method: "POST",
4840
+ body: JSON.stringify({ version: a.version }),
4841
+ ...withHeaders(a.userApproved === true ? { [INPUT_CONFIRMED_HEADER]: "true" } : {})
4842
+ })).data)
4843
+ ),
4844
+ def(
4845
+ "validate_dock_ui",
4846
+ "Validate custom dock UI",
4847
+ NEW_TOOL_TEXT.validate_dock_ui,
4848
+ z.object({ name: dockUiName, version: z.number().int().min(1).optional() }).shape,
4849
+ async (c, a) => ok("Dock UI build receipt.", await data(c, "POST", `/management/dock-ui/${encodeURIComponent(s(a.name))}/validate`, { version: a.version }))
4850
+ ),
4851
+ def(
4852
+ "publish_dock_ui",
4853
+ "Publish custom dock UI",
4854
+ NEW_TOOL_TEXT.publish_dock_ui,
4855
+ z.object({ name: dockUiName, version: z.number().int().min(1).optional() }).shape,
4856
+ async (c, a) => ok("Published the dock UI version.", await data(c, "POST", `/management/dock-ui/${encodeURIComponent(s(a.name))}/publish`, { version: a.version }))
4857
+ ),
4858
+ def(
4859
+ "list_dock_ui",
4860
+ "List custom dock UI",
4861
+ NEW_TOOL_TEXT.list_dock_ui,
4862
+ z.object({ scope: z.enum(["field", "section", "page", "layout"]).optional(), status: z.enum(["draft", "approved", "published"]).optional() }).shape,
4863
+ async (c, a) => ok("Dock UI sources.", await data(c, "GET", `/management/dock-ui${q({ scope: a.scope, status: a.status })}`))
4864
+ ),
4865
+ // ── A V2 TypeSchema in one write (kit A9/C6): a model change set, a model create, or a component's props ──
4866
+ def(
4867
+ "write_schema",
4868
+ "Write a V2 schema",
4869
+ NEW_TOOL_TEXT.write_schema,
4870
+ z.object({
4871
+ model: z.string().min(1).nullable().describe(PARAM_TEXT.writeSchemaModel),
4872
+ lane: z.enum(["fields", "props"]),
4873
+ schema: z.record(z.string(), z.unknown()).describe("{ name, title, kind, fields, \u2026 }"),
4874
+ acceptLoss: z.array(z.string().min(1)).optional().describe(PARAM_TEXT.writeSchemaAcceptLoss),
4875
+ dryRun: z.boolean().optional(),
4876
+ ifMatch: z.number().int().min(0).optional().describe(PARAM_TEXT.writeSchemaIfMatch),
4877
+ confirmed: z.boolean().optional().describe(PARAM_TEXT.confirmedInput)
4878
+ }).shape,
4879
+ async (c, a) => ok(a.dryRun ? "Dry run of the schema write: nothing was written." : "Wrote the schema.", (await c.fetchJSON(c.url(`/management/schema`), {
4880
+ method: "POST",
4881
+ body: JSON.stringify({ model: a.model, lane: a.lane, schema: a.schema, acceptLoss: a.acceptLoss, dryRun: a.dryRun }),
4882
+ ...withHeaders({ ...a.ifMatch !== void 0 ? { "If-Match": `W/"${a.ifMatch}"` } : {}, ...confirmedHeaders(a.confirmed) })
4883
+ })).data)
4884
+ ),
4703
4885
  def(
4704
4886
  "get_seo",
4705
4887
  "Get site SEO",
@@ -4957,6 +5139,7 @@ ${d.outline}` : "Proposed Content structure.", d);
4957
5139
  fieldOrder: z.strictObject({ parentPath: z.string().min(1).optional(), order: z.array(z.string().min(1)).min(1).max(200) }).optional().describe("the full new order of one parent's child keys (top level without parentPath). An array with both zones needs 'KEY.zones.nonRepeatable' or 'KEY.zones.repeatable'; its bare key is a 400"),
4958
5140
  fieldLabel: z.record(z.string(), z.string().min(1).max(255)).optional().describe('path or "<family>#section" \u2192 label'),
4959
5141
  fieldHelpText: z.record(z.string(), z.string().max(500).nullable()).optional().describe("path \u2192 help text, null clears it"),
5142
+ v2: modelV2Arg,
4960
5143
  override: z.boolean().optional().describe(OVERRIDE_NOTE),
4961
5144
  ifMatch: z.number().int().min(0).optional().describe(MODEL_IF_MATCH_NOTE),
4962
5145
  removeFields: z.array(z.string().min(1)).min(1).max(100).optional().describe(FIELD_REMOVAL_NOTE),
@@ -5187,8 +5370,8 @@ ${d.outline}` : "Proposed Content structure.", d);
5187
5370
  helpText: z.string().nullable().optional().describe("null clears it"),
5188
5371
  required: z.boolean().optional(),
5189
5372
  config: z.record(z.string(), z.unknown()).optional().describe("per-type validation/config keys, merged into the stored config; null deletes a key"),
5190
- showIf: z.record(z.string(), z.unknown()).nullable().optional().describe("same shape as on add_field; null clears it"),
5191
- requiredIf: z.record(z.string(), z.unknown()).nullable().optional().describe("same shape as on add_field; null clears it"),
5373
+ showIf: fieldCondition.nullable().optional().describe("same shape as on add_field; null clears it"),
5374
+ requiredIf: fieldCondition.nullable().optional().describe("same shape as on add_field; null clears it"),
5192
5375
  compare: z.array(z.record(z.string(), z.unknown())).nullable().optional().describe("same shape as on add_field; null clears it"),
5193
5376
  ui: z.record(z.string(), z.unknown()).nullable().optional().describe("same vocabulary as on add_field; merged per key, a null key or ui:null clears"),
5194
5377
  placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).nullable().optional(),
@@ -5197,10 +5380,12 @@ ${d.outline}` : "Proposed Content structure.", d);
5197
5380
  helpMore: z.string().max(2e3).nullable().optional().describe(PARAM_TEXT.helpMore),
5198
5381
  advanced: z.boolean().nullable().optional().describe(PARAM_TEXT.advanced),
5199
5382
  recommendedMax: z.number().int().positive().nullable().optional().describe(PARAM_TEXT.recommendedMax),
5200
- openMode: z.enum(["inline", "drill", "popover", "dialog"], { error: OPEN_MODE_INVALID }).nullable().optional().describe(PARAM_TEXT.openMode),
5383
+ openMode: z.enum(OPEN_MODES, { error: OPEN_MODE_INVALID }).nullable().optional().describe(PARAM_TEXT.openMode),
5201
5384
  localized: z.boolean().nullable().optional(),
5385
+ ai: z.boolean().nullable().optional().describe(PARAM_TEXT.fieldAi),
5386
+ sources: z.never({ error: SOURCES_SERVER_OWNED }).optional(),
5202
5387
  type: fieldType.optional().describe("a new type; needs allowRetype"),
5203
- key: fieldKey.optional().describe("a new key (flat namespace, unique in the schema)"),
5388
+ key: fieldKey.optional().describe("a new key (unique among its siblings)"),
5204
5389
  allowRetype: z.boolean().optional().describe("confirm a type change, once the user has agreed"),
5205
5390
  allowFieldRemoval: z.boolean().optional().describe("with a rename: discard the old key's stored values"),
5206
5391
  mergeHref: z.string().min(1).optional().describe(PARAM_TEXT.mergeHref),
@@ -5974,12 +6159,12 @@ ${d.outline}` : "Proposed Content structure.", d);
5974
6159
  name: "create_page",
5975
6160
  config: {
5976
6161
  title: "Create a page",
5977
- description: "Create a page with its own typed schema. Supports pageType 'singleton' (exactly one entry \u2014 Home, About, Contact) and 'dynamic' (many entries sharing the schema \u2014 Blog posts, Products). Project-scoped from the key. Additive \u2014 does not delete or overwrite existing pages. FIRST read the page's real markup and DECOMPOSE it into a destructured tree: each visual section becomes a nested field \u2014 a fixed grouped block \u2192 type 'group', a repeating list of items (cards, testimonials, features, FAQs) \u2192 type 'repeater' \u2014 each carrying its own child `fields`. Do NOT flatten sections into many flat top-level fields. Build the full nested tree, then call this once. \u{1F534} Every field key must be UNIQUE ACROSS THE WHOLE TREE \u2014 the namespace is flat, so 'title' cannot appear in two sections; prefix each with its section ('hero_title', 'faq_title'). Prose fields (headings, body copy, descriptions) are created as RICH TEXT by default; pass richText:false for values that must stay bare strings \u2014 hrefs, slugs, ids, emails. That tree is the page's SCHEMA \u2014 what it holds. " + SECTION_DOCTRINE,
6162
+ description: "Create a page with its own typed schema. Supports pageType 'singleton' (exactly one entry \u2014 Home, About, Contact) and 'dynamic' (many entries sharing the schema \u2014 Blog posts, Products). Project-scoped from the key. Additive \u2014 does not delete or overwrite existing pages. FIRST read the page's real markup and DECOMPOSE it into a destructured tree: each visual section becomes a nested field \u2014 a fixed grouped block \u2192 type 'group', a repeating list of items (cards, testimonials, features, FAQs) \u2192 type 'repeater' \u2014 each carrying its own child `fields`. Do NOT flatten sections into many flat top-level fields. Build the full nested tree, then call this once. Field keys are unique among siblings: 'title' may repeat in two sections, not twice in one. Prose fields (headings, body copy, descriptions) are created as RICH TEXT by default; pass richText:false for values that must stay bare strings \u2014 hrefs, slugs, ids, emails. That tree is the page's SCHEMA \u2014 what it holds. " + SECTION_DOCTRINE,
5978
6163
  inputSchema: createPageInput.shape
5979
6164
  },
5980
6165
  handler: guard(
5981
6166
  async (args) => withClient(async (client) => {
5982
- const dupes = duplicateKeys(flatFieldKeys(args.fields));
6167
+ const dupes = siblingDuplicateKeys(args.fields);
5983
6168
  if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
5984
6169
  const page = await client.createPage({
5985
6170
  title: args.title,
@@ -6015,7 +6200,7 @@ ${notes.join("\n")}` : summary, created);
6015
6200
  },
6016
6201
  handler: guard(
6017
6202
  async (args) => withClient(async (client) => {
6018
- const modelDupes = duplicateKeys(flatFieldKeys(args.fields));
6203
+ const modelDupes = siblingDuplicateKeys(args.fields);
6019
6204
  if (modelDupes.length > 0) return fail(duplicateKeyFailure(modelDupes));
6020
6205
  const input = {
6021
6206
  name: args.name,
@@ -6079,7 +6264,7 @@ ${notes.join("\n")}` : summary, created);
6079
6264
  }
6080
6265
  const modelId = args.modelId;
6081
6266
  const model = await client.getModel(modelId);
6082
- const dupes = duplicateKeys([...flatFieldKeys(model.fields), args.key]);
6267
+ const dupes = (model.fields ?? []).some((f) => f?.key === args.key) ? [args.key] : [];
6083
6268
  if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
6084
6269
  const existingFields = model.fields;
6085
6270
  let at = existingFields.length;
@@ -7885,7 +8070,7 @@ delivered value \u2014 they change editor chrome only.
7885
8070
 
7886
8071
  - **\`helpText\`** \u2014 the exception: most fields get none. Write it only when the author needs it to
7887
8072
  type the right value: a format or unit ("Price in cents."), a placement the canvas cannot show, a
7888
- real constraint, a fallback. One plain sentence under 90 characters, no semicolons, no dashes, no values copied from the page. Never restate the label ("The title field"), never coach ("Keep it short").
8073
+ real constraint, a fallback. One plain sentence of at most 160 characters, no semicolons, no dashes, no values copied from the page. Never restate the label ("The title field"), never coach ("Keep it short").
7889
8074
  - **\`showIf\`** \u2014 hide a field until another has a value:
7890
8075
  \`{match:'all',rules:[{field:'link_type',op:'eq',value:'external'}]}\`. Rules name fields by
7891
8076
  BARE KEY, and the key namespace is FLAT across the model (\xA7 the duplicate-key rule), so the
@@ -8039,7 +8224,7 @@ An unknown \`openMode\` is refused with \`openMode_invalid\`, one on a leaf \`op
8039
8224
  schema: the same call with \`pageId\` instead of \`modelId\`, and the version from \`get_page\`'s
8040
8225
  \`backingModel\`.
8041
8226
  - A component's props: \`update_component\` with \`propUi\`, \`propGroup\`, \`propTab\`, \`propLabel\`, \`propHelpText\` (keyed by prop key).
8042
- Name props from the code (\`plan.cents\` \u2192 Cents); help text only where a format, a hidden placement, a constraint or a fallback needs it ("Whole number, no currency symbol."), one plain sentence under 90 characters with no semicolons or dashes, never repeating
8227
+ Name props from the code (\`plan.cents\` \u2192 Cents); help text only where a format, a hidden placement, a constraint or a fallback needs it ("Whole number, no currency symbol."), one plain sentence of at most 160 characters with no semicolons or dashes, never repeating
8043
8228
  the value; repeated cards are one \`table\` prop. An icon (\`config.media: "icon"\`) is content: group it, never prune it.
8044
8229
  They write the DRAFT props; no publish is needed for the editor to show them.
8045
8230
  - Always send \`ifMatch\`, the \`optimisticVersion\` you read.