@avocadostudio-ai/orchestrator-core 0.3.1 → 0.3.3

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 (73) hide show
  1. package/dist/chat/anthropic-planner.d.ts +8 -0
  2. package/dist/chat/anthropic-planner.js +166 -12
  3. package/dist/chat/chat-pipeline-translation.d.ts +13 -0
  4. package/dist/chat/chat-pipeline-translation.js +109 -45
  5. package/dist/chat/chat-pipeline.d.ts +1 -1
  6. package/dist/chat/chat-pipeline.js +297 -53
  7. package/dist/chat/gemini-planner.d.ts +2 -0
  8. package/dist/chat/gemini-planner.js +2 -1
  9. package/dist/chat/hallucination-validator.d.ts +6 -0
  10. package/dist/chat/hallucination-validator.js +49 -8
  11. package/dist/chat/planner-types.d.ts +15 -0
  12. package/dist/chat/planner-types.js +2 -2
  13. package/dist/chat/planner.d.ts +12 -0
  14. package/dist/chat/planner.js +16 -2
  15. package/dist/chat/translation-chunking.d.ts +124 -0
  16. package/dist/chat/translation-chunking.js +371 -0
  17. package/dist/checks/field-walk.d.ts +25 -0
  18. package/dist/checks/field-walk.js +152 -0
  19. package/dist/checks/index.d.ts +5 -0
  20. package/dist/checks/index.js +4 -0
  21. package/dist/checks/page-weight.d.ts +22 -0
  22. package/dist/checks/page-weight.js +200 -0
  23. package/dist/checks/rules-draft.d.ts +2 -0
  24. package/dist/checks/rules-draft.js +375 -0
  25. package/dist/checks/run-checks.d.ts +32 -0
  26. package/dist/checks/run-checks.js +152 -0
  27. package/dist/checks/session-runner.d.ts +19 -0
  28. package/dist/checks/session-runner.js +95 -0
  29. package/dist/checks/types.d.ts +65 -0
  30. package/dist/checks/types.js +1 -0
  31. package/dist/cms/adapter.d.ts +1 -0
  32. package/dist/durable/durable-store-singleton.d.ts +37 -0
  33. package/dist/durable/durable-store-singleton.js +179 -0
  34. package/dist/durable/finding-impact.d.ts +30 -0
  35. package/dist/durable/finding-impact.js +53 -0
  36. package/dist/durable/in-memory-durable-store.d.ts +203 -0
  37. package/dist/durable/in-memory-durable-store.js +363 -0
  38. package/dist/durable/index.d.ts +5 -0
  39. package/dist/durable/index.js +4 -0
  40. package/dist/durable/pending-plan-store.d.ts +28 -0
  41. package/dist/durable/pending-plan-store.js +156 -0
  42. package/dist/durable/sqlite-durable-store.d.ts +71 -0
  43. package/dist/durable/sqlite-durable-store.js +631 -0
  44. package/dist/durable/types.d.ts +265 -0
  45. package/dist/durable/types.js +1 -0
  46. package/dist/handler/create-orchestrator.d.ts +4 -0
  47. package/dist/handler/create-orchestrator.js +85 -9
  48. package/dist/http/audio-actions.d.ts +1 -1
  49. package/dist/http/checks-actions.d.ts +39 -0
  50. package/dist/http/checks-actions.js +122 -0
  51. package/dist/http/history-actions.d.ts +1 -1
  52. package/dist/http/image-generate-actions.d.ts +2 -2
  53. package/dist/http/ops-actions.d.ts +2 -2
  54. package/dist/http/publish-actions.d.ts +4 -4
  55. package/dist/http/restore-actions.d.ts +3 -3
  56. package/dist/http/screenshot-actions.d.ts +2 -2
  57. package/dist/http/session-actions.d.ts +1 -1
  58. package/dist/http/telemetry-feedback-actions.d.ts +2 -2
  59. package/dist/http/unsplash-actions.d.ts +2 -2
  60. package/dist/http/variations-actions.d.ts +2 -2
  61. package/dist/index.d.ts +7 -0
  62. package/dist/index.js +27 -0
  63. package/dist/nlp/deterministic-planner-context.d.ts +16 -0
  64. package/dist/nlp/deterministic-planner-context.js +33 -7
  65. package/dist/nlp/deterministic-planner-suggestions.js +1 -1
  66. package/dist/nlp/plan-normalizer.js +193 -56
  67. package/dist/ops/destructive-action-gate.js +7 -2
  68. package/dist/ops/ops-engine.d.ts +12 -1
  69. package/dist/ops/ops-engine.js +41 -14
  70. package/dist/publish/publish-target-registry.js +1 -1
  71. package/dist/publish/publish-target.d.ts +1 -1
  72. package/dist/state/session-state.js +8 -1
  73. package/package.json +3 -3
