@aglyn/plugins-ai 1.0.0-beta.238 → 1.0.0-beta.240

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/ai-job-links.js +8 -0
  3. package/src/lib/components/ai-job-links.js.map +1 -1
  4. package/src/lib/components/ai-job-requests.d.ts +5 -0
  5. package/src/lib/components/ai-job-requests.js +4 -2
  6. package/src/lib/components/ai-job-requests.js.map +1 -1
  7. package/src/lib/components/ai-jobs-store.d.ts +12 -0
  8. package/src/lib/components/ai-jobs-store.js +15 -0
  9. package/src/lib/components/ai-jobs-store.js.map +1 -1
  10. package/src/lib/components/ai-usage-meter-refresh.d.ts +35 -0
  11. package/src/lib/components/ai-usage-meter-refresh.js +63 -0
  12. package/src/lib/components/ai-usage-meter-refresh.js.map +1 -0
  13. package/src/lib/components/assist-panel.component.js +17 -1
  14. package/src/lib/components/assist-panel.component.js.map +1 -1
  15. package/src/lib/components/use-ai-usage-meter.d.ts +2 -0
  16. package/src/lib/components/use-ai-usage-meter.js +8 -3
  17. package/src/lib/components/use-ai-usage-meter.js.map +1 -1
  18. package/src/lib/jobs/ai-build-unit-outcome.d.ts +3 -1
  19. package/src/lib/jobs/ai-build-unit-outcome.js +19 -3
  20. package/src/lib/jobs/ai-build-unit-outcome.js.map +1 -1
  21. package/src/lib/jobs/ai-job-build-step.js +29 -2
  22. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  23. package/src/lib/jobs/ai-job-draft-ids.js +7 -2
  24. package/src/lib/jobs/ai-job-draft-ids.js.map +1 -1
  25. package/src/lib/jobs/ai-job-form-dataset.d.ts +100 -0
  26. package/src/lib/jobs/ai-job-form-dataset.js +161 -0
  27. package/src/lib/jobs/ai-job-form-dataset.js.map +1 -0
  28. package/src/lib/jobs/ai-job-form-step.d.ts +15 -3
  29. package/src/lib/jobs/ai-job-form-step.js +37 -9
  30. package/src/lib/jobs/ai-job-form-step.js.map +1 -1
  31. package/src/lib/jobs/ai-job-layout-language.js +7 -2
  32. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  33. package/src/lib/jobs/ai-job-page-language.d.ts +9 -0
  34. package/src/lib/jobs/ai-job-page-language.js +44 -4
  35. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  36. package/src/lib/jobs/ai-job-page-sections.js +13 -2
  37. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  38. package/src/lib/jobs/ai-job-page-step.js +32 -2
  39. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  40. package/src/lib/jobs/ai-job-plan-step.d.ts +18 -0
  41. package/src/lib/jobs/ai-job-plan-step.js +41 -4
  42. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  43. package/src/lib/jobs/ai-job-site-datasets.d.ts +183 -0
  44. package/src/lib/jobs/ai-job-site-datasets.js +530 -0
  45. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -0
  46. package/src/lib/jobs/ai-job-site-step.d.ts +15 -8
  47. package/src/lib/jobs/ai-job-site-step.js +148 -29
  48. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  49. package/src/lib/jobs/fixtures/ai-dataset-site-plan-recordings.d.ts +30 -0
  50. package/src/lib/jobs/fixtures/ai-dataset-site-plan-recordings.js +637 -0
  51. package/src/lib/jobs/fixtures/ai-dataset-site-plan-recordings.js.map +1 -0
  52. package/src/lib/layout-language/ai-layout-compiler.d.ts +10 -0
  53. package/src/lib/layout-language/ai-layout-compiler.js +93 -1
  54. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  55. package/src/lib/layout-language/ai-layout-gaps.js +17 -1
  56. package/src/lib/layout-language/ai-layout-gaps.js.map +1 -1
  57. package/src/lib/layout-language/ai-layout-listings.d.ts +32 -3
  58. package/src/lib/layout-language/ai-layout-listings.js +62 -9
  59. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  60. package/src/lib/layout-language/ai-layout-pictures.js +12 -4
  61. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  62. package/src/lib/model/ai-build-job.d.ts +2 -0
  63. package/src/lib/model/ai-build-job.js +16 -1
  64. package/src/lib/model/ai-build-job.js.map +1 -1
  65. package/src/lib/model/ai-build-plan.d.ts +23 -3
  66. package/src/lib/model/ai-build-plan.js +47 -6
  67. package/src/lib/model/ai-build-plan.js.map +1 -1
  68. package/src/lib/model/ai-plan-capabilities.d.ts +7 -0
  69. package/src/lib/model/ai-plan-capabilities.js.map +1 -1
  70. package/src/lib/model/ai-site-job.d.ts +19 -2
  71. package/src/lib/model/ai-site-job.js +21 -6
  72. package/src/lib/model/ai-site-job.js.map +1 -1
  73. package/src/lib/runtime/ai-dataset-generation.d.ts +77 -0
  74. package/src/lib/runtime/ai-dataset-generation.js +127 -0
  75. package/src/lib/runtime/ai-dataset-generation.js.map +1 -0
  76. package/src/lib/runtime/ai-doctrine-validators.d.ts +84 -0
  77. package/src/lib/runtime/ai-doctrine-validators.js +307 -1
  78. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  79. package/src/lib/runtime/ai-doctrine.js +10 -4
  80. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  81. package/src/lib/runtime/ai-node-tree.d.ts +2 -0
  82. package/src/lib/runtime/ai-node-tree.js +16 -0
  83. package/src/lib/runtime/ai-node-tree.js.map +1 -1
  84. package/src/lib/tools/ai-dataset-tool.d.ts +68 -0
  85. package/src/lib/tools/ai-dataset-tool.js +231 -0
  86. package/src/lib/tools/ai-dataset-tool.js.map +1 -0
  87. package/src/lib/usage/free-ai-credits-left.js +26 -2
  88. package/src/lib/usage/free-ai-credits-left.js.map +1 -1
