@bettercms-ai/mcp 0.84.5 → 0.86.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)]));
@@ -2905,6 +2988,10 @@ var LISTING_TAIL = /* @__PURE__ */ new Set([
2905
2988
  // Reporting, history and workflow chrome.
2906
2989
  "get_analytics_overview",
2907
2990
  "get_analytics_top_pages",
2991
+ "get_analytics_top_referrers",
2992
+ "get_analytics_breakdown",
2993
+ "get_conversions",
2994
+ "get_ai_traffic",
2908
2995
  "list_activity",
2909
2996
  "get_workflow_board",
2910
2997
  "move_stage",
@@ -2959,6 +3046,14 @@ var LISTING_TAIL = /* @__PURE__ */ new Set([
2959
3046
  "start_integration_signin",
2960
3047
  "save_integration_template",
2961
3048
  "install_integration_template",
3049
+ // CMS-stored custom dock UI: reached for once the stock controls and ui keys fall short.
3050
+ "write_dock_ui",
3051
+ "approve_dock_ui",
3052
+ "validate_dock_ui",
3053
+ "publish_dock_ui",
3054
+ "list_dock_ui",
3055
+ // A whole V2 schema at once: reached for when the field-by-field tools would take dozens of calls.
3056
+ "write_schema",
2962
3057
  // The ABM campaign lane — also hidden from the default listing (ABM_TOOLS below).
2963
3058
  "research_account",
2964
3059
  "build_abm_page",
@@ -3010,7 +3105,7 @@ var toolMeta = (name) => ALWAYS_LOADED.has(name) ? { "anthropic/alwaysLoad": tru
3010
3105
  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
3106
 
3012
3107
  // 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?}].";
3108
+ 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
3109
  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
3110
  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
3111
  var WINDOW_NOTE = "{from?, until?}, ISO 8601 with offset: shown only between the two instants; null clears (skill publishing).";
@@ -3056,7 +3151,15 @@ var NEW_TOOL_TEXT = {
3056
3151
  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
3152
  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
3153
  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."
3154
+ 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.",
3155
+ // CMS-stored custom dock UI: write → approve (the user's yes) → validate → publish. Recipe: skill dock-input.
3156
+ 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",
3157
+ 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",
3158
+ validate_dock_ui: "Check one version (default newest) without publishing: allowed imports, size caps, a name the repo also has. Answers the build receipt",
3159
+ 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",
3160
+ list_dock_ui: "The project's dock UI sources, with usage against the limits",
3161
+ // A V2 TypeSchema into a model (lane fields, through a change set) or a component's props (lane props).
3162
+ 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
3163
  };
3061
3164
  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
3165
  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 +3172,10 @@ var PARAM_TEXT = {
3069
3172
  readOnly: "'developer' = the value and this lock change only with schema access (needs lockReason); true = an editor marker",
3070
3173
  lockReason: "which system owns the value (\u2264 200 chars)",
3071
3174
  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.",
3175
+ 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
3176
  helpMore: "longer help behind the \u24D8 next to the label, when one helpText line cannot carry it",
3177
+ fieldAi: "true = the editor offers its AI assist on this field",
3178
+ modelV2: "the V2 block {shape?, placement?, category?, variants?: {slug: {icon?, description?}}}; null clears it",
3074
3179
  advanced: "true = a rare or developer setting: it collects in a closed 'Advanced' section",
3075
3180
  recommendedMax: "a SOFT limit (characters, items or value): the editor warns past it, never refuses",
3076
3181
  openMode: "object/array/group/repeater: how the value (or each row) opens: 'inline', 'drill', 'popover' or 'dialog'",
@@ -3106,7 +3211,12 @@ var PARAM_TEXT = {
3106
3211
  focalX: "focal point, 0-1 across from the left edge; null resets to the centre",
3107
3212
  focalY: "focal point, 0-1 down from the top edge; null resets to the centre",
3108
3213
  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"
3214
+ 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",
3215
+ dockUiUserApproved: "true only after the user said yes to this version",
3216
+ dockUiTarget: "per scope: field {modelId|pageId|componentId, path}, section {componentId}, page {pageId}, layout {}",
3217
+ writeSchemaModel: "model or component id/slug; null creates a model",
3218
+ writeSchemaAcceptLoss: "loss keys or paths you accept",
3219
+ writeSchemaIfMatch: "the dry run's `version`; needed to write over a stored row"
3110
3220
  };
3111
3221
  var INTEGRATION_PARAM_TEXT = {
3112
3222
  integration: "the integration's id or slug (from list_integrations)",
@@ -3136,7 +3246,7 @@ var INTEGRATION_PARAM_TEXT = {
3136
3246
  var INPUT_CONFIRMED_HEADER = "x-bcms-confirmed-input";
3137
3247
  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
3248
  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`.";
3249
+ 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
3250
  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
3251
  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
3252
  var SEO_KEY_TEXT = {
@@ -3402,13 +3512,14 @@ var OPEN_MODE_INVALID = "openMode_invalid: openMode must be one of 'inline', 'dr
3402
3512
  var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
3403
3513
  var fieldKey = z.string().regex(/^[a-zA-Z0-9_-]+$/, "letters, numbers, underscores, and hyphens only");
3404
3514
  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({
3515
+ var fieldCondition = z.strictObject({
3406
3516
  match: z.enum(["all", "any"]),
3407
3517
  rules: z.array(
3408
- z.object({
3518
+ z.strictObject({
3409
3519
  field: z.string().describe("the BARE KEY of another field on this same model"),
3410
3520
  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`)
3521
+ 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`),
3522
+ 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
3523
  })
3413
3524
  )
3414
3525
  });
@@ -3444,7 +3555,7 @@ var uiObject = z.strictObject({
3444
3555
  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
3556
  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
3557
  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."),
3558
+ 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
3559
  input: z.object({
3449
3560
  name: z.string().regex(/^[a-z][a-z0-9-]{0,47}$/),
3450
3561
  writes: z.array(z.string().min(1).max(64)).max(16).optional()
@@ -3475,7 +3586,7 @@ var propUiObject = uiObject.extend({
3475
3586
  var choiceOption = z.union([z.string(), z.object({ label: z.string(), value: z.string(), icon: z.string().optional(), description: z.string().optional() })]);
3476
3587
  var fieldShape = {
3477
3588
  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."
3589
+ "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
3590
  ),
3480
3591
  label: z.string().min(1).describe("human label shown in the editor"),
3481
3592
  type: fieldType.describe(`field type. ${BUTTON_FIELD_NOTE} ${LINK_VALUE_NOTE}`),
@@ -3532,10 +3643,20 @@ var fieldShape = {
3532
3643
  helpMore: z.string().max(2e3).optional().describe(PARAM_TEXT.helpMore),
3533
3644
  advanced: z.boolean().optional().describe(PARAM_TEXT.advanced),
3534
3645
  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()
3646
+ openMode: z.enum(OPEN_MODES, { error: OPEN_MODE_INVALID }).optional().describe(PARAM_TEXT.openMode),
3647
+ localized: z.boolean().optional(),
3648
+ ai: z.boolean().optional().describe(PARAM_TEXT.fieldAi),
3649
+ // Server-owned provenance (types DefSources): declared only so a client that sends it reads why.
3650
+ sources: z.never({ error: SOURCES_SERVER_OWNED }).optional()
3537
3651
  };
3538
- var fieldObject = z.object(fieldShape);
3652
+ var fieldObject = z.strictObject(fieldShape);
3653
+ var modelV2Arg = z.strictObject({
3654
+ shape: z.enum(V2_SHAPES).optional(),
3655
+ placement: z.enum(V2_PLACEMENTS).optional(),
3656
+ category: z.enum(KIT_CATEGORIES).optional(),
3657
+ variants: z.record(z.string(), z.strictObject({ icon: dockIconArg.optional(), description: z.string().optional() })).optional()
3658
+ }).nullable().optional().describe(PARAM_TEXT.modelV2);
3659
+ var dockUiName = z.string().regex(DOCK_UI_NAME).describe("[a-z0-9-], \u226464");
3539
3660
  var urlPatternArg = z.string().nullable().optional().describe(
3540
3661
  "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
3662
  );
@@ -3590,31 +3711,29 @@ var seoPatchShape = {
3590
3711
  searchConfig: z.object({ enabled: z.boolean(), placeholder: z.string().max(120), aiEnabled: z.boolean().optional() }).optional().describe(SEO_KEY_TEXT.searchConfig)
3591
3712
  };
3592
3713
  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) {
3714
+ function siblingDuplicateKeys(fields) {
3594
3715
  const out = [];
3595
- const childrenOf = (f) => {
3716
+ const levelsOf = (f) => {
3596
3717
  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 ?? [];
3718
+ return [f.fields, zones?.nonRepeatable, zones?.repeatable?.fields].filter((l) => Array.isArray(l));
3600
3719
  };
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());
3720
+ const walk = (level) => {
3721
+ const seen = /* @__PURE__ */ new Set();
3722
+ for (const raw of level) {
3723
+ const f = raw;
3724
+ const key = typeof f?.key === "string" ? f.key.trim() : "";
3725
+ if (key) {
3726
+ if (seen.has(key) && !out.includes(key)) out.push(key);
3727
+ seen.add(key);
3728
+ }
3729
+ if (f && typeof f === "object") for (const child of levelsOf(f)) walk(child);
3607
3730
  }
3608
- }
3731
+ };
3732
+ walk(Array.isArray(fields) ? fields : []);
3609
3733
  return out;
3610
3734
  }
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
3735
  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).`;
3736
+ 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
3737
  }
3619
3738
  function toFields(fs) {
3620
3739
  return (fs ?? []).map(toField);
@@ -3689,7 +3808,8 @@ function toField(f) {
3689
3808
  ...f.helpMore !== void 0 ? { helpMore: f.helpMore } : {},
3690
3809
  ...f.advanced !== void 0 ? { advanced: f.advanced } : {},
3691
3810
  ...f.recommendedMax !== void 0 ? { recommendedMax: f.recommendedMax } : {},
3692
- ...f.openMode !== void 0 ? { openMode: f.openMode } : {}
3811
+ ...f.openMode !== void 0 ? { openMode: f.openMode } : {},
3812
+ ...f.ai !== void 0 ? { ai: f.ai } : {}
3693
3813
  };
3694
3814
  if (f.type === "object") {
3695
3815
  return { ...base, type: "object", fields: toFields(f.fields) };
@@ -4700,6 +4820,72 @@ ${d.outline}` : "Proposed Content structure.", d);
4700
4820
  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
4821
  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
4822
  ),
4823
+ // ── CMS-stored custom dock UI (write → approve → validate → publish) ──
4824
+ def(
4825
+ "write_dock_ui",
4826
+ "Write custom dock UI",
4827
+ NEW_TOOL_TEXT.write_dock_ui,
4828
+ z.object({
4829
+ name: dockUiName,
4830
+ scope: z.enum(["field", "section", "page", "layout"]),
4831
+ kind: z.enum(["input", "panel"]),
4832
+ target: z.record(z.string(), z.unknown()).describe(PARAM_TEXT.dockUiTarget),
4833
+ source: z.string().min(1).describe(".tsx, \u2264256 KiB")
4834
+ }).shape,
4835
+ 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 }))
4836
+ ),
4837
+ def(
4838
+ "approve_dock_ui",
4839
+ "Approve custom dock UI",
4840
+ NEW_TOOL_TEXT.approve_dock_ui,
4841
+ z.object({ name: dockUiName, version: z.number().int().min(1), userApproved: z.boolean().optional().describe(PARAM_TEXT.dockUiUserApproved) }).shape,
4842
+ async (c, a) => ok("Approved the dock UI version.", (await c.fetchJSON(c.url(`/management/dock-ui/${encodeURIComponent(s(a.name))}/approve`), {
4843
+ method: "POST",
4844
+ body: JSON.stringify({ version: a.version }),
4845
+ ...withHeaders(a.userApproved === true ? { [INPUT_CONFIRMED_HEADER]: "true" } : {})
4846
+ })).data)
4847
+ ),
4848
+ def(
4849
+ "validate_dock_ui",
4850
+ "Validate custom dock UI",
4851
+ NEW_TOOL_TEXT.validate_dock_ui,
4852
+ z.object({ name: dockUiName, version: z.number().int().min(1).optional() }).shape,
4853
+ async (c, a) => ok("Dock UI build receipt.", await data(c, "POST", `/management/dock-ui/${encodeURIComponent(s(a.name))}/validate`, { version: a.version }))
4854
+ ),
4855
+ def(
4856
+ "publish_dock_ui",
4857
+ "Publish custom dock UI",
4858
+ NEW_TOOL_TEXT.publish_dock_ui,
4859
+ z.object({ name: dockUiName, version: z.number().int().min(1).optional() }).shape,
4860
+ async (c, a) => ok("Published the dock UI version.", await data(c, "POST", `/management/dock-ui/${encodeURIComponent(s(a.name))}/publish`, { version: a.version }))
4861
+ ),
4862
+ def(
4863
+ "list_dock_ui",
4864
+ "List custom dock UI",
4865
+ NEW_TOOL_TEXT.list_dock_ui,
4866
+ z.object({ scope: z.enum(["field", "section", "page", "layout"]).optional(), status: z.enum(["draft", "approved", "published"]).optional() }).shape,
4867
+ async (c, a) => ok("Dock UI sources.", await data(c, "GET", `/management/dock-ui${q({ scope: a.scope, status: a.status })}`))
4868
+ ),
4869
+ // ── A V2 TypeSchema in one write (kit A9/C6): a model change set, a model create, or a component's props ──
4870
+ def(
4871
+ "write_schema",
4872
+ "Write a V2 schema",
4873
+ NEW_TOOL_TEXT.write_schema,
4874
+ z.object({
4875
+ model: z.string().min(1).nullable().describe(PARAM_TEXT.writeSchemaModel),
4876
+ lane: z.enum(["fields", "props"]),
4877
+ schema: z.record(z.string(), z.unknown()).describe("{ name, title, kind, fields, \u2026 }"),
4878
+ acceptLoss: z.array(z.string().min(1)).optional().describe(PARAM_TEXT.writeSchemaAcceptLoss),
4879
+ dryRun: z.boolean().optional(),
4880
+ ifMatch: z.number().int().min(0).optional().describe(PARAM_TEXT.writeSchemaIfMatch),
4881
+ confirmed: z.boolean().optional().describe(PARAM_TEXT.confirmedInput)
4882
+ }).shape,
4883
+ 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`), {
4884
+ method: "POST",
4885
+ body: JSON.stringify({ model: a.model, lane: a.lane, schema: a.schema, acceptLoss: a.acceptLoss, dryRun: a.dryRun }),
4886
+ ...withHeaders({ ...a.ifMatch !== void 0 ? { "If-Match": `W/"${a.ifMatch}"` } : {}, ...confirmedHeaders(a.confirmed) })
4887
+ })).data)
4888
+ ),
4703
4889
  def(
4704
4890
  "get_seo",
4705
4891
  "Get site SEO",
@@ -4957,6 +5143,7 @@ ${d.outline}` : "Proposed Content structure.", d);
4957
5143
  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
5144
  fieldLabel: z.record(z.string(), z.string().min(1).max(255)).optional().describe('path or "<family>#section" \u2192 label'),
4959
5145
  fieldHelpText: z.record(z.string(), z.string().max(500).nullable()).optional().describe("path \u2192 help text, null clears it"),
5146
+ v2: modelV2Arg,
4960
5147
  override: z.boolean().optional().describe(OVERRIDE_NOTE),
4961
5148
  ifMatch: z.number().int().min(0).optional().describe(MODEL_IF_MATCH_NOTE),
4962
5149
  removeFields: z.array(z.string().min(1)).min(1).max(100).optional().describe(FIELD_REMOVAL_NOTE),
@@ -5187,8 +5374,8 @@ ${d.outline}` : "Proposed Content structure.", d);
5187
5374
  helpText: z.string().nullable().optional().describe("null clears it"),
5188
5375
  required: z.boolean().optional(),
5189
5376
  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"),
5377
+ showIf: fieldCondition.nullable().optional().describe("same shape as on add_field; null clears it"),
5378
+ requiredIf: fieldCondition.nullable().optional().describe("same shape as on add_field; null clears it"),
5192
5379
  compare: z.array(z.record(z.string(), z.unknown())).nullable().optional().describe("same shape as on add_field; null clears it"),
5193
5380
  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
5381
  placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).nullable().optional(),
@@ -5197,10 +5384,12 @@ ${d.outline}` : "Proposed Content structure.", d);
5197
5384
  helpMore: z.string().max(2e3).nullable().optional().describe(PARAM_TEXT.helpMore),
5198
5385
  advanced: z.boolean().nullable().optional().describe(PARAM_TEXT.advanced),
5199
5386
  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),
5387
+ openMode: z.enum(OPEN_MODES, { error: OPEN_MODE_INVALID }).nullable().optional().describe(PARAM_TEXT.openMode),
5201
5388
  localized: z.boolean().nullable().optional(),
5389
+ ai: z.boolean().nullable().optional().describe(PARAM_TEXT.fieldAi),
5390
+ sources: z.never({ error: SOURCES_SERVER_OWNED }).optional(),
5202
5391
  type: fieldType.optional().describe("a new type; needs allowRetype"),
5203
- key: fieldKey.optional().describe("a new key (flat namespace, unique in the schema)"),
5392
+ key: fieldKey.optional().describe("a new key (unique among its siblings)"),
5204
5393
  allowRetype: z.boolean().optional().describe("confirm a type change, once the user has agreed"),
5205
5394
  allowFieldRemoval: z.boolean().optional().describe("with a rename: discard the old key's stored values"),
5206
5395
  mergeHref: z.string().min(1).optional().describe(PARAM_TEXT.mergeHref),
@@ -5632,9 +5821,9 @@ ${d.outline}` : "Proposed Content structure.", d);
5632
5821
  def(
5633
5822
  "get_analytics_overview",
5634
5823
  "Get traffic overview",
5635
- "Traffic totals and the daily series for the connected project \u2014 views, unique visitors, bytes, sessions and bounces. Defaults to the last 30 days; pass `from`/`to` as YYYY-MM-DD.",
5636
- z.object({ from: z.string().optional(), to: z.string().optional() }).shape,
5637
- async (c, a) => ok("Analytics overview.", await data(c, "GET", `/management/insights/analytics/overview${q({ from: a.from, to: a.to })}`))
5824
+ 'Traffic totals and the daily series for the connected project: views, unique visitors, bytes, sessions, bounces and responses by status class. Defaults to the last 30 days; pass `from`/`to` as YYYY-MM-DD, and `compare: "previous"` for the previous period\'s totals beside them.',
5825
+ z.object({ from: z.string().optional(), to: z.string().optional(), compare: z.enum(["previous"]).optional() }).shape,
5826
+ async (c, a) => ok("Analytics overview.", await data(c, "GET", `/management/insights/analytics/overview${q({ from: a.from, to: a.to, compare: a.compare })}`))
5638
5827
  ),
5639
5828
  def(
5640
5829
  "get_analytics_top_pages",
@@ -5643,6 +5832,34 @@ ${d.outline}` : "Proposed Content structure.", d);
5643
5832
  z.object({ from: z.string().optional(), to: z.string().optional(), limit: z.number().optional() }).shape,
5644
5833
  async (c, a) => ok("Top pages.", await data(c, "GET", `/management/insights/analytics/top-pages${q({ from: a.from, to: a.to, limit: a.limit })}`))
5645
5834
  ),
5835
+ def(
5836
+ "get_analytics_top_referrers",
5837
+ "Get top referrers",
5838
+ "Traffic sources: the referrer hosts sending visitors, by views. Defaults to the last 30 days; pass `from`/`to` (YYYY-MM-DD) and `limit`.",
5839
+ z.object({ from: z.string().optional(), to: z.string().optional(), limit: z.number().optional() }).shape,
5840
+ async (c, a) => ok("Top referrers.", await data(c, "GET", `/management/insights/analytics/top-referrers${q({ from: a.from, to: a.to, limit: a.limit })}`))
5841
+ ),
5842
+ def(
5843
+ "get_analytics_breakdown",
5844
+ "Get traffic breakdown",
5845
+ "Views and visitors by one dimension: `country` (ISO code), `device` (desktop, mobile, tablet), `browser` or `crawler` (the bots that fetched the site). Defaults to the last 30 days; pass `from`/`to` (YYYY-MM-DD) and `limit`.",
5846
+ z.object({ dim: z.enum(["country", "device", "browser", "crawler"]), from: z.string().optional(), to: z.string().optional(), limit: z.number().optional() }).shape,
5847
+ async (c, a) => ok("Traffic breakdown.", await data(c, "GET", `/management/insights/analytics/breakdown${q({ dim: a.dim, from: a.from, to: a.to, limit: a.limit })}`))
5848
+ ),
5849
+ def(
5850
+ "get_conversions",
5851
+ "Get conversions",
5852
+ "Conversions: form submissions (the one goal a site measures) per form over the last `days` days (default 30, max 90), with last-7 vs previous-7, all-time spam, the pages each form is on, and the site's conversion rate (submissions per 100 page views). `degraded: true` means the submissions store didn't answer, so the counts are not measurements and the rate is null.",
5853
+ z.object({ days: z.number().int().min(1).max(90).optional() }).shape,
5854
+ async (c, a) => ok("Conversions.", await data(c, "GET", `/management/insights/analytics/conversions${q({ days: a.days })}`))
5855
+ ),
5856
+ def(
5857
+ "get_ai_traffic",
5858
+ "Get AI traffic",
5859
+ "AI traffic: crawler visits for AI products (GPTBot, ClaudeBot\u2026, beside search crawlers) and visitors referred by answer engines (ChatGPT, Perplexity\u2026), with totals. Pro plan and above. Defaults to the last 30 days; pass `from`/`to` (YYYY-MM-DD).",
5860
+ z.object({ from: z.string().optional(), to: z.string().optional() }).shape,
5861
+ async (c, a) => ok("AI traffic.", await data(c, "GET", `/management/insights/analytics/ai-traffic${q({ from: a.from, to: a.to })}`))
5862
+ ),
5646
5863
  def(
5647
5864
  "list_seo_issues",
5648
5865
  "List SEO issues",
@@ -5974,12 +6191,12 @@ ${d.outline}` : "Proposed Content structure.", d);
5974
6191
  name: "create_page",
5975
6192
  config: {
5976
6193
  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,
6194
+ 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
6195
  inputSchema: createPageInput.shape
5979
6196
  },
5980
6197
  handler: guard(
5981
6198
  async (args) => withClient(async (client) => {
5982
- const dupes = duplicateKeys(flatFieldKeys(args.fields));
6199
+ const dupes = siblingDuplicateKeys(args.fields);
5983
6200
  if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
5984
6201
  const page = await client.createPage({
5985
6202
  title: args.title,
@@ -6015,7 +6232,7 @@ ${notes.join("\n")}` : summary, created);
6015
6232
  },
6016
6233
  handler: guard(
6017
6234
  async (args) => withClient(async (client) => {
6018
- const modelDupes = duplicateKeys(flatFieldKeys(args.fields));
6235
+ const modelDupes = siblingDuplicateKeys(args.fields);
6019
6236
  if (modelDupes.length > 0) return fail(duplicateKeyFailure(modelDupes));
6020
6237
  const input = {
6021
6238
  name: args.name,
@@ -6079,7 +6296,7 @@ ${notes.join("\n")}` : summary, created);
6079
6296
  }
6080
6297
  const modelId = args.modelId;
6081
6298
  const model = await client.getModel(modelId);
6082
- const dupes = duplicateKeys([...flatFieldKeys(model.fields), args.key]);
6299
+ const dupes = (model.fields ?? []).some((f) => f?.key === args.key) ? [args.key] : [];
6083
6300
  if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
6084
6301
  const existingFields = model.fields;
6085
6302
  let at = existingFields.length;
@@ -7885,7 +8102,7 @@ delivered value \u2014 they change editor chrome only.
7885
8102
 
7886
8103
  - **\`helpText\`** \u2014 the exception: most fields get none. Write it only when the author needs it to
7887
8104
  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").
8105
+ 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
8106
  - **\`showIf\`** \u2014 hide a field until another has a value:
7890
8107
  \`{match:'all',rules:[{field:'link_type',op:'eq',value:'external'}]}\`. Rules name fields by
7891
8108
  BARE KEY, and the key namespace is FLAT across the model (\xA7 the duplicate-key rule), so the
@@ -8039,7 +8256,7 @@ An unknown \`openMode\` is refused with \`openMode_invalid\`, one on a leaf \`op
8039
8256
  schema: the same call with \`pageId\` instead of \`modelId\`, and the version from \`get_page\`'s
8040
8257
  \`backingModel\`.
8041
8258
  - 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
8259
+ 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
8260
  the value; repeated cards are one \`table\` prop. An icon (\`config.media: "icon"\`) is content: group it, never prune it.
8044
8261
  They write the DRAFT props; no publish is needed for the editor to show them.
8045
8262
  - Always send \`ifMatch\`, the \`optimisticVersion\` you read.