@@ -24,8 +24,8 @@
24
24
  * page. So the path is declarable: per request, per registered site, or via
25
25
  * `createOrchestrator({ draftPath })`.
26
26
  */
27
- import type { Logger } from "../logger.js";
28
- import type { ActionResult } from "./history-actions.js";
27
+ import type { Logger } from "../logger.ts";
28
+ import type { ActionResult } from "./history-actions.ts";
29
29
  export type { ActionResult };
30
30
  export type ScreenshotParams = {
31
31
  session?: string;
@@ -17,7 +17,7 @@
17
17
  * embedding its own editor, and it would answer a question no embedded caller
18
18
  * should be asking.
19
19
  */
20
- import type { ActionResult } from "./history-actions.js";
20
+ import type { ActionResult } from "./history-actions.ts";
21
21
  export type { ActionResult };
22
22
  /**
23
23
  * The caller's bound session and a summary of its state.
@@ -15,8 +15,8 @@
15
15
  * status code and body to send; neither Fastify nor `Response` appears in this
16
16
  * file.
17
17
  */
18
- import type { FeedbackStore } from "../telemetry/feedback-store.js";
19
- import type { ActionResult } from "./history-actions.js";
18
+ import type { FeedbackStore } from "../telemetry/feedback-store.ts";
19
+ import type { ActionResult } from "./history-actions.ts";
20
20
  export type { ActionResult };
21
21
  /**
22
22
  * The store is a parameter, not a module singleton.
@@ -15,8 +15,8 @@
15
15
  * orchestrator runtime is ready, and why this file needs no collaborators
16
16
  * beyond an access key and a `fetch`.
17
17
  */
18
- import type { Logger } from "../logger.js";
19
- import type { ActionResult } from "./history-actions.js";
18
+ import type { Logger } from "../logger.ts";
19
+ import type { ActionResult } from "./history-actions.ts";
20
20
  export type { ActionResult };
21
21
  /**
22
22
  * Raw query values, straight off the wire.
@@ -17,8 +17,8 @@
17
17
  * wire bytes for callers that don't already have an SSE writer. Neither Fastify
18
18
  * nor `Response` appears in this file.
19
19
  */
20
- import { type VariationRequestBody, type VariationPipelineContext, type VariationResult, type VariationImageUpdate } from "../chat/variation-pipeline.js";
21
- import type { ActionResult } from "./history-actions.js";
20
+ import { type VariationRequestBody, type VariationPipelineContext, type VariationResult, type VariationImageUpdate } from "../chat/variation-pipeline.ts";
21
+ import type { ActionResult } from "./history-actions.ts";
22
22
  export type { ActionResult };
23
23
  export type { VariationRequestBody, VariationPipelineContext };
24
24
  export type ParsedVariationRequest = {
package/dist/index.d.ts CHANGED
@@ -2,3 +2,10 @@ export { createOrchestrator, type CreateOrchestratorConfig, type OrchestratorHan
2
2
  export type { OrchestratorAuth, AuthContext } from "./handler/auth.ts";
3
3
  export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, CmsMediaItem, CmsMediaPage, CmsMediaQuery, ResolvedCapabilities } from "./cms/adapter.ts";
4
4
  export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaSourceConfig } from "./cms/index.ts";
5
+ export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.ts";
6
+ export type { PublishTarget, PublishContext, PublishOutcome, PublishStatus, PublishResult } from "./publish/publish-target.ts";
7
+ export { SqliteDurableStore, InMemoryDurableStore, getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral, type SqliteDurableStoreOptions, type InMemoryDurableStoreOptions, type DurableStore, type FindingInput, type FindingRecord, type FindingQuery, type FindingSeverity, type FindingStatus, type FindingEvidence, type CheckRunInput, type CheckRunRecord, type CheckRunPatch, type CheckRunTrigger, type MemoryInput, type MemoryRecord, type MemoryQuery, type MemoryScope, type MemoryKind, type MemorySource, type MemoryStatus, type CorrectionInput, type CorrectionRecord, type CorrectionQuery, type CorrectionOutcome, type ProposalInput, type ProposalRecord, type ProposalQuery, type ProposalStatus } from "./durable/index.ts";
8
+ export { runDraftChecks, runChecksForSession, scheduleChecksAfterApply, scheduleChecksAfterPublish, cancelScheduledChecks, fingerprintFor, DRAFT_RULES, walkPageFields, fieldText, type RunChecksArgs, type CheckRule, type CheckContext, type RuleFinding, type FieldEntry, type SiteView } from "./checks/index.ts";
9
+ export { runChecksAction, listFindingsAction, listCheckRunsAction, updateFindingAction, type RunChecksParams, type ListFindingsParams, type UpdateFindingParams, type ChecksScope } from "./http/checks-actions.ts";
10
+ export { durableHealth, noteDurableFailure, isDiscardInFlight } from "./durable/durable-store-singleton.ts";
11
+ export { loadPendingPlan, savePendingPlan, clearPendingPlan, peekPendingPlan } from "./durable/pending-plan-store.ts";
package/dist/index.js CHANGED
@@ -19,3 +19,30 @@
19
19
  // The Fastify HTTP wrapper lives in apps/orchestrator and imports from here.
20
20
  export { createOrchestrator } from "./handler/create-orchestrator.js";
21
21
  export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel } from "./cms/index.js";