@@ -689,6 +689,50 @@ export declare function aiSettleDisagreeingNodes(input: unknown): unknown;
689
689
  * re-ask. Reads and returns the tree as the model wrote it.
690
690
  */
691
691
  export declare function aiSettleCutHeadings(input: unknown): unknown;
692
+ /**
693
+ * THE RULES A CHECK WOULD REFUSE FOR AN ATTRIBUTE, SETTLED IN CODE (AGL-3660).
694
+ *
695
+ * A finding whose fix is one attribute, one level or one key, with nothing to
696
+ * decide about what the page says, is applied before the check reads the
697
+ * tree, as `aiSettleGridItems` sizes a container's items. A live Free guided
698
+ * start's Portfolio page (2026-10-10) was refused for rule 16's `eager-image`:
699
+ * the pictures step loaded every photo of the opening section eagerly, the
700
+ * last pass reads the stored page and asks no model, and Try again could only
701
+ * refuse the same page for the same reason. None of these is a choice a model
702
+ * makes better than the code that knows the answer:
703
+ *
704
+ * - `eager-image` (16): only the tree's first image may load eagerly; every
705
+ * later one has `loading` unset, so the renderer loads it when reached;
706
+ * - `autoplay-video` (16), its attribute half: `autoPlay` and `preload: auto`
707
+ * are taken off. A film with no poster still needs a picture, and is left;
708
+ * - `extra-font` (16): an `sx` font family outside an email is taken off, so
709
+ * the element reads the theme's typography;
710
+ * - `multiple-h1` and `skipped-heading` (11): every top-level heading after a
711
+ * page's first renders as an h2 (a layout's all do, a component's after its
712
+ * first), and a heading after it that skips a level renders one below the
713
+ * heading before it. Only the element changes (`component`): the variant,
714
+ * and so the look, is kept. A heading before a page's h1, and a page with no
715
+ * h1, are the model's to mend;
716
+ * - `multiple-main` and `landmark-in-fragment` (11): a `main` landmark after
717
+ * the first, and any in a component, a form or an email, is unset;
718
+ * - `grid-gap` (12): a Grid container's `sx` gap is taken off and becomes its
719
+ * `spacing` where it sets none, a number gap as itself, anything else 2,
720
+ * which is what the re-ask asks for;
721
+ * - `link-color-on-band` (5): a link or a button whose words are drawn in its
722
+ * band's own family is given that family's contrast text as its `sx` color,
723
+ * which is what the re-ask asks for.
724
+ *
725
+ * A tree with nothing to settle comes back as the same object, so settling is
726
+ * idempotent; a node that is changed is copied, never edited in place.
727
+ */
728
+ export declare function aiSettleMechanicalRules<T extends AiDoctrineTree>(tree: T, outputKind: AiOutputKind): T;
729
+ /**
730
+ * `aiSettleMechanicalRules` on a tree as the model wrote it, before the
731
+ * palette validator reads it (AGL-3660), as `aiSettleWrittenGridItems` settles
732
+ * its items. Anything that is no flat node map comes back as it was, and a
733
+ * node whose shape is not a palette node's is read as nothing and left as written.
734
+ */
735
+ export declare function aiSettleWrittenMechanicalRules(input: unknown, outputKind: AiOutputKind): unknown;
692
736
  export interface AiDoctrineTreeReport {
693
737
  ok: boolean;
694
738
  /** The palette validator's result: the tree to store, its repairs and its id map. */
@@ -754,6 +798,46 @@ export declare function detectPlanSplitLists(plan: AiBuildPlan, inventory: AiSit
754
798
  * can answer with the form's bare name, which is no reference at all.
755
799
  */
756
800
  export declare function aiSettlePlanRefs(plan: AiBuildPlan, inventory?: AiSiteInventory | null): AiBuildPlan;
801
+ /**
802
+ * What customers say, which no dataset of the site's holds: a review, a
803
+ * testimonial, a rating, a quote (AGL-3616). A job never writes them for the
804
+ * people who did not, so a dataset of them is one nothing can seed.
805
+ */
806
+ export declare const AI_DATASET_SAID_NAME: RegExp;
807
+ /**
808
+ * A plan that creates more datasets than this job may make is settled in
809
+ * code, never re-asked (AGL-3616). The live eval of 2026-10-10 refused a
810
+ * dental practice's plan four times over for a fourth dataset, and a food
811
+ * bank's re-ask for one broke a layout rule its first answer had kept.
812
+ *
813
+ * A dataset of what customers say (`AI_DATASET_SAID_NAME`, in its name or a
814
+ * field) is let go first, whatever the cap: both of those plans made one of
815
+ * testimonials, which nothing may seed.
816
+ *
817
+ * The datasets kept are the most useful: the ones the most sections list,
818
+ * a record template counting double, plan order breaking ties. Each one let
819
+ * go leaves the plan whole — its `new:` reference comes off every section,
820
+ * a record template of it becomes a page of its own, and a section that
821
+ * listed it shows fewer items than rule 8 binds, so its items are written
822
+ * out on the page. Where the job may create no dataset at all, rule 7 still
823
+ * asks, as it does for any creation.
824
+ */
825
+ export declare function aiSettlePlanDatasets(plan: AiBuildPlan, capabilities?: AiPlanCapabilities | null): AiBuildPlan;
826
+ /**
827
+ * A form's dataset, settled in code before the rules read the plan
828
+ * (AGL-3616, Zach 2026-10-10: Aglyn AI's forms write to its datasets).
829
+ *
830
+ * - A `writesTo` that names nothing the form can write to comes off: a
831
+ * `new:` of no dataset this plan creates, an id the inventory does not
832
+ * hold, or a dataset this job may not create. The form is then the
833
+ * form it always was, its submissions in the Inbox. A dataset the
834
+ * inventory holds, named by its name rather than its id, is given its id.
835
+ * - A dataset this plan creates for a form holds the people who send it:
836
+ * no page lists it and none is its record template, so its `new:` comes
837
+ * off every section and record that named it, and a section that listed
838
+ * it shows fewer items than rule 8 binds.
839
+ */
840
+ export declare function aiSettlePlanFormDatasets(plan: AiBuildPlan, inventory: AiSiteInventory | null, capabilities?: AiPlanCapabilities | null): AiBuildPlan;
757
841
  /**
758
842
  * A page that names no layout, on a site where only one layout can frame it,
759
843
  * is put in that layout before the rules read the plan (2026-10-07). The
@@ -23,7 +23,7 @@ import { FREE_AI_TASTE_CREDITS_PER_MONTH } from "../plan-entitlements.js";
23
23
  import { REUSABLE_INSTANCE_COMPONENT_ID, REUSABLE_INSTANCE_PROP_VALUES_KEY } from "@aglyn/aglyn/app-utils/reusable-component-keys";
24
24
  import { parseBreakpointSpan } from "@aglyn/shared-data-enums/breakpoint-span";
25
25
  import { renderEmailHtml } from "@aglyn/shared-util-email/email-render";
26
- import { AI_BUILD_PLAN_CREATION_NOUNS, AI_BUILD_PLAN_EMBED_HOST_NAMES, AI_BUILD_PLAN_LIMITS, AI_PLAN_ASKS_FOR_VIDEO, aiEmbedHostOf, aiEmbedVideoKey, aiPlanCreateFor, aiPlanItemFor, aiPlanSlugKey, aiPlanUndeclaredRefs, isAiPlanNewRef } from "../model/ai-build-plan.js";
26
+ import { AI_BUILD_PLAN_CREATION_NOUNS, AI_BUILD_PLAN_EMBED_HOST_NAMES, AI_BUILD_PLAN_LIMITS, AI_PLAN_ASKS_FOR_VIDEO, aiEmbedHostOf, aiEmbedVideoKey, AI_PLAN_DATASET_ITEM_OP, aiPlanCreateFor, aiPlanFormDatasetNames, aiPlanItemFor, aiPlanSlugKey, aiPlanUndeclaredRefs, isAiPlanNewRef } from "../model/ai-build-plan.js";
27
27
  import { aiCreationNoun, aiPlanCanPlaceForm, aiPlanUncreatable, aiPlanUncreatableKind } from "../model/ai-plan-capabilities.js";
28
28
  import { aiFreeSiteSectionsWithin, aiSiteHomeMinSections, aiSitePlanIsHome } from "../model/ai-site-job.js";
29
29
  import { AI_INSTANCE_REF_PROP, validateAiNodeTree } from "./ai-node-tree.js";
@@ -2958,6 +2958,183 @@ const BUDGET_METRICS = [
2958
2958
  nodes
2959
2959
  }) : input;
2960
2960
  }
2961
+ // ── Mechanical rules, settled in code (AGL-3660) ─────────────────────────
2962
+ /**
2963
+ * THE RULES A CHECK WOULD REFUSE FOR AN ATTRIBUTE, SETTLED IN CODE (AGL-3660).
2964
+ *
2965
+ * A finding whose fix is one attribute, one level or one key, with nothing to
2966
+ * decide about what the page says, is applied before the check reads the
2967
+ * tree, as `aiSettleGridItems` sizes a container's items. A live Free guided
2968
+ * start's Portfolio page (2026-10-10) was refused for rule 16's `eager-image`:
2969
+ * the pictures step loaded every photo of the opening section eagerly, the
2970
+ * last pass reads the stored page and asks no model, and Try again could only
2971
+ * refuse the same page for the same reason. None of these is a choice a model
2972
+ * makes better than the code that knows the answer:
2973
+ *
2974
+ * - `eager-image` (16): only the tree's first image may load eagerly; every
2975
+ * later one has `loading` unset, so the renderer loads it when reached;
2976
+ * - `autoplay-video` (16), its attribute half: `autoPlay` and `preload: auto`
2977
+ * are taken off. A film with no poster still needs a picture, and is left;
2978
+ * - `extra-font` (16): an `sx` font family outside an email is taken off, so
2979
+ * the element reads the theme's typography;
2980
+ * - `multiple-h1` and `skipped-heading` (11): every top-level heading after a
2981
+ * page's first renders as an h2 (a layout's all do, a component's after its
2982
+ * first), and a heading after it that skips a level renders one below the
2983
+ * heading before it. Only the element changes (`component`): the variant,
2984
+ * and so the look, is kept. A heading before a page's h1, and a page with no
2985
+ * h1, are the model's to mend;
2986
+ * - `multiple-main` and `landmark-in-fragment` (11): a `main` landmark after
2987
+ * the first, and any in a component, a form or an email, is unset;
2988
+ * - `grid-gap` (12): a Grid container's `sx` gap is taken off and becomes its
2989
+ * `spacing` where it sets none, a number gap as itself, anything else 2,
2990
+ * which is what the re-ask asks for;
2991
+ * - `link-color-on-band` (5): a link or a button whose words are drawn in its
2992
+ * band's own family is given that family's contrast text as its `sx` color,
2993
+ * which is what the re-ask asks for.
2994
+ *
2995
+ * A tree with nothing to settle comes back as the same object, so settling is
2996
+ * idempotent; a node that is changed is copied, never edited in place.
2997
+ */ export function aiSettleMechanicalRules(tree, outputKind) {
2998
+ if (!tree.nodes[tree.rootId]) return tree;
2999
+ const nodes = _extends({}, tree.nodes);
3000
+ const copied = new Set();
3001
+ const edit = (id)=>{
3002
+ if (!copied.has(id)) {
3003
+ const node = nodes[id];
3004
+ nodes[id] = _extends({}, node, node.props ? {
3005
+ props: _extends({}, node.props)
3006
+ } : {}, node.sx ? {
3007
+ sx: _extends({}, node.sx)
3008
+ } : {});
3009
+ copied.add(id);
3010
+ }
3011
+ return nodes[id];
3012
+ };
3013
+ const unsetProp = (id, name)=>{
3014
+ var _nodes_id_props;
3015
+ if (((_nodes_id_props = nodes[id].props) == null ? void 0 : _nodes_id_props[name]) === undefined) return;
3016
+ delete edit(id).props[name];
3017
+ };
3018
+ const setProp = (id, name, value)=>{
3019
+ var _node_props;
3020
+ var _nodes_id_props;
3021
+ if (((_nodes_id_props = nodes[id].props) == null ? void 0 : _nodes_id_props[name]) === value) return;
3022
+ const node = edit(id);
3023
+ node.props = _extends({}, (_node_props = node.props) != null ? _node_props : {}, {
3024
+ [name]: value
3025
+ });
3026
+ };
3027
+ const unsetSx = (id, name)=>{
3028
+ var _nodes_id_sx;
3029
+ if (((_nodes_id_sx = nodes[id].sx) == null ? void 0 : _nodes_id_sx[name]) === undefined) return;
3030
+ delete edit(id).sx[name];
3031
+ };
3032
+ const visits = walkTree(tree);
3033
+ const email = outputKind === 'email';
3034
+ const framed = !email && outputKind !== 'form';
3035
+ const fragment = outputKind === 'component' || email || outputKind === 'form';
3036
+ let images = 0;
3037
+ let mains = 0;
3038
+ for (const { id, node } of visits){
3039
+ var _node_sx, _node_props;
3040
+ // Rule 16: one eager image, the first; the rest load when they are reached.
3041
+ if (node.componentId === 'image') {
3042
+ var _node_props1;
3043
+ images += 1;
3044
+ if (images > 1 && ((_node_props1 = node.props) == null ? void 0 : _node_props1['loading']) === 'eager') unsetProp(id, 'loading');
3045
+ }
3046
+ // Rule 16: a film loads nothing before it is played.
3047
+ if (node.componentId === 'video') {
3048
+ var _node_props2, _node_props3;
3049
+ if (((_node_props2 = node.props) == null ? void 0 : _node_props2['autoPlay']) === true) unsetProp(id, 'autoPlay');
3050
+ if (((_node_props3 = node.props) == null ? void 0 : _node_props3['preload']) === 'auto') unsetProp(id, 'preload');
3051
+ }
3052
+ // Rule 16: the theme's typography, outside an email.
3053
+ if (!email && ((_node_sx = node.sx) == null ? void 0 : _node_sx['fontFamily']) !== undefined) unsetSx(id, 'fontFamily');
3054
+ // Rule 11: one main landmark, and none in what is placed inside pages.
3055
+ if (elementOf(node) === 'main') {
3056
+ mains += 1;
3057
+ if (fragment || mains > 1) {
3058
+ var _node_props4, _node_props5;
3059
+ if (((_node_props4 = node.props) == null ? void 0 : _node_props4['component']) === 'main') unsetProp(id, 'component');
3060
+ if (((_node_props5 = node.props) == null ? void 0 : _node_props5['element']) === 'main') unsetProp(id, 'element');
3061
+ }
3062
+ }
3063
+ // Rule 12: a container is spaced by its `spacing`, never an sx gap.
3064
+ if (framed && node.componentId === GRID && ((_node_props = node.props) == null ? void 0 : _node_props['container']) === true) {
3065
+ const gap = GRID_GAP_SX_KEYS.find((key)=>{
3066
+ var _node_sx;
3067
+ return ((_node_sx = node.sx) == null ? void 0 : _node_sx[key]) !== undefined;
3068
+ });
3069
+ if (gap) {
3070
+ var _node_sx1, _node_props6;
3071
+ const value = (_node_sx1 = node.sx) == null ? void 0 : _node_sx1[gap];
3072
+ for (const key of GRID_GAP_SX_KEYS)unsetSx(id, key);
3073
+ if (((_node_props6 = node.props) == null ? void 0 : _node_props6['spacing']) === undefined) setProp(id, 'spacing', typeof value === 'number' ? value : 2);
3074
+ }
3075
+ }
3076
+ }
3077
+ // Rule 11: the heading outline, in document order.
3078
+ if (framed) {
3079
+ const headings = visits.map((visit)=>({
3080
+ id: visit.id,
3081
+ level: aiHeadingLevel(nodes[visit.id])
3082
+ })).filter((entry)=>entry.level !== null);
3083
+ const page = outputKind === 'page' || outputKind === 'template';
3084
+ const firstH1 = headings.findIndex((heading)=>heading.level === 1);
3085
+ // A page's outline is settled from its h1 on; a layout's and a component's from their first heading.
3086
+ const start = page ? firstH1 : 0;
3087
+ let previous = page ? 0 : null;
3088
+ for(let index = Math.max(start, 0); start >= 0 && index < headings.length; index += 1){
3089
+ const heading = headings[index];
3090
+ // A page keeps its first h1, a component its first, and a layout none.
3091
+ const keepsH1 = outputKind !== 'layout' && index === firstH1;
3092
+ let level = heading.level === 1 && !keepsH1 ? 2 : heading.level;
3093
+ previous != null ? previous : previous = level - 1;
3094
+ if (level > previous + 1) level = previous + 1;
3095
+ if (level !== heading.level) setProp(heading.id, 'component', `h${level}`);
3096
+ previous = level;
3097
+ }
3098
+ }
3099
+ // Rule 5: a link's words in its band's contrast text, read on the tree as settled so far.
3100
+ if (framed) {
3101
+ const settled = {
3102
+ rootId: tree.rootId,
3103
+ nodes
3104
+ };
3105
+ for (const visit of walkTree(settled)){
3106
+ var _node_sx2;
3107
+ if (!LINK_COMPONENTS.has(visit.node.componentId)) continue;
3108
+ const family = linkWordsFamily(visit.node);
3109
+ const band = family ? bandOf(settled, visit) : null;
3110
+ if (!band || band.family !== family) continue;
3111
+ const node = edit(visit.id);
3112
+ node.sx = _extends({}, (_node_sx2 = node.sx) != null ? _node_sx2 : {}, {
3113
+ color: `${family}.contrastText`
3114
+ });
3115
+ }
3116
+ }
3117
+ return copied.size ? _extends({}, tree, {
3118
+ nodes
3119
+ }) : tree;
3120
+ }
3121
+ /**
3122
+ * `aiSettleMechanicalRules` on a tree as the model wrote it, before the
3123
+ * palette validator reads it (AGL-3660), as `aiSettleWrittenGridItems` settles
3124
+ * its items. Anything that is no flat node map comes back as it was, and a
3125
+ * node whose shape is not a palette node's is read as nothing and left as written.
3126
+ */ export function aiSettleWrittenMechanicalRules(input, outputKind) {
3127
+ if (!isRecord(input) || typeof input['rootId'] !== 'string' || !isRecord(input['nodes'])) return input;
3128
+ const readable = Object.entries(input['nodes']).filter(([, node])=>isRecord(node) && typeof node['componentId'] === 'string' && (node['props'] === undefined || isRecord(node['props'])) && (node['sx'] === undefined || isRecord(node['sx'])) && (node['nodes'] === undefined || Array.isArray(node['nodes']) && node['nodes'].every((child)=>typeof child === 'string')));
3129
+ const tree = {
3130
+ rootId: input['rootId'],
3131
+ nodes: Object.fromEntries(readable)
3132
+ };
3133
+ const settled = aiSettleMechanicalRules(tree, outputKind);
3134
+ return settled === tree ? input : _extends({}, input, {
3135
+ nodes: _extends({}, input['nodes'], settled.nodes)
3136
+ });
3137
+ }
2961
3138
  /**
2962
3139
  * The whole check on one emitted tree: the palette validator first, then
2963
3140
  * every tree rule, then the budget. `otherPages` are the trees generated
@@ -3324,6 +3501,135 @@ const FORM_SECTION_NAME = /\bform\b|\bsign[- ]?up\b|\bsubscribe\b|\bnewsletter\b
3324
3501
  screens
3325
3502
  }) : plan;
3326
3503
  }
3504
+ /**
3505
+ * What customers say, which no dataset of the site's holds: a review, a
3506
+ * testimonial, a rating, a quote (AGL-3616). A job never writes them for the
3507
+ * people who did not, so a dataset of them is one nothing can seed.
3508
+ */ export const AI_DATASET_SAID_NAME = /\b(reviews?|testimonials?|ratings?|quotes?|stars?|endorsements?)\b/i;
3509
+ /**
3510
+ * A plan that creates more datasets than this job may make is settled in
3511
+ * code, never re-asked (AGL-3616). The live eval of 2026-10-10 refused a
3512
+ * dental practice's plan four times over for a fourth dataset, and a food
3513
+ * bank's re-ask for one broke a layout rule its first answer had kept.
3514
+ *
3515
+ * A dataset of what customers say (`AI_DATASET_SAID_NAME`, in its name or a
3516
+ * field) is let go first, whatever the cap: both of those plans made one of
3517
+ * testimonials, which nothing may seed.
3518
+ *
3519
+ * The datasets kept are the most useful: the ones the most sections list,
3520
+ * a record template counting double, plan order breaking ties. Each one let
3521
+ * go leaves the plan whole — its `new:` reference comes off every section,
3522
+ * a record template of it becomes a page of its own, and a section that
3523
+ * listed it shows fewer items than rule 8 binds, so its items are written
3524
+ * out on the page. Where the job may create no dataset at all, rule 7 still
3525
+ * asks, as it does for any creation.
3526
+ */ export function aiSettlePlanDatasets(plan, capabilities = null) {
3527
+ var _ref, _entry_left;
3528
+ const entry = capabilities == null ? void 0 : capabilities.create.dataset;
3529
+ if (!(entry == null ? void 0 : entry.allowed)) return plan;
3530
+ const cap = Math.min((_ref = capabilities == null ? void 0 : capabilities.datasetsMax) != null ? _ref : Infinity, (_entry_left = entry.left) != null ? _entry_left : Infinity);
3531
+ const datasets = plan.create.filter((one)=>one.kind === 'dataset');
3532
+ // A dataset a form writes to holds what visitors send (AGL-3616): its
3533
+ // fields may well be a rating or a quote request, and nothing seeds it.
3534
+ const forForms = aiPlanFormDatasetNames(plan);
3535
+ const said = (one)=>!forForms.has(one.name.trim().toLowerCase()) && (AI_DATASET_SAID_NAME.test(one.name) || one.fields.some((field)=>AI_DATASET_SAID_NAME.test(field)));
3536
+ if (!datasets.some(said) && (!Number.isFinite(cap) || datasets.length <= cap)) return plan;
3537
+ const named = (ref, name)=>!!ref && isAiPlanNewRef(ref) && ref.slice('new:'.length).trim().toLowerCase() === name.trim().toLowerCase();
3538
+ // A form's dataset counts as much as a record template: the form the
3539
+ // person asked for is built around it.
3540
+ const score = (name)=>(forForms.has(name.trim().toLowerCase()) ? 2 : 0) + plan.screens.reduce((total, screen)=>{
3541
+ var _screen_record;
3542
+ return total + (named((_screen_record = screen.record) == null ? void 0 : _screen_record.dataset, name) ? 2 : 0) + screen.sections.filter((section)=>section.uses.some((ref)=>named(ref, name))).length;
3543
+ }, 0);
3544
+ const ranked = datasets.filter((one)=>!said(one)).map((one, index)=>({
3545
+ name: one.name,
3546
+ index,
3547
+ score: score(one.name)
3548
+ })).sort((a, b)=>b.score - a.score || a.index - b.index);
3549
+ const kept = new Set(ranked.slice(0, Math.max(0, cap)).map((one)=>one.name.trim().toLowerCase()));
3550
+ const dropped = new Set(datasets.map((one)=>one.name.trim().toLowerCase()).filter((name)=>!kept.has(name)));
3551
+ const isDropped = (ref)=>[
3552
+ ...dropped
3553
+ ].some((name)=>named(ref, name));
3554
+ return _extends({}, plan, {
3555
+ create: plan.create.filter((one)=>one.kind !== 'dataset' || !dropped.has(one.name.trim().toLowerCase()))// A form whose dataset was let go sends its submissions to the Inbox only.
3556
+ .map((one)=>one.kind === 'form' && isDropped(one.writesTo) ? withoutWritesTo(one) : one),
3557
+ screens: plan.screens.map((screen)=>_extends({}, screen, {
3558
+ record: screen.record && isDropped(screen.record.dataset) ? null : screen.record,
3559
+ sections: screen.sections.map((section)=>section.uses.some(isDropped) ? _extends({}, section, {
3560
+ uses: section.uses.filter((ref)=>!isDropped(ref)),
3561
+ items: Math.min(section.items, AI_TYPED_LIST_MIN_ITEMS - 1)
3562
+ }) : section)
3563
+ }))
3564
+ });
3565
+ }
3566
+ /** A creation with no `writesTo`, as a plan that never said one reads. */ function withoutWritesTo(creation) {
3567
+ const rest = _extends({}, creation);
3568
+ delete rest.writesTo;
3569
+ return rest;
3570
+ }
3571
+ /**
3572
+ * A form's dataset, settled in code before the rules read the plan
3573
+ * (AGL-3616, Zach 2026-10-10: Aglyn AI's forms write to its datasets).
3574
+ *
3575
+ * - A `writesTo` that names nothing the form can write to comes off: a
3576
+ * `new:` of no dataset this plan creates, an id the inventory does not
3577
+ * hold, or a dataset this job may not create. The form is then the
3578
+ * form it always was, its submissions in the Inbox. A dataset the
3579
+ * inventory holds, named by its name rather than its id, is given its id.
3580
+ * - A dataset this plan creates for a form holds the people who send it:
3581
+ * no page lists it and none is its record template, so its `new:` comes
3582
+ * off every section and record that named it, and a section that listed
3583
+ * it shows fewer items than rule 8 binds.
3584
+ */ export function aiSettlePlanFormDatasets(plan, inventory, capabilities = null) {
3585
+ var _ref;
3586
+ if (!plan.create.some((one)=>one.kind === 'form' && one.writesTo)) return plan;
3587
+ const held = (_ref = inventory == null ? void 0 : inventory.datasets) != null ? _ref : [];
3588
+ const creatable = !capabilities || capabilities.create.dataset.allowed;
3589
+ let changed = false;
3590
+ const create = plan.create.map((one)=>{
3591
+ if (one.kind !== 'form' || !one.writesTo) return one;
3592
+ const ref = one.writesTo.trim();
3593
+ if (isAiPlanNewRef(ref)) {
3594
+ var _aiPlanCreateFor, _aiPlanItemFor;
3595
+ if (creatable && ((_aiPlanCreateFor = aiPlanCreateFor(plan, ref)) == null ? void 0 : _aiPlanCreateFor.kind) === 'dataset') return one;
3596
+ // A build's dataset item, which the data plugin's operation makes.
3597
+ if (((_aiPlanItemFor = aiPlanItemFor(plan, ref)) == null ? void 0 : _aiPlanItemFor.op) === AI_PLAN_DATASET_ITEM_OP) return one;
3598
+ } else {
3599
+ if (held.some((row)=>row.id === ref)) return one;
3600
+ const byName = held.find((row)=>aiNamesMatch(row.name, ref));
3601
+ if (byName) {
3602
+ changed = true;
3603
+ return _extends({}, one, {
3604
+ writesTo: byName.id
3605
+ });
3606
+ }
3607
+ }
3608
+ changed = true;
3609
+ return withoutWritesTo(one);
3610
+ });
3611
+ const settled = _extends({}, plan, {
3612
+ create
3613
+ });
3614
+ const forForms = aiPlanFormDatasetNames(settled);
3615
+ const listsOne = (ref)=>isAiPlanNewRef(ref) && forForms.has(ref.slice('new:'.length).trim().toLowerCase());
3616
+ const screens = plan.screens.map((screen)=>{
3617
+ const record = screen.record && listsOne(screen.record.dataset) ? null : screen.record;
3618
+ const sections = screen.sections.map((section)=>section.uses.some(listsOne) ? _extends({}, section, {
3619
+ uses: section.uses.filter((ref)=>!listsOne(ref)),
3620
+ items: Math.min(section.items, AI_TYPED_LIST_MIN_ITEMS - 1)
3621
+ }) : section);
3622
+ if (record === screen.record && sections.every((section, index)=>section === screen.sections[index])) return screen;
3623
+ changed = true;
3624
+ return _extends({}, screen, {
3625
+ record,
3626
+ sections
3627
+ });
3628
+ });
3629
+ return changed ? _extends({}, settled, {
3630
+ screens
3631
+ }) : plan;
3632
+ }
3327
3633
  /**
3328
3634
  * A page that names no layout, on a site where only one layout can frame it,
3329
3635
  * is put in that layout before the rules read the plan (2026-10-07). The