@avocadostudio-ai/orchestrator-core 0.3.2 → 0.4.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.
Files changed (88) 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 +312 -54
  7. package/dist/chat/gemini-planner.d.ts +2 -0
  8. package/dist/chat/gemini-planner.js +2 -1
  9. package/dist/chat/planner-types.d.ts +15 -0
  10. package/dist/chat/planner-types.js +2 -2
  11. package/dist/chat/planner.d.ts +12 -0
  12. package/dist/chat/planner.js +16 -2
  13. package/dist/chat/prompts.d.ts +5 -0
  14. package/dist/chat/prompts.js +92 -9
  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 +42 -0
  18. package/dist/checks/field-walk.js +198 -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 +439 -0
  25. package/dist/checks/run-checks.d.ts +42 -0
  26. package/dist/checks/run-checks.js +159 -0
  27. package/dist/checks/session-runner.d.ts +19 -0
  28. package/dist/checks/session-runner.js +99 -0
  29. package/dist/checks/types.d.ts +109 -0
  30. package/dist/checks/types.js +1 -0
  31. package/dist/cms/adapter.d.ts +74 -1
  32. package/dist/cms/adapter.js +1 -0
  33. package/dist/cms/index.d.ts +1 -1
  34. package/dist/cms/index.js +1 -1
  35. package/dist/cms/media-sources.d.ts +29 -1
  36. package/dist/cms/media-sources.js +188 -7
  37. package/dist/durable/durable-store-singleton.d.ts +37 -0
  38. package/dist/durable/durable-store-singleton.js +179 -0
  39. package/dist/durable/finding-impact.d.ts +30 -0
  40. package/dist/durable/finding-impact.js +53 -0
  41. package/dist/durable/in-memory-durable-store.d.ts +203 -0
  42. package/dist/durable/in-memory-durable-store.js +363 -0
  43. package/dist/durable/index.d.ts +5 -0
  44. package/dist/durable/index.js +4 -0
  45. package/dist/durable/pending-plan-store.d.ts +28 -0
  46. package/dist/durable/pending-plan-store.js +156 -0
  47. package/dist/durable/sqlite-durable-store.d.ts +71 -0
  48. package/dist/durable/sqlite-durable-store.js +631 -0
  49. package/dist/durable/types.d.ts +265 -0
  50. package/dist/durable/types.js +1 -0
  51. package/dist/handler/create-orchestrator.d.ts +4 -0
  52. package/dist/handler/create-orchestrator.js +283 -32
  53. package/dist/http/audio-actions.d.ts +1 -1
  54. package/dist/http/checks-actions.d.ts +39 -0
  55. package/dist/http/checks-actions.js +122 -0
  56. package/dist/http/history-actions.d.ts +44 -1
  57. package/dist/http/history-actions.js +122 -0
  58. package/dist/http/image-generate-actions.d.ts +2 -2
  59. package/dist/http/ops-actions.d.ts +2 -2
  60. package/dist/http/publish-actions.d.ts +15 -4
  61. package/dist/http/publish-actions.js +3 -3
  62. package/dist/http/restore-actions.d.ts +3 -3
  63. package/dist/http/screenshot-actions.d.ts +2 -2
  64. package/dist/http/session-actions.d.ts +1 -1
  65. package/dist/http/telemetry-feedback-actions.d.ts +2 -2
  66. package/dist/http/unsplash-actions.d.ts +2 -2
  67. package/dist/http/variations-actions.d.ts +2 -2
  68. package/dist/index.d.ts +9 -2
  69. package/dist/index.js +28 -1
  70. package/dist/nlp/deterministic-planner-context.d.ts +16 -0
  71. package/dist/nlp/deterministic-planner-context.js +33 -7
  72. package/dist/nlp/intent-detection.d.ts +16 -0
  73. package/dist/nlp/intent-detection.js +15 -1
  74. package/dist/nlp/plan-normalizer.js +66 -32
  75. package/dist/ops/destructive-action-gate.js +7 -2
  76. package/dist/ops/ops-engine.d.ts +12 -1
  77. package/dist/ops/ops-engine.js +41 -14
  78. package/dist/publish/publish-helpers.d.ts +12 -2
  79. package/dist/publish/publish-helpers.js +10 -3
  80. package/dist/publish/publish-selection.d.ts +84 -0
  81. package/dist/publish/publish-selection.js +113 -0
  82. package/dist/publish/publish-target-registry.js +1 -1
  83. package/dist/publish/publish-target.d.ts +1 -1
  84. package/dist/publish/targets/git.js +2 -2
  85. package/dist/state/session-state.js +8 -1
  86. package/dist/state/site-assets.d.ts +41 -0
  87. package/dist/state/site-assets.js +40 -0
  88. package/package.json +3 -3