22
+ // The publish-target plugin point. `docs-site/integration/publishing.mdx` has
23
+ // documented this as the way to publish somewhere we do not ship a target for
24
+ // since before the package had an export map, and told the reader to import it
25
+ // from `"./publish/publish-target-registry.js"` — a relative path into *this*
26
+ // repository, pasted into their file. It cannot resolve from anywhere, and
27
+ // there was no specifier that could: the symbol was not exported here, so a
28
+ // registry consumer had no reachable way to register a target at all.
29
+ //
30
+ // The interface was always meant to be public — `architecture.mdx` calls it the
31
+ // integration point for "S3, GitLab Pages, Netlify, a CMS API, a custom CI/CD
32
+ // pipeline". Only the export was missing.
33
+ export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.js";
34
+ // The durable substrate for findings, memory, corrections and proposals. It is
35
+ // public because the implementation is meant to be replaceable: a library-mode
36
+ // host on a serverless platform has no persistent disk for a SQLite file, and
37
+ // every record here would otherwise die with the process. `DurableStore` is the
38
+ // seam a Neon- or Turso-backed store fills — which is only true if a consumer
39
+ // installing from the registry can reach the type.
40
+ export { SqliteDurableStore, InMemoryDurableStore, getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral } from "./durable/index.js";
41
+ // The draft-tier checks: pure functions of a PageDoc and the block manifest,
42
+ // producing findings into the durable store. Public so an integrator can run
43
+ // them (or add a rule of their own) against a site we do not host.
44
+ export { runDraftChecks, runChecksForSession, scheduleChecksAfterApply, scheduleChecksAfterPublish, cancelScheduledChecks, fingerprintFor, DRAFT_RULES, walkPageFields, fieldText } from "./checks/index.js";
45
+ // The checks HTTP surface, mounted by both transports.
46
+ export { runChecksAction, listFindingsAction, listCheckRunsAction, updateFindingAction } from "./http/checks-actions.js";
47
+ export { durableHealth, noteDurableFailure, isDiscardInFlight } from "./durable/durable-store-singleton.js";
48
+ export { loadPendingPlan, savePendingPlan, clearPendingPlan, peekPendingPlan } from "./durable/pending-plan-store.js";
@@ -46,6 +46,22 @@ export declare function selectedBlockSnapshot(args: {
46
46
  selectedEditablePath: string | null;
47
47
  selectedEditableValue: {} | null;
48
48
  } | null;
49
+ /**
50
+ * How an item in a list prop gets named to the planner, in precedence order.
51
+ *
52
+ * A list whose items match none of these reaches the planner as a bare count,
53
+ * and the planner then cannot answer "update the FAQ entry about freezing" —
54
+ * it knows there are three entries and nothing about any of them. It says so,
55
+ * correctly, and asks which one. Three of the twelve list fields in the block
56
+ * registry were in that state: `FAQAccordion.items` names its question `q`,
57
+ * `Testimonials.items` has `author`/`quote`, and `Gallery.images` has
58
+ * `caption`/`alt`. None of those were listed here.
59
+ *
60
+ * `arrayPropLabelKeys` is asserted against the block registry by a test, so a
61
+ * new block with a differently-named item field fails there rather than
62
+ * silently costing the planner a clarification round-trip.
63
+ */
64
+ export declare const ARRAY_PROP_LABEL_KEYS: readonly ["label", "title", "heading", "question", "q", "name", "author", "caption", "alt", "quote", "value", "text"];
49
65
  export declare function arrayPropLengths(props: Record<string, unknown>): Record<string, {
50
66
  length: number;
51
67
  labels?: string[];
@@ -184,6 +184,35 @@ export function selectedBlockSnapshot(args) {
184
184
  // ---------------------------------------------------------------------------
185
185
  // Array prop metadata
186
186
  // ---------------------------------------------------------------------------
187
+ /**
188
+ * How an item in a list prop gets named to the planner, in precedence order.
189
+ *
190
+ * A list whose items match none of these reaches the planner as a bare count,
191
+ * and the planner then cannot answer "update the FAQ entry about freezing" —
192
+ * it knows there are three entries and nothing about any of them. It says so,
193
+ * correctly, and asks which one. Three of the twelve list fields in the block
194
+ * registry were in that state: `FAQAccordion.items` names its question `q`,
195
+ * `Testimonials.items` has `author`/`quote`, and `Gallery.images` has
196
+ * `caption`/`alt`. None of those were listed here.
197
+ *
198
+ * `arrayPropLabelKeys` is asserted against the block registry by a test, so a
199
+ * new block with a differently-named item field fails there rather than
200
+ * silently costing the planner a clarification round-trip.
201
+ */
202
+ export const ARRAY_PROP_LABEL_KEYS = [
203
+ "label",
204
+ "title",
205
+ "heading",
206
+ "question",
207
+ "q",
208
+ "name",
209
+ "author",
210
+ "caption",
211
+ "alt",
212
+ "quote",
213
+ "value",
214
+ "text"
215
+ ];
187
216
  export function arrayPropLengths(props) {
188
217
  const out = {};
189
218
  for (const [key, value] of Object.entries(props)) {
@@ -192,13 +221,10 @@ export function arrayPropLengths(props) {
192
221
  const labels = [];
193
222
  for (const item of value) {
194
223
  if (typeof item === "object" && item !== null) {
195
- const labelValue = item.label ??
196
- item.title ??
197
- item.heading ??
198
- item.question ??
199
- item.name;
200
- if (typeof labelValue === "string")
201
- labels.push(labelValue);
224
+ const record = item;
225
+ const labelKey = ARRAY_PROP_LABEL_KEYS.find((candidate) => typeof record[candidate] === "string");
226
+ if (labelKey)
227
+ labels.push(record[labelKey]);
202
228
  }
203
229
  }
204
230
  out[key] = labels.length > 0 ? { length: value.length, labels } : { length: value.length };
@@ -31,7 +31,7 @@ export function promptFromPropKey(propKey, blockType) {
31
31
  return CLARIFICATION_LABELS[propKey];
32
32
  const human = blockType ? getPropDisplayName(blockType, propKey) : propKey;
33
33
  // Fall back to the registry-declared label so host-app overrides (e.g.
34
- // villa's TwoColumn adding `sectionId`) surface as "Edit Section id" rather
34
+ // a host site's TwoColumn adding `sectionId`) surface as "Edit Section id" rather
35
35
  // than leaking the raw camelCase key into the UI.
36
36
  return human === propKey ? `Edit ${propKey}` : `Edit ${human.toLowerCase()}`;
37
37
  }
@@ -1,6 +1,135 @@
1
- import { allowedBlockTypes, declaredDefaultPropsForType, defaultPropsForType as sharedDefaultPropsForType } from "@avocadostudio-ai/shared";
1
+ import { allowedBlockTypes, blockSchemas, declaredDefaultPropsForType, defaultPropsForType as sharedDefaultPropsForType, getBlockMeta } from "@avocadostudio-ai/shared";
2
2
  import { extractRouteMentions, firstRouteMention, normalizeRouteCandidate, parseCreatePageRequest } from "./intent-helpers.js";
3
3
  // ---------------------------------------------------------------------------
4
+ // Prop-name aliasing, asked of the registry rather than of a literal
5
+ // ---------------------------------------------------------------------------
6
+ /*
7
+ * Models confuse Avocado's own prop names — `heading` for a section that calls
8
+ * it `title`, `question`/`answer` for FAQ items that call them `q`/`a`. The
9
+ * aliases below exist to repair that, and they are worth keeping.
10
+ *
11
+ * They were applied by comparing the block type against the literal string
12
+ * "Hero", which is a name only Avocado's own catalogue has. Every other block
13
+ * in the world is `!== "Hero"`, so a site that brings its own blocks and names
14
+ * a text prop `heading` had that prop deleted here and replaced with `title` —
15
+ * a prop its schema does not have. The ops engine then stripped `title` as a
16
+ * hallucinated prop, the edit vanished, and the user was told that "some
17
+ * requested styling isn't available" on their block. Nothing in the chain was
18
+ * wrong about its own job; the first link was answering a question about a name
19
+ * instead of about a schema.
20
+ *
21
+ * So ask the registry. A rename now requires positive evidence in both
22
+ * directions: the block cannot take the key the planner used, and can take the
23
+ * one we would rewrite it to. Every other case — unknown block type, a block
24
+ * that accepts both, a block that accepts neither — leaves the value alone,
25
+ * which is the answer that loses no data.
26
+ */
27
+ function blockAcceptsProp(blockType, prop) {
28
+ if (!blockType)
29
+ return false;
30
+ const meta = getBlockMeta(blockType);
31
+ if (meta?.fields && prop in meta.fields)
32
+ return true;
33
+ // Manifest-registered blocks may carry a schema richer than their derived
34
+ // meta, so the schema gets the second look rather than the first refusal.
35
+ const shape = blockSchemas[blockType]?.shape;
36
+ return Boolean(shape && prop in shape);
37
+ }
38
+ /*
39
+ * Avocado's own `autoplay` / `loop` / `striped` are string enums ("true" /
40
+ * "false"), not booleans, so a model that emits a real boolean has to be
41
+ * coerced. That coercion ran on every block type, and a custom block whose
42
+ * `loop` is a genuine `z.boolean()` had the string `"true"` written into it —
43
+ * silently, and only on the chat path, so it looked like a CMS problem.
44
+ *
45
+ * Coerce only where the block actually declares the prop as a string enum.
46
+ */
47
+ const BOOLEAN_ENUM_PROPS = ["autoplay", "loop", "striped"];
48
+ /**
49
+ * Ask the block's own schema, which is the only thing that actually knows:
50
+ * rewrite a boolean to `"true"`/`"false"` exactly when the schema rejects the
51
+ * boolean and accepts the string. A block that takes a real boolean keeps it,
52
+ * a block that takes either keeps what the planner sent, and an unregistered
53
+ * type is left alone.
54
+ */
55
+ function coerceBooleanEnumProps(blockType, props) {
56
+ const shape = blockSchemas[blockType]?.shape;
57
+ if (!shape)
58
+ return;
59
+ for (const key of BOOLEAN_ENUM_PROPS) {
60
+ const value = props[key];
61
+ if (typeof value !== "boolean")
62
+ continue;
63
+ const field = shape[key];
64
+ if (typeof field?.safeParse !== "function")
65
+ continue;
66
+ const asString = value ? "true" : "false";
67
+ /*
68
+ * Round-trip, not merely "parses". Avocado's own `autoplay` is
69
+ * `z.enum(["true","false"]).default("false").catch("false")`, and `.catch`
70
+ * means a boolean parses *successfully* — into the wrong value. Accepting
71
+ * that as "the block wants a boolean" would leave the silent wrong answer
72
+ * in place. A schema genuinely wants a boolean only when it gives the
73
+ * boolean back unchanged.
74
+ */
75
+ const asBool = field.safeParse(value);
76
+ if (asBool.success && asBool.data === value)
77
+ continue;
78
+ const asStr = field.safeParse(asString);
79
+ if (!asStr.success || asStr.data !== asString)
80
+ continue;
81
+ props[key] = asString;
82
+ }
83
+ }
84
+ /** Rename `from`→`to` only when the block demonstrably wants `to` and not `from`. */
85
+ function shouldAliasProp(blockType, from, to) {
86
+ return !blockAcceptsProp(blockType, from) && blockAcceptsProp(blockType, to);
87
+ }
88
+ /*
89
+ * The same question for a key inside a list item. `listFields[key].itemFields`
90
+ * is the declared shape; a block with no declared list metadata answers "no"
91
+ * to both halves and is therefore left alone.
92
+ */
93
+ function listItemAcceptsKey(blockType, listKey, itemKey) {
94
+ if (!blockType)
95
+ return false;
96
+ const itemFields = getBlockMeta(blockType)?.listFields?.[listKey]?.itemFields;
97
+ return Boolean(itemFields && itemKey in itemFields);
98
+ }
99
+ function shouldAliasItemKey(blockType, listKey, from, to) {
100
+ return (!listItemAcceptsKey(blockType, listKey, from) && listItemAcceptsKey(blockType, listKey, to));
101
+ }
102
+ const ITEM_KEY_ALIASES = {
103
+ question: "q",
104
+ answer: "a",
105
+ testimonial: "quote",
106
+ review: "quote"
107
+ };
108
+ /**
109
+ * Apply the list-item aliases to one array prop, block-type aware.
110
+ * Shared by the `update_props` and `add_block` paths so they cannot drift.
111
+ */
112
+ function aliasListItems(blockType, listKey, value) {
113
+ return value.map((item) => {
114
+ if (!item || typeof item !== "object" || Array.isArray(item))
115
+ return item;
116
+ const entry = item;
117
+ let changed = false;
118
+ const mapped = {};
119
+ for (const [k, v] of Object.entries(entry)) {
120
+ const alias = ITEM_KEY_ALIASES[k.toLowerCase()];
121
+ if (alias && !(alias in entry) && shouldAliasItemKey(blockType, listKey, k.toLowerCase(), alias)) {
122
+ mapped[alias] = v;
123
+ changed = true;
124
+ }
125
+ else {
126
+ mapped[k] = v;
127
+ }
128
+ }
129
+ return changed ? mapped : entry;
130
+ });
131
+ }
132
+ // ---------------------------------------------------------------------------
4
133
  // Op reordering: create_page must precede ops targeting the same slug
5
134
  // ---------------------------------------------------------------------------
6
135
  function reorderCreatePageFirst(ops) {
@@ -582,6 +711,19 @@ export function normalizePlanCandidate(input, args) {
582
711
  return args.currentPage.blocks[idx - 1]?.id;
583
712
  };
584
713
  const usedBlockIds = new Set((args?.currentPage?.blocks ?? []).map((b) => b.id));
714
+ /**
715
+ * Block ids this pass renamed, old → new.
716
+ *
717
+ * Renaming an `add_block` id is only safe if the rest of the plan follows it.
718
+ * A plan that adds `b_hero_x` and then updates `b_hero_x` means the block it
719
+ * just added; leave the reference pointing at the colliding name and the
720
+ * update lands on the *existing* block instead — content written over the
721
+ * wrong block, which is the failure mode this repo keeps re-shipping. Ops are
722
+ * normalized in order, so only ops after the rename are rewritten: a
723
+ * reference that came *before* the add_block can only have meant the block
724
+ * that was already there.
725
+ */
726
+ const renamedBlockIds = new Map();
585
727
  let createdPageSlug;
586
728
  let droppedPageLevelUpdate = false;
587
729
  // itemIds already claimed by a remove_item freeze in THIS plan — used to spot
@@ -843,6 +985,19 @@ export function normalizePlanCandidate(input, args) {
843
985
  raw.afterBlockId =
844
986
  raw.after_block_id ?? raw.after ?? raw.insertAfterId ?? beforeToAfter(raw.beforeId ?? raw.insertBeforeId);
845
987
  }
988
+ // Follow any add_block id an earlier op in this plan had to uniquify. Runs
989
+ // after the alias resolution above so `after_block_id` and friends are
990
+ // already folded into the canonical keys. See `renamedBlockIds`.
991
+ if (renamedBlockIds.size > 0) {
992
+ for (const key of ["blockId", "afterBlockId", "newBlockId"]) {
993
+ const value = raw[key];
994
+ if (typeof value === "string") {
995
+ const renamed = renamedBlockIds.get(value);
996
+ if (renamed)
997
+ raw[key] = renamed;
998
+ }
999
+ }
1000
+ }
846
1001
  if (!raw.afterPageSlug) {
847
1002
  raw.afterPageSlug =
848
1003
  raw.afterPageSlug ??
@@ -886,38 +1041,17 @@ export function normalizePlanCandidate(input, args) {
886
1041
  const patch = raw.patch;
887
1042
  const targetBlock = args?.currentPage?.blocks.find((b) => b.id === raw.blockId);
888
1043
  const blockType = targetBlock?.type ?? "";
889
- if (blockType !== "Hero" && "heading" in patch && !("title" in patch)) {
1044
+ if ("heading" in patch && !("title" in patch) && shouldAliasProp(blockType, "heading", "title")) {
890
1045
  patch.title = patch.heading;
891
1046
  delete patch.heading;
892
1047
  }
893
- const itemKeyAliases = { question: "q", answer: "a", testimonial: "quote", review: "quote" };
894
1048
  for (const [propKey, propVal] of Object.entries(patch)) {
895
1049
  if (!Array.isArray(propVal))
896
1050
  continue;
897
- patch[propKey] = propVal.map((item) => {
898
- if (!item || typeof item !== "object" || Array.isArray(item))
899
- return item;
900
- const entry = item;
901
- let changed = false;
902
- const mapped = {};
903
- for (const [k, v] of Object.entries(entry)) {
904
- const alias = itemKeyAliases[k.toLowerCase()];
905
- if (alias && !(alias in entry)) {
906
- mapped[alias] = v;
907
- changed = true;
908
- }
909
- else {
910
- mapped[k] = v;
911
- }
912
- }
913
- return changed ? mapped : entry;
914
- });
1051
+ patch[propKey] = aliasListItems(blockType, propKey, propVal);
915
1052
  }
916
1053
  // Block-specific prop key remapping and type coercion (mirrors add_block path)
917
- for (const k of ["autoplay", "loop", "striped"]) {
918
- if (typeof patch[k] === "boolean")
919
- patch[k] = patch[k] ? "true" : "false";
920
- }
1054
+ coerceBooleanEnumProps(blockType, patch);
921
1055
  if (blockType === "Carousel" && Array.isArray(patch.slides) && !patch.items) {
922
1056
  patch.items = patch.slides;
923
1057
  delete patch.slides;
@@ -1138,51 +1272,57 @@ export function normalizePlanCandidate(input, args) {
1138
1272
  block.props = {};
1139
1273
  }
1140
1274
  }
1141
- if ((!block.id || typeof block.id !== "string") && typeof block.type === "string") {
1142
- let fallbackId = `b_${String(block.type).toLowerCase()}_${Date.now()}`;
1275
+ /*
1276
+ * The model authors this id. Nothing in the `add_block` prompt says how
1277
+ * to build one, so it free-associates from the page and the block type —
1278
+ * `b_featuregrid_wellness` for a FeatureGrid on /avocado-wellness. That is
1279
+ * a near-deterministic function of the prompt, so the same request asked
1280
+ * twice produces the same id twice, and the second one dies in the ops
1281
+ * engine on "Block id … already exists", taking the whole plan down with
1282
+ * it: a committing apply is all-or-nothing.
1283
+ *
1284
+ * `usedBlockIds` is seeded from the page's existing blocks and knew this
1285
+ * all along. It was consulted only when the model *omitted* an id, which
1286
+ * is the one case that cannot collide; a supplied id went through
1287
+ * untouched and was never even registered, so two ops in one plan could
1288
+ * pick the same name. Uniquify instead — a fourth FeatureGrid is a
1289
+ * legitimate reading of "populate this page", and only the label was
1290
+ * wrong. The engine's own check stays as the backstop and must never be
1291
+ * relaxed into an overwrite.
1292
+ */
1293
+ const suppliedId = typeof block.id === "string" && block.id.length > 0 ? block.id : null;
1294
+ const idStem = suppliedId ?? (typeof block.type === "string" ? `b_${String(block.type).toLowerCase()}_${Date.now()}` : null);
1295
+ if (idStem) {
1296
+ let candidate = idStem;
1143
1297
  let sfx = 0;
1144
- while (usedBlockIds.has(fallbackId)) {
1298
+ while (usedBlockIds.has(candidate)) {
1145
1299
  sfx++;
1146
- fallbackId = `b_${String(block.type).toLowerCase()}_${Date.now()}_${sfx}`;
1300
+ candidate = `${idStem}_${sfx}`;
1147
1301
  }
1148
- usedBlockIds.add(fallbackId);
1149
- block.id = fallbackId;
1302
+ usedBlockIds.add(candidate);
1303
+ if (suppliedId && candidate !== suppliedId)
1304
+ renamedBlockIds.set(suppliedId, candidate);
1305
+ block.id = candidate;
1150
1306
  }
1151
1307
  raw.block = block;
1152
1308
  // Remap heading→title for non-Hero blocks (LLMs often confuse heading/title)
1153
1309
  if (block.props && typeof block.props === "object" && !Array.isArray(block.props)) {
1154
1310
  const bProps = block.props;
1155
1311
  const blockType = typeof block.type === "string" ? block.type : "";
1156
- if (blockType !== "Hero" && "heading" in bProps && !("title" in bProps)) {
1312
+ if ("heading" in bProps && !("title" in bProps) && shouldAliasProp(blockType, "heading", "title")) {
1157
1313
  bProps.title = bProps.heading;
1158
1314
  delete bProps.heading;
1159
1315
  }
1160
1316
  }
1161
- // Remap list item keys inside add_block props (e.g., question→q, answer→a)
1317
+ // Remap list item keys inside add_block props (e.g., question→q, answer→a),
1318
+ // asking the block's own list metadata rather than rewriting every array.
1162
1319
  if (block.props && typeof block.props === "object" && !Array.isArray(block.props)) {
1163
1320
  const bProps = block.props;
1164
- const itemKeyAliases = { question: "q", answer: "a", testimonial: "quote", review: "quote" };
1321
+ const blockType = typeof block.type === "string" ? block.type : "";
1165
1322
  for (const [propKey, propVal] of Object.entries(bProps)) {
1166
1323
  if (!Array.isArray(propVal))
1167
1324
  continue;
1168
- bProps[propKey] = propVal.map((item) => {
1169
- if (!item || typeof item !== "object" || Array.isArray(item))
1170
- return item;
1171
- const entry = item;
1172
- let changed = false;
1173
- const mapped = {};
1174
- for (const [k, v] of Object.entries(entry)) {
1175
- const alias = itemKeyAliases[k.toLowerCase()];
1176
- if (alias && !(alias in entry)) {
1177
- mapped[alias] = v;
1178
- changed = true;
1179
- }
1180
- else {
1181
- mapped[k] = v;
1182
- }
1183
- }
1184
- return changed ? mapped : entry;
1185
- });
1325
+ bProps[propKey] = aliasListItems(blockType, propKey, propVal);
1186
1326
  }
1187
1327
  }
1188
1328
  // Block-specific prop key remapping and type coercion
@@ -1190,10 +1330,7 @@ export function normalizePlanCandidate(input, args) {
1190
1330
  const cProps = block.props;
1191
1331
  const bt = block.type;
1192
1332
  // Coerce boolean→string for "true"/"false" enum props (Carousel, Video, Table)
1193
- for (const k of ["autoplay", "loop", "striped"]) {
1194
- if (typeof cProps[k] === "boolean")
1195
- cProps[k] = cProps[k] ? "true" : "false";
1196
- }
1333
+ coerceBooleanEnumProps(typeof bt === "string" ? bt : "", cProps);
1197
1334
  // Carousel: slides→items
1198
1335
  if (bt === "Carousel" && Array.isArray(cProps.slides) && !cProps.items) {
1199
1336
  cProps.items = cProps.slides;
@@ -24,12 +24,17 @@ function slugsTouchedByOps(ops) {
24
24
  set.add(op.toPageSlug);
25
25
  continue;
26
26
  }
27
+ // rename_page: one page, under two names. Counting both slugs made every
28
+ // rename look like a cross-page plan and held it for approval — the same
29
+ // miscount duplicate_page and duplicate_block are special-cased for above.
30
+ if (op.op === "rename_page") {
31
+ set.add(typeof op.newPageSlug === "string" ? op.newPageSlug : op.pageSlug);
32
+ continue;
33
+ }
27
34
  if ("pageSlug" in op && typeof op.pageSlug === "string")
28
35
  set.add(op.pageSlug);
29
36
  if (op.op === "duplicate_block" && typeof op.toPageSlug === "string")
30
37
  set.add(op.toPageSlug);
31
- if (op.op === "rename_page" && typeof op.newPageSlug === "string")
32
- set.add(op.newPageSlug);
33
38
  }
34
39
  return Array.from(set);
35
40
  }
@@ -22,7 +22,18 @@ export declare function isNoEffectiveChangeError(reason: string): boolean;
22
22
  */
23
23
  export declare function isAlreadyCurrentError(reason: string): boolean;
24
24
  export declare function classifyGuardrailError(reason: string): GuardrailErrorCategory;
25
- export declare function formatValidationError(reason: string): string;
25
+ /**
26
+ * `category` is the one the failure was actually thrown with, for callers that
27
+ * still hold it. Without it this falls back to reading the category back out of
28
+ * the message, which is a guess — see `isRepairEligibleCategory`.
29
+ */
30
+ export declare function formatValidationError(reason: string, category?: GuardrailErrorCategory): string;
31
+ /**
32
+ * The deterministic repair pass exists for plans that are structurally wrong
33
+ * but fixable — a bad prop name, an id that clashes. Anything else is either
34
+ * hopeless or not the planner's fault, and re-prompting it wastes a model call.
35
+ */
36
+ export declare function isRepairEligibleCategory(category: GuardrailErrorCategory): category is "schema_violation";
26
37
  export declare function isDeterministicRepairEligible(reason: string): boolean;
27
38
  /**
28
39
  * Extracts structured fields from a planner schema_violation reason so the