@@ -87,6 +87,21 @@ export type CommonGeneratePlanArgs = {
87
87
  thinking?: {
88
88
  effort: PlannerEffort;
89
89
  };
90
+ /**
91
+ * Multiplier on the planner's output-token budget (Anthropic). The pipeline
92
+ * raises it on a retry that followed a `max_tokens` truncation, so the next
93
+ * attempt has room the previous one lacked. Ignored by other providers.
94
+ */
95
+ outputTokenScale?: number;
96
+ /**
97
+ * Restrict the block-schema contracts sent to the model to these types.
98
+ *
99
+ * Set by the translation chunker: a chunk holding a Hero and a CTA cannot
100
+ * legitimately emit an op for a Gallery, so shipping every block's contract to
101
+ * every chunk repeats the largest part of the request for nothing. Leave unset
102
+ * anywhere the model may reference a type that isn't already on the page.
103
+ */
104
+ contractBlockTypeAllowlist?: string[];
90
105
  };
91
106
  /**
92
107
  * Effort levels accepted by the Messages API `output_config.effort` field on the
@@ -29,7 +29,7 @@ const openAIPlanner = {
29
29
  },
30
30
  async generatePlan(args) {
31
31
  // OpenAI doesn't yet support Anthropic-style thinking events — drop them.
32
- const { onStatusUpdate: _s, onImageProgress: _i, log: _l, onThinking: _t, thinking: _th, ...rest } = args;
32
+ const { onStatusUpdate: _s, onImageProgress: _i, log: _l, onThinking: _t, thinking: _th, outputTokenScale: _o, ...rest } = args;
33
33
  return generatePlanWithOpenAI(rest);
34
34
  },
35
35
  };
@@ -44,7 +44,7 @@ const geminiPlanner = {
44
44
  supportsNativeTools: true,
45
45
  parseIntent: parseIntentWithGemini,
46
46
  async generatePlan(args) {
47
- const { onThinking: _t, thinking: _th, ...rest } = args;
47
+ const { onThinking: _t, thinking: _th, outputTokenScale: _o, ...rest } = args;
48
48
  return generatePlanWithGemini(rest);
49
49
  },
50
50
  };
@@ -45,6 +45,16 @@ export declare function buildPlannerSchemaContext(args: {
45
45
  forceFullContracts?: boolean;
46
46
  componentsManifest?: BlockManifest;
47
47
  effectiveBlockTypes?: string[];
48
+ /**
49
+ * Hard ceiling on which block contracts may be sent, whatever mode is chosen.
50
+ *
51
+ * A page-wide translation asks for `full` contracts because it edits every
52
+ * block on the page — but a *chunk* of one edits only the blocks it holds, and
53
+ * shipping all eighteen contracts to each of six chunks repeats ~9 KB six times
54
+ * for no gain. Set only where the caller can guarantee the model cannot
55
+ * legitimately reference a type outside the list.
56
+ */
57
+ contractBlockTypeAllowlist?: string[];
48
58
  }): {
49
59
  payload: PlannerSchemaContextPayload;
50
60
  meta: PlannerSchemaContextMeta;
@@ -136,6 +146,8 @@ export declare function generatePlanWithOpenAI(args: {
136
146
  client?: PlannerOpenAIClient;
137
147
  siteContextBlock?: string | null;
138
148
  forceFullSchemaContracts?: boolean;
149
+ /** Restrict block-schema contracts to these types. See CommonGeneratePlanArgs. */
150
+ contractBlockTypeAllowlist?: string[];
139
151
  componentsManifest?: BlockManifest;
140
152
  lightweight?: boolean;
141
153
  signal?: AbortSignal;
@@ -191,13 +191,26 @@ function buildContractsForMode(args) {
191
191
  payload.pageMetaContract = pageMetaContractSummary();
192
192
  return payload;
193
193
  }
194
+ /** Narrow the contract set to an allowlist, when the caller supplied a non-empty one. */
195
+ function restrictContracts(allContracts, allowlist) {
196
+ if (!allowlist || allowlist.length === 0)
197
+ return allContracts;
198
+ const restricted = {};
199
+ for (const type of allowlist) {
200
+ if (type in allContracts)
201
+ restricted[type] = allContracts[type];
202
+ }
203
+ // An allowlist that matches nothing is a caller bug, not an instruction to
204
+ // send the model a page it has no schema for — fall back to everything.
205
+ return Object.keys(restricted).length > 0 ? restricted : allContracts;
206
+ }
194
207
  export function buildPlannerSchemaContext(args) {
195
208
  const strictJsonEnabled = isStrictJsonResponseEnabled();
196
209
  const targetBlockTypes = pickTargetBlockTypes({ message: args.message, contextPack: args.contextPack });
197
210
  const includePageMetaContract = /\b(seo|meta|metadata|og\s*image|open\s*graph|description|structured\s*data|schema\.org)\b/i.test(args.message) || /\d{2,3}\s*char/i.test(args.message);
198
211
  const knownTypes = args.effectiveBlockTypes
199
212
  ?? (args.componentsManifest ? args.componentsManifest.blocks.map(c => c.type) : Object.keys(blockSchemas));
200
- const allContracts = blockContractsSummary(args.componentsManifest);
213
+ const allContracts = restrictContracts(blockContractsSummary(args.componentsManifest), args.contractBlockTypeAllowlist);
201
214
  if (!isAdaptiveSchemaContextEnabled()) {
202
215
  const payload = args.legacyIncludeContracts
203
216
  ? {
@@ -1162,7 +1175,8 @@ export async function generatePlanWithOpenAI(args) {
1162
1175
  pageWideTranslation,
1163
1176
  legacyIncludeContracts: includeContracts,
1164
1177
  forceFullContracts: args.forceFullSchemaContracts,
1165
- componentsManifest: args.componentsManifest
1178
+ componentsManifest: args.componentsManifest,
1179
+ contractBlockTypeAllowlist: args.contractBlockTypeAllowlist
1166
1180
  });
1167
1181
  const user = {
1168
1182
  request: args.message,
@@ -65,3 +65,8 @@ export declare function buildPlannerSystemPromptSegments(opts: PlannerPromptOpti
65
65
  stable: string;
66
66
  dynamic: string;
67
67
  };
68
+ /**
69
+ * The subset of the corrections above that is true of THIS site's catalogue.
70
+ * Returns one prompt line per surviving correction, or an empty array.
71
+ */
72
+ export declare function propNameCorrectionLines(effectiveBlockTypes: string[]): string[];
@@ -4,6 +4,7 @@
4
4
  * Eliminates duplication between OpenAI and Anthropic planner modules.
5
5
  * Provider-specific extensions are injected via the `provider` option.
6
6
  */
7
+ import { blockAcceptsProp, blockListItemAcceptsKey } from "@avocadostudio-ai/shared";
7
8
  // ---------------------------------------------------------------------------
8
9
  // Intent parser
9
10
  // ---------------------------------------------------------------------------
@@ -154,7 +155,13 @@ const ANTHROPIC_IMAGE_TOOL_LINES = [
154
155
  "When using image.generate, write the returned imageUrl into the relevant imageUrl field and set imageAlt from the returned alt text.",
155
156
  ];
156
157
  const BLOCK_NAME_PRIVACY_OPENAI = "Never mention internal block IDs (b_hero_*, b_featuregrid_*, etc.), prop names (imageUrl, imageAlt), or system settings in summary_for_user or change_log. Use human-friendly descriptions instead (e.g. 'Update the Hero image' not 'Update imageUrl on b_hero_123').";
157
- const BLOCK_NAME_PRIVACY_ANTHROPIC = "Never mention internal block IDs (b_hero_*, b_featuregrid_*, etc.), prop names (imageUrl, imageAlt), or system settings in summary_for_user, change_log, or suggested_next_actions. Also avoid raw block type names like 'RichText', 'FeatureGrid', 'CardGrid', 'FAQAccordion' — use natural descriptions instead: 'text section', 'features grid', 'card grid', 'FAQ section'. Exception: 'Hero', 'CTA', and 'Testimonials' are fine as-is since users understand these terms.";
158
+ /*
159
+ * Stated as a rule about identifiers rather than as a list of our block names:
160
+ * a site with its own catalogue has its own CamelCase type names, and an
161
+ * allowlist of ours neither covers them nor describes them. The test is whether
162
+ * the word reads as English to someone who has never seen the schema.
163
+ */
164
+ const BLOCK_NAME_PRIVACY_ANTHROPIC = "Never mention internal block IDs (b_hero_*, b_featuregrid_*, etc.), prop names (imageUrl, imageAlt), or system settings in summary_for_user, change_log, or suggested_next_actions. Also avoid raw block type names — they are schema identifiers, not words users know. Describe the section by what it is: a type named 'RichText' is a 'text section', 'FeatureGrid' a 'features grid', 'FAQAccordion' an 'FAQ section', 'heroSplit' or 'PageHeader' just 'the hero' or 'the page header'. The test is whether the word reads as ordinary English rather than as a name from a schema; where it does — 'Hero', 'CTA', 'Testimonials', 'Gallery' — using it as-is is fine.";
158
165
  // ---------------------------------------------------------------------------
159
166
  // Full planner prompt — composed from section builders.
160
167
  // Each section becomes a ## HEADER in the emitted prompt so the LLM can
@@ -168,16 +175,18 @@ function joinSections(sections) {
168
175
  }
169
176
  function buildFullPlannerSegments(opts) {
170
177
  const hasNativeTools = opts.provider === "anthropic" || opts.provider === "gemini";
171
- // Stable sections — depend only on provider (fixed for a given planner) and
172
- // static rule constants. These bytes are identical across requests and are
173
- // the part Anthropic caches. IMAGES is here too; it depends on hasNativeTools
174
- // but that's stable per provider.
178
+ // Stable sections — the part Anthropic caches. "Stable" means stable across
179
+ // *requests*, not constant: IMAGES varies with hasNativeTools and OPERATION
180
+ // CATALOG with the site's block catalogue, and both are fixed for a given
181
+ // provider and site. Two sites with different catalogues get two cache
182
+ // entries, which is the correct trade — a prompt that tells the model the
183
+ // wrong prop names caches beautifully and edits the wrong field.
175
184
  const stableSections = [
176
185
  sectionRole(),
177
186
  sectionOutputContract(),
178
187
  sectionIntentDecisionTree(),
179
188
  sectionVoice(opts, hasNativeTools),
180
- sectionOperationCatalog(),
189
+ sectionOperationCatalog(opts),
181
190
  sectionSchemaDiscipline(),
182
191
  sectionImages(hasNativeTools),
183
192
  ];
@@ -241,14 +250,88 @@ function sectionVoice(opts, hasNativeTools) {
241
250
  if (hasNativeTools) {
242
251
  lines.push("For edit_plan intent: summary_for_user must be ONE short sentence (max ~20 words) describing what the plan will do. Do NOT elaborate, explain why, or describe the content being added — let change_log carry the detail. Bad: 'Updated the hero heading with a punchier tone.' Good: 'Will add a **text section** about blueberry varieties after the features grid.'", "change_log coverage is MANDATORY: emit exactly one change_log entry per op, in the same order as ops[], describing what that specific op does. If ops has N entries, change_log must have N entries — never cluster multiple ops into one entry, never skip an op, never leave an op undescribed. The user reads change_log to decide whether to approve; a missing entry is a silent bait-and-switch.", "change_log entries should add specific detail NOT already in summary_for_user — e.g. list the actual content, items, or values being set. Do not paraphrase the summary.");
243
252
  }
244
- lines.push("In summary_for_user, use simple markdown for readability: **bold** for key terms or labels, and bullet lists (- item) when listing multiple items, recommendations, or observations. Keep it scannable — avoid walls of text.", "When rewriting text, return plain text unless the prop is a rich-text prop (see below) or the user explicitly asks for markdown formatting. Do not wrap the entire rewrite in **bold** markers.", RULE_RICH_TEXT_PROPS, "For copy in German or similar long-compound languages, insert soft hyphen opportunities in long compounds where helpful for responsive line wrapping. Use the Unicode soft hyphen character (U+00AD), never HTML entities like ­ or ­.", opts.provider !== "openai" ? BLOCK_NAME_PRIVACY_ANTHROPIC : BLOCK_NAME_PRIVACY_OPENAI, "", "### suggested_next_actions", "2-4 short imperative phrases the user could type next (max 6 words each). Each MUST be a logical follow-up to the specific change just made — not a generic action. NEVER suggest 'Open /X' or any navigation to a page that the current plan is creating, duplicating, or otherwise still pending — the page won't exist until the user approves the plan, and there is no special navigation chip handler (suggestions are sent verbatim as the next chat command). Suggest plan refinements instead (e.g. 'Use a punchier hero headline', 'Add a card grid for spotlights', 'Drop the FAQ section'). Ask yourself: 'what would the user likely want to do next given THIS edit?' When the plan contains exactly one update_props op that changes a text field, the first 1-2 suggestions MUST be refinements of that same field (e.g. 'Make it shorter', 'Try a bolder tone', 'Revert to previous'). For example, after rewriting stats labels, suggest refining the same section ('Make the numbers bigger', 'Add a stat about X') — not unrelated actions like 'Change title' or 'Add a Testimonials section'. For needs_clarification, suggest the most likely concrete answers. Omit suggested_next_actions entirely if no contextual follow-up is obvious. Every suggestion must be an action the user can perform inside this editor — restricted to the block types in blockContracts / blockCatalogue (Hero, FeatureGrid, Testimonials, FAQAccordion, CTA, Card, CardGrid, RichText, TwoColumn, Banner, Carousel, Embed, Footer, Gallery, Quote, SiteHeader, Stats, Table, Tabs, Video) or SEO/site-config edits. NEVER suggest unsupported features: no forms, no email capture, no contact forms, no subscribe boxes, no newsletter signups, no popups/modals, no chat widgets, no live video, no payment/checkout — these require custom code the editor cannot produce. Never suggest actions outside the editor's scope such as A/B testing, analytics, performance monitoring, user research, or marketing strategy.");
253
+ lines.push("In summary_for_user, use simple markdown for readability: **bold** for key terms or labels, and bullet lists (- item) when listing multiple items, recommendations, or observations. Keep it scannable — avoid walls of text.", "When rewriting text, return plain text unless the prop is a rich-text prop (see below) or the user explicitly asks for markdown formatting. Do not wrap the entire rewrite in **bold** markers.", RULE_RICH_TEXT_PROPS, "For copy in German or similar long-compound languages, insert soft hyphen opportunities in long compounds where helpful for responsive line wrapping. Use the Unicode soft hyphen character (U+00AD), never HTML entities like ­ or ­.", opts.provider !== "openai" ? BLOCK_NAME_PRIVACY_ANTHROPIC : BLOCK_NAME_PRIVACY_OPENAI, "", "### suggested_next_actions", "2-4 short imperative phrases the user could type next (max 6 words each). Each MUST be a logical follow-up to the specific change just made — not a generic action. NEVER suggest 'Open /X' or any navigation to a page that the current plan is creating, duplicating, or otherwise still pending — the page won't exist until the user approves the plan, and there is no special navigation chip handler (suggestions are sent verbatim as the next chat command). Suggest plan refinements instead (e.g. 'Use a punchier hero headline', 'Add a card grid for spotlights', 'Drop the FAQ section'). Ask yourself: 'what would the user likely want to do next given THIS edit?' When the plan contains exactly one update_props op that changes a text field, the first 1-2 suggestions MUST be refinements of that same field (e.g. 'Make it shorter', 'Try a bolder tone', 'Revert to previous'). For example, after rewriting stats labels, suggest refining the same section ('Make the numbers bigger', 'Add a stat about X') — not unrelated actions like 'Change title' or 'Add a Testimonials section'. For needs_clarification, suggest the most likely concrete answers. Omit suggested_next_actions entirely if no contextual follow-up is obvious. Every suggestion must be an action the user can perform inside this editor — restricted to the block types listed in blockContracts / blockCatalogue for THIS site, or SEO/site-config edits. Never suggest adding a section this site has no block for; the catalogue is the whole list, not a sample of a larger one. NEVER suggest unsupported features: no forms, no email capture, no contact forms, no subscribe boxes, no newsletter signups, no popups/modals, no chat widgets, no live video, no payment/checkout — these require custom code the editor cannot produce. Never suggest actions outside the editor's scope such as A/B testing, analytics, performance monitoring, user research, or marketing strategy.");
254
+ return lines;
255
+ }
256
+ /*
257
+ * Prop-name corrections the model actually needs, and the reason they are not
258
+ * written as prose.
259
+ *
260
+ * Models reach for the obvious English word — `heading` for a section title,
261
+ * `question`/`answer` for an FAQ entry, `testimonial` for a quote — and several
262
+ * of Avocado's built-in blocks chose a different name. Telling the model so is
263
+ * worth real accuracy, and the line that did it read: "use 'title' not
264
+ * 'heading' for section titles (except Hero which uses 'heading')".
265
+ *
266
+ * That is a fact about Avocado's own catalogue stated as a fact about section
267
+ * titles. A site that brings its own blocks and names a text prop `heading` was
268
+ * being instructed, in the system prompt, to emit a prop its schema does not
269
+ * have. It is the same mistake the plan normalizer was fixed for in 0.3.3 — the
270
+ * normalizer no longer *creates* the wrong prop name, but nothing had stopped
271
+ * the prompt from *asking* for it, so the bug simply moved one layer up.
272
+ *
273
+ * So each correction now names the block type it is about, and is emitted only
274
+ * when the registry confirms it is still true: the type is in this site's
275
+ * catalogue, it really does reject the wrong key, and it really does accept the
276
+ * right one. A site that registers its own `Hero` with a `title` prop gets no
277
+ * line about Hero; a site with no FAQAccordion gets no line about `q`/`a`.
278
+ */
279
+ const PROP_NAME_CORRECTIONS = [
280
+ { type: "Hero", wrong: "title", right: "heading", what: "the headline" },
281
+ { type: "FeatureGrid", wrong: "heading", right: "title", what: "the section title" },
282
+ { type: "CardGrid", wrong: "heading", right: "title", what: "the section title" },
283
+ { type: "Testimonials", wrong: "heading", right: "title", what: "the section title" },
284
+ { type: "FAQAccordion", wrong: "heading", right: "title", what: "the section title" },
285
+ { type: "CTA", wrong: "title", right: "heading", what: "the headline" },
286
+ ];
287
+ const LIST_ITEM_CORRECTIONS = [
288
+ { type: "FAQAccordion", list: "items", wrong: "question", right: "q" },
289
+ { type: "FAQAccordion", list: "items", wrong: "answer", right: "a" },
290
+ { type: "Testimonials", list: "items", wrong: "testimonial", right: "quote" },
291
+ ];
292
+ /**
293
+ * The subset of the corrections above that is true of THIS site's catalogue.
294
+ * Returns one prompt line per surviving correction, or an empty array.
295
+ */
296
+ export function propNameCorrectionLines(effectiveBlockTypes) {
297
+ const present = new Set(effectiveBlockTypes);
298
+ const lines = [];
299
+ for (const c of PROP_NAME_CORRECTIONS) {
300
+ if (!present.has(c.type))
301
+ continue;
302
+ if (blockAcceptsProp(c.type, c.wrong))
303
+ continue;
304
+ if (!blockAcceptsProp(c.type, c.right))
305
+ continue;
306
+ lines.push(`${c.type} names ${c.what} '${c.right}', not '${c.wrong}'.`);
307
+ }
308
+ for (const c of LIST_ITEM_CORRECTIONS) {
309
+ if (!present.has(c.type))
310
+ continue;
311
+ if (blockListItemAcceptsKey(c.type, c.list, c.wrong))
312
+ continue;
313
+ if (!blockListItemAcceptsKey(c.type, c.list, c.right))
314
+ continue;
315
+ lines.push(`${c.type} \`${c.list}\` entries use '${c.right}', not '${c.wrong}'.`);
316
+ }
245
317
  return lines;
246
318
  }
247
- function sectionOperationCatalog() {
319
+ function sectionOperationCatalog(opts) {
320
+ /*
321
+ * The universal rule first, so it governs even when no correction below
322
+ * applies: a prop name is a property of one block type's schema, never of the
323
+ * English word for the thing it holds.
324
+ */
325
+ const corrections = propNameCorrectionLines(opts.effectiveBlockTypes);
326
+ const addBlockPropRule = "add_block: use the exact prop names blockContracts lists for THAT block type. A prop name is a fact about one block's schema, not about the kind of content it holds — never carry a prop name across from another block type, and never infer one from the English word for the field ('heading', 'question', 'subtitle'). If blockContracts does not list a prop, the block does not have it: omit it rather than inventing a plausible name." +
327
+ (corrections.length > 0
328
+ ? ` Corrections for blocks on this site that models routinely get wrong: ${corrections.join(" ")}`
329
+ : "") +
330
+ " Always populate block.props with REAL content matching the user's request (headlines, body copy, list items grounded in the user's topic) — never emit add_block with empty or missing props; the system falls back to demo template defaults if you omit props, and that template will mislead the user. Placement: set `afterBlockId` to the id of the block the new section should follow — e.g. to add a section 'below the hero' / 'under the hero' / 'after the hero', set afterBlockId to the hero block's id from the pageOutline. Honor any position the user names ('below the hero', 'above the footer', 'after the pricing'). Omit afterBlockId only when no position is implied — it then appends at the very end of the page.";
248
331
  return [
249
332
  "## OPERATION CATALOG",
250
333
  "update_props: blockId is required and must target an existing block id (b_*). Never use a page route/path as blockId or path. Use blockId values from the pageOutline — never invent block IDs. Set patch to changed props only; use existing prop keys for the target block type. Emit keys in this exact order: op, pageSlug (if present), blockId, patch.",
251
- "add_block: use exact prop names from blockContracts. Always populate block.props with REAL content matching the user's request (headlines, body copy, list items grounded in the user's topic) — never emit add_block with empty or missing props. The system will fall back to demo template defaults (e.g. 'Key features / Fast setup / Safe edits / Live updates') if you omit props, and that template will mislead the user. Common mistakes: use 'title' not 'heading' for section titles (except Hero which uses 'heading'), use 'q'/'a' not 'question'/'answer' for FAQ items, use 'quote' not 'testimonial' for Testimonials items. Placement: set `afterBlockId` to the id of the block the new section should follow — e.g. to add a section 'below the hero' / 'under the hero' / 'after the hero', set afterBlockId to the hero block's id from the pageOutline. Honor any position the user names ('below the hero', 'above the footer', 'after the pricing'). Omit afterBlockId only when no position is implied — it then appends at the very end of the page.",
334
+ addBlockPropRule,
252
335
  "remove_block: delete an ENTIRE block/section from the page (blockId required, no listKey). Use this — NOT remove_item — whenever the user removes a whole section, INCLUDING when they pick it by POSITION or TYPE: 'delete the second card grid', 'remove the first feature grid', 'hide the third section', 'remove the FAQ', 'get rid of the testimonials'. For 'remove ALL the <type>' / 'delete every <type>', emit one remove_block per matching block. remove_item is ONLY for deleting ONE entry inside a block's list and is signalled by an item word scoped INTO a container ('remove the last card IN the grid', 'delete a question FROM the FAQ'). A bare '[the Nth] <block-or-section-noun>' with no into-container preposition is ALWAYS a remove_block — never remove an item to satisfy it.",
253
336
  "add_item / update_item / remove_item / move_item: edit ONE entry inside a block's list prop (e.g. FeatureGrid `features`, FAQAccordion `items`, Testimonials `items`, CardGrid `cards`). Required on every item op: pageSlug, blockId, listKey. Address the target entry by its stable `itemId` (the item's `id` field) WHEN the block's full props are in your context — never guess or invent an itemId you cannot see. Otherwise use the 0-based `index`; the system resolves it against the current page state, so an index is safe too. add_item: supply `item` with the entry's props (do NOT invent an `id`, one is assigned); optional `afterItemId` (or `afterIndex`) positions it, omit ⇒ append. update_item: `patch` is a merge-patch over that entry's fields. move_item: `afterItemId` (or `afterIndex`) sets the new position, omit ⇒ move to front. When changing SEVERAL entries of one list in a single plan, replacing the whole list with one update_props op is also fine.",
254
337
  "update_page_meta: set SEO metadata (title, description, ogImage) on a page. Patch is merge-patch: only supplied keys update. Set a field to empty string to clear it.",
@@ -0,0 +1,124 @@
1
+ import type { EditPlan, PageDoc } from "@avocadostudio-ai/shared";
2
+ import type { plannerContextPack } from "../nlp/deterministic-planner.ts";
3
+ import type { CommonGeneratePlanArgs, GeneratePlanResult } from "./planner-types.ts";
4
+ export declare function translationChunkingConfig(): {
5
+ enabled: boolean;
6
+ /**
7
+ * Translatable bytes to aim for per chunk.
8
+ *
9
+ * Chunk wall clock is roughly a fixed time-to-first-token plus streaming time
10
+ * proportional to this. Measured on Sonnet 5: ~5s to first token, then ~130
11
+ * output tokens/second, and a chunk's output runs a little over one token per
12
+ * source byte. ~900 bytes puts streaming (~8s) in the same range as the fixed
13
+ * cost, which is where splitting further stops buying much and starts paying
14
+ * another chunk's input tokens for nothing.
15
+ */
16
+ targetBytes: number;
17
+ /** Upper bound on parallel planner calls — one wave, not a stampede. */
18
+ maxChunks: number;
19
+ /** Below these, a single request is already fast and chunking only adds input cost. */
20
+ minBlocks: number;
21
+ minBytes: number;
22
+ };
23
+ export type TranslationChunk = {
24
+ blockIds: string[];
25
+ /** Translatable bytes carried by this chunk — the driver of its output length. */
26
+ bytes: number;
27
+ };
28
+ /**
29
+ * Partition the page's translatable blocks into balanced chunks.
30
+ *
31
+ * Wall clock is the *heaviest* chunk, not the average one, so balance is the
32
+ * whole game: a first live run split a page into 1604 / 1010 / 172 / 769 bytes
33
+ * and spent 17 of its 23 seconds waiting on the first bin while a 172-byte bin
34
+ * sat finished. Packing blocks in page order is what produces that — it has to
35
+ * close a chunk before it can see what comes next.
36
+ *
37
+ * So blocks are packed heaviest-first into whichever chunk is currently lightest
38
+ * (longest-processing-time-first), which bounds the heaviest chunk far better,
39
+ * and page order is restored afterwards so the change log still reads top to
40
+ * bottom. Blocks are never split: an `update_props` op carries a whole block.
41
+ *
42
+ * Returns a single chunk when the page is too small to be worth splitting; the
43
+ * caller treats that as "don't chunk".
44
+ */
45
+ export declare function planTranslationChunks(args: {
46
+ page: PageDoc;
47
+ targetBytes?: number;
48
+ maxChunks?: number;
49
+ }): TranslationChunk[];
50
+ /** Whether this page is worth fanning out, per the configured thresholds. */
51
+ export declare function shouldChunkTranslation(page: PageDoc, chunks: TranslationChunk[]): boolean;
52
+ /**
53
+ * The page as this chunk should see it: same id/slug/meta, only its own blocks.
54
+ *
55
+ * Everything downstream in the planner derives from `currentPage` — the output
56
+ * token budget, and the enumerated translation checklist handed to the model —
57
+ * so narrowing the page is what makes a chunk a chunk. No extra plumbing.
58
+ */
59
+ export declare function subsetPageForChunk(page: PageDoc, blockIds: Set<string>): PageDoc;
60
+ /** The context pack narrowed to the chunk's blocks, so input cost doesn't repeat the whole page N times. */
61
+ export declare function subsetContextPackForChunk(pack: ReturnType<typeof plannerContextPack>, blockIds: Set<string>): ReturnType<typeof plannerContextPack>;
62
+ /**
63
+ * Keep only what this chunk was asked for.
64
+ *
65
+ * A chunk sees a slice of the page, but nothing stops a model from volunteering
66
+ * an op for a block it half-remembers from the site context, or from restructuring
67
+ * a page it was asked to translate. Ops outside the chunk's blocks are dropped
68
+ * rather than merged: a translation that also deletes a section is not a
69
+ * translation, and two chunks editing the same block would silently race.
70
+ */
71
+ export declare function filterChunkOps(args: {
72
+ ops: EditPlan["ops"];
73
+ blockIds: Set<string>;
74
+ isFirstChunk: boolean;
75
+ }): {
76
+ kept: EditPlan["ops"];
77
+ droppedCount: number;
78
+ };
79
+ export type ChunkOutcome = {
80
+ index: number;
81
+ blockIds: string[];
82
+ ok: boolean;
83
+ opCount: number;
84
+ droppedOpCount: number;
85
+ attempts: number;
86
+ /** Wall clock for this chunk. The slowest one is the request's latency — the number to tune against. */
87
+ durationMs: number;
88
+ bytes: number;
89
+ outputTokens: number;
90
+ reason?: string;
91
+ };
92
+ /**
93
+ * Run every chunk in parallel and merge the results into one plan.
94
+ *
95
+ * A chunk that dies on a token ceiling is retried once on its own, with double
96
+ * the budget — retrying one chunk is cheap where retrying the page is not. A
97
+ * chunk that still fails is left out: the merged plan is short those blocks, and
98
+ * the pipeline's translation coverage gate is what notices and repairs that. The
99
+ * whole call only fails when every chunk failed, so the caller's own retry loop
100
+ * sees a normal planner failure.
101
+ */
102
+ export declare function generateChunkedTranslationPlan(args: {
103
+ plannerArgs: CommonGeneratePlanArgs;
104
+ chunks: TranslationChunk[];
105
+ generate: (plannerArgs: CommonGeneratePlanArgs) => Promise<GeneratePlanResult>;
106
+ log?: {
107
+ warn: (obj: Record<string, unknown>, msg: string) => void;
108
+ info?: (obj: Record<string, unknown>, msg: string) => void;
109
+ };
110
+ onChunksSettled?: (outcomes: ChunkOutcome[]) => void;
111
+ }): Promise<GeneratePlanResult>;
112
+ /**
113
+ * Fold the chunk plans back into one.
114
+ *
115
+ * Chunks are balanced by weight rather than page position, so their ops come
116
+ * back shuffled; `blockOrder` puts them back the way the page reads, which is
117
+ * the order the change log and the plan preview are reviewed in.
118
+ *
119
+ * The user-facing summary is not concatenated: every chunk was given the user's
120
+ * original message, so every chunk wrote a summary of the same request
121
+ * ("Translated the page into Russian"), and stacking six of those reads like a
122
+ * stutter. The first one stands for all.
123
+ */
124
+ export declare function mergeChunkResults(results: GeneratePlanResult[], blockOrder?: string[]): GeneratePlanResult;