@avocadostudio-ai/orchestrator-core 0.1.0 → 0.2.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 (61) hide show
  1. package/dist/agent/sites-agent-context.js +3 -2
  2. package/dist/agent/sites-agent-shared.js +1 -0
  3. package/dist/chat/anthropic-planner.js +3 -3
  4. package/dist/chat/chat-pipeline.js +122 -20
  5. package/dist/chat/gemini-planner.js +3 -3
  6. package/dist/chat/planner.js +7 -5
  7. package/dist/chat/prompts.js +6 -1
  8. package/dist/cms/adapter.d.ts +159 -1
  9. package/dist/cms/adapter.js +19 -1
  10. package/dist/cms/bootstrap.d.ts +46 -1
  11. package/dist/cms/bootstrap.js +126 -2
  12. package/dist/cms/index.d.ts +3 -2
  13. package/dist/cms/index.js +2 -1
  14. package/dist/errors.d.ts +9 -1
  15. package/dist/handler/auth.d.ts +79 -0
  16. package/dist/handler/auth.js +113 -0
  17. package/dist/handler/create-orchestrator.d.ts +205 -0
  18. package/dist/handler/create-orchestrator.js +1599 -0
  19. package/dist/http/access-tokens.d.ts +58 -0
  20. package/dist/http/access-tokens.js +161 -0
  21. package/dist/http/audio-actions.d.ts +121 -0
  22. package/dist/http/audio-actions.js +248 -0
  23. package/dist/http/blocks-actions.d.ts +31 -0
  24. package/dist/http/blocks-actions.js +31 -0
  25. package/dist/http/draft-provenance.d.ts +68 -0
  26. package/dist/http/draft-provenance.js +101 -0
  27. package/dist/http/history-actions.d.ts +58 -0
  28. package/dist/http/history-actions.js +169 -0
  29. package/dist/http/image-generate-actions.d.ts +268 -0
  30. package/dist/http/image-generate-actions.js +546 -0
  31. package/dist/http/ops-actions.d.ts +51 -0
  32. package/dist/http/ops-actions.js +79 -0
  33. package/dist/http/publish-actions.d.ts +153 -0
  34. package/dist/http/publish-actions.js +323 -0
  35. package/dist/http/restore-actions.d.ts +67 -0
  36. package/dist/http/restore-actions.js +145 -0
  37. package/dist/http/screenshot-actions.d.ts +108 -0
  38. package/dist/http/screenshot-actions.js +181 -0
  39. package/dist/http/session-actions.d.ts +35 -0
  40. package/dist/http/session-actions.js +98 -0
  41. package/dist/http/telemetry-feedback-actions.d.ts +53 -0
  42. package/dist/http/telemetry-feedback-actions.js +68 -0
  43. package/dist/http/unsplash-actions.d.ts +64 -0
  44. package/dist/http/unsplash-actions.js +81 -0
  45. package/dist/http/variations-actions.d.ts +102 -0
  46. package/dist/http/variations-actions.js +104 -0
  47. package/dist/index.d.ts +4 -1
  48. package/dist/index.js +21 -1
  49. package/dist/nlp/deterministic-planner-refs.d.ts +1 -1
  50. package/dist/nlp/deterministic-planner-suggestions.d.ts +10 -0
  51. package/dist/nlp/deterministic-planner-suggestions.js +37 -11
  52. package/dist/nlp/plan-normalizer.js +18 -2
  53. package/dist/ops/ops-engine.js +219 -14
  54. package/dist/state/session-state.d.ts +56 -1
  55. package/dist/state/session-state.js +92 -6
  56. package/dist/state/sqlite-store-singleton.d.ts +22 -0
  57. package/dist/state/sqlite-store-singleton.js +49 -1
  58. package/dist/state/sqlite-store.d.ts +5 -0
  59. package/dist/state/sqlite-store.js +125 -2
  60. package/dist/telemetry/chat-telemetry.js +6 -1
  61. package/package.json +12 -16
@@ -18,7 +18,7 @@ const BLOCK_VISUAL_GUIDE = {
18
18
  CTA: { layout: "Centered section with headline, description, and one CTA button", whenToUse: "Drive conversions — sign-up, purchase, contact" },
19
19
  FAQAccordion: { layout: "Collapsible question-and-answer list", whenToUse: "FAQ sections, knowledge base summaries" },
20
20
  Stats: { layout: "Horizontal row of large numbers with labels", whenToUse: "Social proof, key metrics (e.g. '10K+ Users')" },
21
- RichText: { layout: "Freeform markdown/HTML text block", whenToUse: "Long-form content, articles, legal text, any prose" },
21
+ RichText: { layout: "Freeform prose; the body prop is rendered as markdown", whenToUse: "Long-form content, articles, legal text, any prose" },
22
22
  Testimonials: { layout: "Grid of quote cards with author names", whenToUse: "Customer reviews, social proof", confusedWith: "Quote (Quote is a single pull-quote; Testimonials is a grid of many)" },
23
23
  TwoColumn: { layout: "Two side-by-side columns with typed child components (heading, paragraph, image, CTA, video, list)", whenToUse: "Mixed content layouts — text beside image, features beside demo" },
24
24
  Table: { layout: "Data table with column headers and rows", whenToUse: "Pricing comparisons, feature matrices, structured data" },
@@ -290,7 +290,8 @@ Write the final summary using the format from "Output Formatting" above. This is
290
290
  - **Respect scope**: If the user specifies "homepage only" or specific pages, create ONLY those pages. Do NOT create additional pages.${intent === "migrate" ? `
291
291
  - **VERIFY custom blocks before bootstrap_pages**: After block-coder finishes, run \`pnpm --filter @ai-site-editor/{siteId} build\` to catch import resolution failures. Also check that \`apps/{siteId}/blocks/register.ts\` contains for EACH custom block: (1) \`import "./{kebab}/schema.ts"\`, (2) \`import { BlockName } from "./{kebab}/renderer.tsx"\` (WITH .tsx extension!), (3) \`registerCustomRenderer("BlockName", BlockName)\`. If the build fails or any import is missing, tell block-coder to fix it before proceeding.
292
292
  - **CRITICAL: Preserve original text exactly.** Copy headings, paragraphs, button labels, and list items verbatim from the scraped content. Do NOT paraphrase, translate, summarize, or rewrite any text. The migrated site must contain the exact same copy as the original. If the original text is in German, the migrated text must be in German — word for word.
293
- - **Plain text only in block props** — \`description\`, \`subtitle\`, \`body\`, and similar text fields are rendered as plain text, not markdown. Never use markdown syntax (\`**bold**\`, \`_italic_\`, \`# heading\`, \`- list\`) in these fields. Write the text exactly as it appears on the source page without any formatting markers.
293
+ - **Plain text in every prop except the rich-text ones.** Most props — \`description\`, \`subtitle\`, headings, labels render as plain text, so markdown syntax there shows up literally on the page. Write those exactly as they appear on the source page, with no formatting markers.
294
+ The exceptions are the props each block contract lists under \`richTextProps\` (RichText \`body\`, FAQAccordion \`items[].a\`, Quote \`quote\`, Tabs \`tabs[].content\`). Those ARE rendered as markdown, so when the source page has bold, italic, links, lists, headings or blockquotes inside a passage of prose, carry that formatting over rather than flattening it — flattening loses structure the original had. Use \`##\`–\`######\` for headings (never a single \`#\`), \`- \` / \`1. \` for lists, \`> \` for quotes, and a blank line between paragraphs.
294
295
  - **Navigation**: Extract nav item labels from the original site's \`<nav>\` links — use the EXACT original text (e.g. "Über uns" not "About"). Pass as \`navLabels\` in \`bootstrap_pages\`. If the original nav has dropdowns (parent → children), pass as \`navGroups\`.
295
296
  - **Site logo**: Download the original site's logo image and pass the local path as \`siteLogo\` in \`bootstrap_pages\`.
296
297
  - **Custom blocks — use them!** Spawn the **block-coder** subagent when a section needs a layout that standard blocks can't represent well. **NEVER use RichText as a fallback for structured data** — if the source has pricing tiers, event cards, team members, timelines, or any structured/tabular content, you MUST create a custom block. RichText is only for freeform prose.
@@ -463,6 +463,7 @@ const nextConfig: NextConfig = {
463
463
  "@avocadostudio-ai/site-sdk",
464
464
  "@avocadostudio-ai/blocks",
465
465
  "@avocadostudio-ai/shared",
466
+ "@avocadostudio-ai/richtext",
466
467
  ],
467
468
  images: {
468
469
  remotePatterns: [
@@ -1,5 +1,5 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
- import { allowedBlockTypes, editPlanSchema } from "@avocadostudio-ai/shared";
2
+ import { catalogueBlockTypes, editPlanSchema } from "@avocadostudio-ai/shared";
3
3
  import { buildIntentParserSystemPrompt, buildPlannerSystemPromptSegments } from "./prompts.js";
4
4
  import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER } from "./chat-pipeline.js";
5
5
  import { extractAudienceTarget, fetchImageAsBase64, resolveAttachmentsForLlm, intentSchema } from "../nlp/deterministic-planner.js";
@@ -78,7 +78,7 @@ export async function parseIntentWithAnthropic(args) {
78
78
  activeBlockId: args.activeBlockId ?? null,
79
79
  activeBlockType: args.activeBlockType ?? null,
80
80
  activeEditablePath: args.activeEditablePath ?? null,
81
- availableBlockTypes: allowedBlockTypes,
81
+ availableBlockTypes: catalogueBlockTypes(),
82
82
  blocks: args.currentPage.blocks.map((b) => ({ id: b.id, type: b.type, props: Object.keys(b.props) }))
83
83
  };
84
84
  const response = await client.messages.create({
@@ -170,7 +170,7 @@ export async function generatePlanWithAnthropic(args) {
170
170
  const effectiveSignal = args.signal
171
171
  ? AbortSignal.any([args.signal, AbortSignal.timeout(LLM_TIMEOUT_MS)])
172
172
  : AbortSignal.timeout(LLM_TIMEOUT_MS);
173
- const effectiveBlockTypes = args.componentsManifest ? args.componentsManifest.blocks.map(c => c.type) : allowedBlockTypes;
173
+ const effectiveBlockTypes = args.componentsManifest ? args.componentsManifest.blocks.map(c => c.type) : catalogueBlockTypes();
174
174
  const batchOverride = isBatchAddRequest(args.message) || isBatchRemoveRequest(args.message) || isBatchReorderRequest(args.message) || isPageWideRewriteRequest(args.message) || isDuplicateAndModifyRequest(args.message);
175
175
  const pageWideRewrite = isPageWideRewriteRequest(args.message);
176
176
  const planFirstMode = requestsPlanFirst(args.message);
@@ -9,7 +9,23 @@ import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
9
9
  import { clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
10
10
  import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
11
11
  import { isDemoModeEnabled, splitDemoOps, getDemoAllowedBlockTypes } from "../demo-mode.js";
12
- import { isCancelError as _isCancelError } from "../errors.js";
12
+ import { isCancelError as _isCancelError, OperationError } from "../errors.js";
13
+ import { acquireSessionLock } from "../state/session-lock.js";
14
+ const ERROR_CATEGORIES = [
15
+ "schema_violation",
16
+ "ambiguity",
17
+ "not_found",
18
+ "no_effective_change",
19
+ "planner_refusal",
20
+ "incomplete_output",
21
+ "malformed_output",
22
+ "internal_error",
23
+ "canceled",
24
+ "operation_failed"
25
+ ];
26
+ function isErrorCategory(value) {
27
+ return value !== undefined && ERROR_CATEGORIES.includes(value);
28
+ }
13
29
  // `isCancelError` is used directly (without underscore) in runChatPipeline
14
30
  const isCancelError = _isCancelError;
15
31
  import { isMultiStepCandidate, decomposeRequest } from "./decomposer.js";
@@ -1768,6 +1784,29 @@ export async function runChatPipeline(ctx, body, options) {
1768
1784
  if (resolvedPlan.ops.length === 0) {
1769
1785
  pendingClarificationBySession.delete(body.session);
1770
1786
  pendingApprovalPlanBySession.delete(body.session);
1787
+ /*
1788
+ * Zero ops is not the same fact as "already correct".
1789
+ *
1790
+ * The branch above this one earns that phrasing: it has a dry-run and can
1791
+ * see that every op was skipped as `unchanged_value`. Here there is no
1792
+ * plan to inspect — the planner returned none. Saying the content is up
1793
+ * to date asserts a reason nothing established, and it reads as success,
1794
+ * so a request that was silently dropped looks like a request that was
1795
+ * honoured. "rewrite the copy to be punchier" answered with "already up
1796
+ * to date" is the shape of the complaint.
1797
+ *
1798
+ * An `edit_plan` intent carrying no operations is the planner failing to
1799
+ * express an edit it meant to make, so say that, and prefer its own
1800
+ * summary when it wrote one — it is usually closer to what happened than
1801
+ * anything canned.
1802
+ */
1803
+ const plannerIntendedAnEdit = resolvedPlan.intent === "edit_plan";
1804
+ const plannerSummary = resolvedPlan.summary_for_user?.trim();
1805
+ const emptyPlanSummary = plannerIntendedAnEdit
1806
+ ? (plannerSummary && plannerSummary.length > 0
1807
+ ? plannerSummary
1808
+ : "I didn't manage to turn that into an edit. Try naming the block or the field you want changed.")
1809
+ : "No changes needed. That content is already up to date.";
1771
1810
  ctx.chatTelemetry.push({
1772
1811
  id: chatRequestId,
1773
1812
  at: new Date().toISOString(),
@@ -1781,7 +1820,11 @@ export async function runChatPipeline(ctx, body, options) {
1781
1820
  promptHash,
1782
1821
  promptExcerpt,
1783
1822
  promptLength: plannerMessage.length,
1784
- outcome: "no_effective_change",
1823
+ // Distinct from `no_effective_change`: nothing was even proposed. The
1824
+ // two were indistinguishable in telemetry, which is why a planner that
1825
+ // silently produced nothing looked like a planner correctly doing
1826
+ // nothing.
1827
+ outcome: plannerIntendedAnEdit ? "empty_edit_plan" : "no_effective_change",
1785
1828
  intent: resolvedPlan.intent,
1786
1829
  opCount: 0,
1787
1830
  opTypes: [],
@@ -1795,14 +1838,17 @@ export async function runChatPipeline(ctx, body, options) {
1795
1838
  code: 200,
1796
1839
  payload: withDebugPayload({
1797
1840
  status: "applied",
1798
- summary: "No changes needed. That content is already up to date.",
1841
+ summary: emptyPlanSummary,
1799
1842
  changes: [],
1800
1843
  mentionedSlugs: [effectiveSlug],
1801
1844
  previewVersion: versions.get(body.session) ?? 0,
1802
1845
  plannerSource: source,
1803
1846
  modelUsed,
1804
1847
  modelKey
1805
- }, { outcome: "no_effective_change", plannerTier })
1848
+ }, {
1849
+ outcome: plannerIntendedAnEdit ? "empty_edit_plan" : "no_effective_change",
1850
+ plannerTier
1851
+ })
1806
1852
  }
1807
1853
  };
1808
1854
  }
@@ -1876,19 +1922,64 @@ export async function runChatPipeline(ctx, body, options) {
1876
1922
  applyStartedAtMs = Date.now();
1877
1923
  const hasPageStructuralOps = resolvedPlan.ops.some((op) => op.op === "create_page" || op.op === "rename_page" || op.op === "remove_page" || op.op === "move_page" || op.op === "duplicate_page");
1878
1924
  if (incrementalApplyEnabled && options?.onOpApplied && !hasPageStructuralOps) {
1879
- // Snapshot the entire session draft so we can restore it wholesale after preflight.
1880
- const sessionDraft = getSessionDraft(body.session);
1881
- const preFlightSnapshot = new Map();
1882
- for (const [slug, page] of sessionDraft) {
1883
- preFlightSnapshot.set(slug, structuredClone(page));
1884
- }
1885
- // Validate the whole plan from the current state before progressive apply.
1886
- const preflight = await applyOpsAtomically(body.session, resolvedPlan.ops, { componentsManifest });
1925
+ /*
1926
+ * Validate the whole plan before touching anything, then apply it once.
1927
+ *
1928
+ * This used to *apply* the plan for real as its preflight, clear the
1929
+ * live session draft by reference, restore it from a snapshot, and then
1930
+ * replay every operation individually with a pause between steps. Three
1931
+ * things followed, and all three ran on the default production path
1932
+ * because both flags default on:
1933
+ *
1934
+ * - every operation's side effects happened twice;
1935
+ * - the clear-and-restore sat between two locked calls rather than
1936
+ * inside one, so there was a window in which the session held zero
1937
+ * pages and a concurrent draft fetch rendered an empty site;
1938
+ * - a failure mid-replay rolled back the *whole session* from that
1939
+ * snapshot, destroying any write that had landed meanwhile.
1940
+ *
1941
+ * `dryRun` does what the preflight was reaching for: it runs every op
1942
+ * against a clone under the session lock, reports per-op outcomes, and
1943
+ * never commits. So the plan is still validated as a whole before the
1944
+ * first visible change, and the ops are applied exactly once.
1945
+ */
1946
+ const touchedSlugs = new Set(resolvedPlan.ops
1947
+ .map((op) => ("pageSlug" in op ? op.pageSlug : undefined))
1948
+ .filter((slug) => typeof slug === "string" && slug.length > 0));
1949
+ const preflight = await applyOpsAtomically(body.session, resolvedPlan.ops, {
1950
+ componentsManifest,
1951
+ dryRun: true
1952
+ });
1887
1953
  skippedOps = preflight.skippedOps;
1888
- // Restore pre-apply state wholesale so we can replay ops progressively.
1889
- sessionDraft.clear();
1890
- for (const [slug, page] of preFlightSnapshot) {
1891
- setPage(body.session, structuredClone(page));
1954
+ // A dry run reports a hard failure instead of throwing, so that the
1955
+ // publish preview can say "3 of 4 will apply". Here the whole point is
1956
+ // to refuse before anything is visible, so the first failure is raised.
1957
+ const failedOp = preflight.opResults.find((result) => result.status === "failed");
1958
+ if (failedOp) {
1959
+ throw new OperationError(failedOp.reason ?? `Operation ${failedOp.op} cannot be applied`, {
1960
+ // `OpResult.category` is a plain string; keep only the values the
1961
+ // error type actually knows, rather than widening that type to
1962
+ // accept whatever an op happened to report.
1963
+ category: isErrorCategory(failedOp.category) ? failedOp.category : "operation_failed"
1964
+ });
1965
+ }
1966
+ // Likewise the no-effective-change check, which dryRun skips.
1967
+ if (preflight.appliedCount === 0) {
1968
+ throw new OperationError(skippedOps.length > 0
1969
+ ? "No effective prop change across plan. All update patches matched existing values."
1970
+ : "Edit plan produced no changes", { category: "no_effective_change" });
1971
+ }
1972
+ /*
1973
+ * Snapshot only the pages this plan can touch. Structural page ops are
1974
+ * excluded from this branch, so no slug appears or disappears — which
1975
+ * means a rollback can put those pages back without clearing the map,
1976
+ * and a concurrent edit to an untouched page survives it.
1977
+ */
1978
+ const preFlightSnapshot = new Map();
1979
+ for (const slug of touchedSlugs) {
1980
+ const page = getSessionDraft(body.session).get(slug);
1981
+ if (page)
1982
+ preFlightSnapshot.set(slug, structuredClone(page));
1892
1983
  }
1893
1984
  const total = resolvedPlan.ops.length;
1894
1985
  try {
@@ -1953,10 +2044,21 @@ export async function runChatPipeline(ctx, body, options) {
1953
2044
  if (isCancelError(progressiveError))
1954
2045
  throw progressiveError;
1955
2046
  options?.onRollbackStarted?.({ appliedCount: total, reason: toErrorDetail(progressiveError) });
1956
- // Roll back to pre-plan state so we don't leave a partially-applied plan.
1957
- sessionDraft.clear();
1958
- for (const [slug, page] of preFlightSnapshot) {
1959
- setPage(body.session, structuredClone(page));
2047
+ /*
2048
+ * Roll back the pages this plan touched, under the session lock, and
2049
+ * leave every other page alone. Clearing the whole draft and
2050
+ * rewriting it from a session-wide snapshot — which is what this did
2051
+ * — discarded concurrent writes to pages the plan never mentioned,
2052
+ * and briefly published an empty site while it ran.
2053
+ */
2054
+ const release = await acquireSessionLock(body.session);
2055
+ try {
2056
+ for (const [, page] of preFlightSnapshot) {
2057
+ setPage(body.session, structuredClone(page));
2058
+ }
2059
+ }
2060
+ finally {
2061
+ release();
1960
2062
  }
1961
2063
  options?.onRollbackDone?.({ restoredVersion: versions.get(body.session) ?? 0 });
1962
2064
  throw progressiveError;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { allowedBlockTypes, editPlanSchema } from "@avocadostudio-ai/shared";
2
+ import { catalogueBlockTypes, editPlanSchema } from "@avocadostudio-ai/shared";
3
3
  import { buildIntentParserSystemPrompt, buildPlannerSystemPrompt } from "./prompts.js";
4
4
  import { extractAudienceTarget, fetchImageAsBase64, resolveAttachmentsForLlm, intentSchema } from "../nlp/deterministic-planner.js";
5
5
  import { isBatchAddRequest, isBatchRemoveRequest, isBatchReorderRequest, isPageWideRewriteRequest, requestsPlanFirst } from "../nlp/intent-detection.js";
@@ -94,7 +94,7 @@ export async function parseIntentWithGemini(args) {
94
94
  activeBlockId: args.activeBlockId ?? null,
95
95
  activeBlockType: args.activeBlockType ?? null,
96
96
  activeEditablePath: args.activeEditablePath ?? null,
97
- availableBlockTypes: allowedBlockTypes,
97
+ availableBlockTypes: catalogueBlockTypes(),
98
98
  blocks: args.currentPage.blocks.map((b) => ({ id: b.id, type: b.type, props: Object.keys(b.props) }))
99
99
  };
100
100
  const response = await raceWithAbort(ai.models.generateContent({
@@ -134,7 +134,7 @@ export async function generatePlanWithGemini(args) {
134
134
  const effectiveSignal = args.signal
135
135
  ? AbortSignal.any([args.signal, AbortSignal.timeout(LLM_TIMEOUT_MS)])
136
136
  : AbortSignal.timeout(LLM_TIMEOUT_MS);
137
- const effectiveBlockTypes = args.componentsManifest ? args.componentsManifest.blocks.map(c => c.type) : allowedBlockTypes;
137
+ const effectiveBlockTypes = args.componentsManifest ? args.componentsManifest.blocks.map(c => c.type) : catalogueBlockTypes();
138
138
  const batchOverride = isBatchAddRequest(args.message) || isBatchRemoveRequest(args.message) || isBatchReorderRequest(args.message) || isPageWideRewriteRequest(args.message);
139
139
  const pageWideRewrite = isPageWideRewriteRequest(args.message);
140
140
  const planFirstMode = requestsPlanFirst(args.message);
@@ -1,6 +1,6 @@
1
1
  import OpenAI from "openai";
2
2
  import { z } from "zod";
3
- import { allowedBlockTypes, blockSchemas, editPlanSchema } from "@avocadostudio-ai/shared";
3
+ import { catalogueBlockTypes, blockSchemas, editPlanSchema } from "@avocadostudio-ai/shared";
4
4
  import { buildIntentParserSystemPrompt, buildPlannerSystemPrompt } from "./prompts.js";
5
5
  // ---------------------------------------------------------------------------
6
6
  // Singleton OpenAI client — reuses connection pool across requests.
@@ -117,7 +117,9 @@ function bytesForPayload(value) {
117
117
  }
118
118
  }
119
119
  function uniqueBlockTypes(values) {
120
- const known = new Set(allowedBlockTypes.map((type) => type.toLowerCase()));
120
+ // Read once: `catalogueBlockTypes()` filters on every call.
121
+ const catalogue = catalogueBlockTypes();
122
+ const known = new Set(catalogue.map((type) => type.toLowerCase()));
121
123
  const out = [];
122
124
  const seen = new Set();
123
125
  for (const value of values) {
@@ -127,7 +129,7 @@ function uniqueBlockTypes(values) {
127
129
  if (!known.has(normalized) || seen.has(normalized))
128
130
  continue;
129
131
  seen.add(normalized);
130
- const canonical = allowedBlockTypes.find((type) => type.toLowerCase() === normalized);
132
+ const canonical = catalogue.find((type) => type.toLowerCase() === normalized);
131
133
  if (canonical)
132
134
  out.push(canonical);
133
135
  }
@@ -1066,7 +1068,7 @@ export async function parseIntentWithOpenAI(args) {
1066
1068
  activeBlockId: args.activeBlockId ?? null,
1067
1069
  activeBlockType: args.activeBlockType ?? null,
1068
1070
  activeEditablePath: args.activeEditablePath ?? null,
1069
- availableBlockTypes: allowedBlockTypes,
1071
+ availableBlockTypes: catalogueBlockTypes(),
1070
1072
  blocks: args.currentPage.blocks.map((b) => ({ id: b.id, type: b.type, props: Object.keys(b.props) }))
1071
1073
  };
1072
1074
  const completion = await client.chat.completions.create({
@@ -1105,7 +1107,7 @@ export async function generatePlanWithOpenAI(args) {
1105
1107
  const effectiveSignal = args.signal
1106
1108
  ? AbortSignal.any([args.signal, AbortSignal.timeout(LLM_TIMEOUT_MS)])
1107
1109
  : AbortSignal.timeout(LLM_TIMEOUT_MS);
1108
- const effectiveBlockTypes = args.componentsManifest ? args.componentsManifest.blocks.map(c => c.type) : allowedBlockTypes;
1110
+ const effectiveBlockTypes = args.componentsManifest ? args.componentsManifest.blocks.map(c => c.type) : catalogueBlockTypes();
1109
1111
  const batchOverride = isBatchAddRequest(args.message) || isBatchRemoveRequest(args.message) || isBatchReorderRequest(args.message) || isPageWideRewriteRequest(args.message) || isDuplicateAndModifyRequest(args.message);
1110
1112
  const pageWideRewrite = isPageWideRewriteRequest(args.message);
1111
1113
  const planFirstMode = requestsPlanFirst(args.message);
@@ -88,6 +88,11 @@ function localeInstruction(locale) {
88
88
  // ---------------------------------------------------------------------------
89
89
  const RULE_ROLE = "You are an editing planner for a website builder.";
90
90
  const RULE_JSON_ONLY = "Return ONLY one JSON object matching EditPlan.";
91
+ const RULE_RICH_TEXT_PROPS = [
92
+ "A few props are rendered as markdown rather than plain text. Each block contract lists them under richTextProps, as `key` or `listKey[].key` (e.g. RichText `body`, FAQAccordion `items[].a`).",
93
+ "In those props you may use: **bold**, *italic*, ~~strikethrough~~, `inline code`, [label](url), headings from ## to ###### (never a single # — the page already has its top-level heading), bullet lists (- item) and numbered lists (1. item) including nested ones (indent the nested item by two spaces), blockquotes (> quoted), fenced code blocks and thematic breaks (---). Separate paragraphs with a blank line.",
94
+ "Everywhere else — every prop not listed in richTextProps, and summary text the user reads — markdown characters render literally, so write plain text. Never put markdown in a heading, label, button text, or a delimited value such as Footer `links`."
95
+ ].join(" ");
91
96
  const RULE_NO_MARKDOWN = "Never output markdown or code fences.";
92
97
  const RULE_NO_OP_PATCH = "Do not return no-op updates: patch must change at least one effective value.";
93
98
  const RULE_STRICT_SCHEMA_DISCIPLINE = "STRICT SCHEMA DISCIPLINE: Only promise changes to props that exist in the block's contract. When a request has some supported parts AND some unsupported parts (e.g. 'add icons and colors' on a block with icon but no color), APPLY the supported parts and mention in summary_for_user that the unsupported part isn't available — don't bail out. Only return needs_clarification when NOTHING in the request maps to the schema. Do NOT generate summary_for_user or change_log text that describes changes your ops don't actually make.";
@@ -236,7 +241,7 @@ function sectionVoice(opts, hasNativeTools) {
236
241
  if (hasNativeTools) {
237
242
  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.");
238
243
  }
239
- 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 user explicitly asks for markdown formatting. Do not wrap the entire rewrite in **bold** markers.", "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 &shy; or &amp;shy;.", 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.");
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 &shy; or &amp;shy;.", 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.");
240
245
  return lines;
241
246
  }
242
247
  function sectionOperationCatalog() {
@@ -25,6 +25,24 @@ export interface CmsPublishContext {
25
25
  * in block props, and emit the resulting public URL.
26
26
  */
27
27
  assets?: Record<string, CmsInlineAsset>;
28
+ /**
29
+ * The pages as the adapter last reported them, if the orchestrator has them.
30
+ *
31
+ * Without a baseline, `onPublish(pages)` can only be implemented as "store
32
+ * these documents", which is exactly what a real CMS cannot accept: every
33
+ * read is a projection — an asset reference flattened to a URL, a document
34
+ * reference resolved to one language's href — and writing the projection back
35
+ * destroys what it was projected from. A publisher has to diff, and it cannot
36
+ * diff against nothing.
37
+ *
38
+ * Absent when the session was never bootstrapped from the adapter, so treat
39
+ * `undefined` as "no baseline available" and not as "the site was empty" —
40
+ * publishing every field on that assumption is the overwrite this exists to
41
+ * prevent. An integration that embeds its own source snapshot in block props
42
+ * (the more precise approach, since only it knows what it projected) can
43
+ * ignore this.
44
+ */
45
+ published?: PageDoc[];
28
46
  }
29
47
  /**
30
48
  * Result of an `onPublish` call. `void` means "success, nothing to report";
@@ -33,22 +51,77 @@ export interface CmsPublishContext {
33
51
  */
34
52
  export type CmsPublishResult = void | {
35
53
  ok: true;
54
+ unsupported?: string[];
36
55
  } | {
37
56
  ok: false;
38
57
  error?: string;
58
+ unsupported?: string[];
39
59
  };
60
+ /**
61
+ * Which side of a CMS's draft/published split a read wants.
62
+ *
63
+ * Most content stores keep two versions of a document: the one visitors see,
64
+ * and the one an editor is working on. Sanity calls them perspectives,
65
+ * Contentful splits them across the Delivery and Preview APIs, Strapi calls
66
+ * them published and draft entries. The vocabulary differs; the split does not.
67
+ *
68
+ * `getPages()` had no way to say which one it meant, so an adapter had to pick
69
+ * one for every caller at once — and the two callers want opposite things.
70
+ */
71
+ export type CmsPerspective =
72
+ /**
73
+ * The editor's working copy: unpublished edits included, falling back to the
74
+ * published version of any document that has none. This is what seeds a
75
+ * session, because it is the content someone is actually working on.
76
+ */
77
+ "draft"
78
+ /**
79
+ * What is live for visitors right now. This is the publish diff's baseline:
80
+ * "what will change on the site" is a question about the live version and
81
+ * nothing else.
82
+ */
83
+ | "published";
84
+ /** Options for a read. An options bag rather than a positional flag, so a later read hint costs no signature change. */
85
+ export interface CmsReadOptions {
86
+ perspective: CmsPerspective;
87
+ }
40
88
  export interface CmsAdapter {
41
89
  /**
42
90
  * Stable identifier for telemetry and per-adapter bootstrap caches.
43
91
  * Keep it short and DNS-safe: `json-file`, `editor-api`, `sanity`, etc.
44
92
  */
45
93
  readonly id: string;
94
+ /**
95
+ * Whether `getPages` honours `options.perspective`.
96
+ *
97
+ * Silence means no, which is the opposite default from `capabilities` below
98
+ * and deliberately so: those are permissions, where the safe answer is to
99
+ * allow what nobody has forbidden, and this is an ability, where the safe
100
+ * answer is not to claim one nobody has implemented. An adapter wrongly
101
+ * believed to read drafts makes the publish diff assert that unpublished
102
+ * work is live.
103
+ *
104
+ * Set it `true` only when both perspectives are genuinely reachable — often
105
+ * a runtime condition rather than a constant, since reading drafts usually
106
+ * needs a token the deployment may not have: `perspectives: Boolean(token)`
107
+ * is the honest form.
108
+ */
109
+ readonly perspectives?: boolean;
46
110
  /**
47
111
  * Return every page the orchestrator should know about for this site.
48
112
  * Called lazily on the first chat for a fresh session. Failures are
49
113
  * logged but non-fatal — the session simply starts empty.
114
+ *
115
+ * `options` is optional and every existing implementation may ignore it: an
116
+ * adapter that reads one perspective is not wrong, it is just blind to the
117
+ * other, and that is exactly what `perspectives` reports. Ignoring the
118
+ * argument reproduces the pre-perspective behaviour byte for byte.
119
+ *
120
+ * Callers always pass it. That is the guarded half of the contract — a
121
+ * caller that omits it silently re-collapses the two questions into one, and
122
+ * `cms/read-perspective.test.ts` fails the build if one does.
50
123
  */
51
- getPages(): Promise<PageDoc[]>;
124
+ getPages(options?: CmsReadOptions): Promise<PageDoc[]>;
52
125
  /**
53
126
  * Optional publish hook. If present, called when the editor publishes
54
127
  * a session's draft. If absent, publish remains a no-op against the
@@ -59,4 +132,89 @@ export interface CmsAdapter {
59
132
  * collected during the session.
60
133
  */
61
134
  onPublish?(pages: PageDoc[], config: SiteConfig, context?: CmsPublishContext): Promise<CmsPublishResult>;
135
+ /**
136
+ * What this site can actually honour, for the operations where "applied
137
+ * successfully" and "publishable" are different questions.
138
+ *
139
+ * A CMS-backed site typically cannot mint pages: Avocado assigns a new page
140
+ * an id and a slug, the CMS has no document behind either, and the op
141
+ * applies cleanly, previews correctly and reports success — then fails the
142
+ * whole publish transaction. An agent told "this site cannot create pages"
143
+ * is strictly more useful than one that can create a broken one.
144
+ *
145
+ * Static, deliberately: `/whoami` must be able to answer this with the CMS
146
+ * unreachable, so it cannot be a method that reads upstream.
147
+ */
148
+ readonly capabilities?: CmsCapabilities;
62
149
  }
150
+ /**
151
+ * Per-operation capability declaration. Every field is tri-state, and the
152
+ * third state is the important one: `undefined` means *the adapter did not
153
+ * say*, which is not the same as `false`.
154
+ *
155
+ * Unknown resolves to permitted. That is not optimism, it is the only
156
+ * non-breaking default: no adapter shipped so far declares anything, the
157
+ * standalone orchestrator has no adapter at all, and both bundled adapters
158
+ * genuinely can create pages. A restrictive default would silently withdraw
159
+ * working operations from every existing install on upgrade, with no way to
160
+ * opt back in short of editing the adapter. So a capability is enforced only
161
+ * where an adapter has explicitly said `false`.
162
+ */
163
+ export interface CmsCapabilities {
164
+ /**
165
+ * Whether a new page can exist upstream. Gates `create_page` and
166
+ * `duplicate_page` — the latter mints an identity just as surely as the
167
+ * former.
168
+ */
169
+ createPages?: boolean;
170
+ /** Whether a page can be removed upstream. A patch-only publisher emits nothing for a deletion, so the page survives it. */
171
+ deletePages?: boolean;
172
+ /**
173
+ * Whether the *set and order* of blocks on a page can change.
174
+ *
175
+ * Separate from editing a block's props, because for many integrations they
176
+ * are genuinely different: a site whose three language variants share one
177
+ * block structure can accept every text edit and no insertion.
178
+ *
179
+ * Deliberately narrower than the engine's own `isStructuralOperation`, which
180
+ * also counts list-item ops. Adding a row to a list is an ordinary array
181
+ * write inside one block's props, and any adapter that projects the CMS's
182
+ * own keys can publish it.
183
+ */
184
+ structuralEdits?: boolean;
185
+ }
186
+ /** Capabilities with the unknowns resolved, plus what we can derive ourselves. */
187
+ export interface ResolvedCapabilities {
188
+ createPages: boolean;
189
+ deletePages: boolean;
190
+ structuralEdits: boolean;
191
+ /** True when the adapter has an `onPublish`; derived, never declared. */
192
+ publishesUpstream: boolean;
193
+ /**
194
+ * True when the adapter reads the CMS's draft perspective; derived from
195
+ * `adapter.perspectives`, never declared as a capability.
196
+ *
197
+ * False is the interesting value and the reason this is reported at all: it
198
+ * means a document edited in the CMS and not yet published is invisible to
199
+ * Avocado — the session was seeded from the live version, and the publish
200
+ * diff will call it unchanged. That is a true statement about a page whose
201
+ * author can see their unsaved-to-live edits in their own CMS, which is the
202
+ * most confusing shape a correct answer can take. An agent reading this
203
+ * knows not to conclude "nothing is pending" from an empty diff.
204
+ */
205
+ readsDraftPerspective: boolean;
206
+ /**
207
+ * The subset the adapter actually declared. A caller that needs to
208
+ * distinguish "this site says no" from "nobody said" reads this; everything
209
+ * else reads the resolved booleans above.
210
+ */
211
+ declared: CmsCapabilities;
212
+ }
213
+ /**
214
+ * Resolve an adapter's declaration, treating silence as permission.
215
+ *
216
+ * `null` — the standalone multi-site orchestrator, which wires no adapter at
217
+ * all — resolves fully capable, because it is: its pages live in Avocado's own
218
+ * store and nothing upstream can refuse them.
219
+ */
220
+ export declare function resolveCapabilities(adapter: CmsAdapter | null | undefined, override?: CmsCapabilities): ResolvedCapabilities;
@@ -1 +1,19 @@
1
- export {};
1
+ /**
2
+ * Resolve an adapter's declaration, treating silence as permission.
3
+ *
4
+ * `null` — the standalone multi-site orchestrator, which wires no adapter at
5
+ * all — resolves fully capable, because it is: its pages live in Avocado's own
6
+ * store and nothing upstream can refuse them.
7
+ */
8
+ export function resolveCapabilities(adapter, override) {
9
+ const declared = { ...adapter?.capabilities, ...override };
10
+ return {
11
+ createPages: declared.createPages !== false,
12
+ deletePages: declared.deletePages !== false,
13
+ structuralEdits: declared.structuralEdits !== false,
14
+ publishesUpstream: typeof adapter?.onPublish === "function",
15
+ // `=== true`, not truthiness: an adapter that says nothing has not said yes.
16
+ readsDraftPerspective: adapter?.perspectives === true,
17
+ declared
18
+ };
19
+ }
@@ -1,11 +1,54 @@
1
- import type { CmsAdapter } from "./adapter.ts";
1
+ import type { PageDoc } from "@avocadostudio-ai/shared";
2
+ import type { CmsAdapter, CmsCapabilities } from "./adapter.ts";
2
3
  import type { Logger } from "../logger.ts";
3
4
  export interface CmsBootstrapCacheOptions {
4
5
  /** Soft cap on remembered "already-attempted" sessions. Default 5000. */
5
6
  maxAttempted?: number;
7
+ /**
8
+ * How long a warmed page list stays usable, in milliseconds.
9
+ * Default 60000 — see `DEFAULT_WARM_TTL_MS`.
10
+ */
11
+ warmTtlMs?: number;
12
+ /**
13
+ * Capability declaration from the host, merged over the adapter's own.
14
+ * Lets a site using a stock adapter (`jsonFileAdapter`, `editorApiAdapter`)
15
+ * declare what it can honour without writing an adapter class of its own.
16
+ */
17
+ capabilities?: CmsCapabilities;
18
+ /**
19
+ * How many sessions' publish baselines to retain. Default 50 — a library-mode
20
+ * process serves one site and a handful of sessions; the cap exists so a
21
+ * long-lived multi-session host cannot accumulate page sets forever.
22
+ */
23
+ maxBaselines?: number;
6
24
  }
7
25
  export interface CmsBootstrapCache {
8
26
  ensure(session: string, adapter: CmsAdapter | null | undefined, log: Logger): Promise<void>;
27
+ /**
28
+ * Start the adapter's page fetch ahead of the first request. Fire-and-forget:
29
+ * returns immediately, never throws, and never leaves an unhandled rejection.
30
+ * A failed warm is logged at warn and forgotten, so `ensure()` retries the
31
+ * fetch itself rather than inheriting the failure. Calling it twice for the
32
+ * same adapter inside the TTL does not issue a second fetch.
33
+ */
34
+ warm(adapter: CmsAdapter | null | undefined, log: Logger): void;
35
+ /**
36
+ * What the adapter reported for this session when it was bootstrapped, or
37
+ * null if this process never seeded it.
38
+ *
39
+ * A publisher has to diff — `onPublish(pages)` as "store these documents" is
40
+ * only implementable when the CMS shape is the editor shape — and it cannot
41
+ * diff against nothing. This is the cheap baseline: the pages are already
42
+ * being cloned into the draft, so keeping the pre-edit copy costs one more
43
+ * clone and no extra adapter read. Re-reading upstream at publish time would
44
+ * cost 45 sequential CMS calls on the integration that motivated this.
45
+ *
46
+ * Bounded and best-effort by construction. It is null after a restart that
47
+ * reloaded the draft from SQLite (the seed never ran), and it is evicted
48
+ * FIFO past `maxBaselines`, so a caller must treat null as "no baseline
49
+ * available" and never as "the site was empty".
50
+ */
51
+ baselineFor(session: string): PageDoc[] | null;
9
52
  /** Test helper — drop all remembered state. */
10
53
  reset(): void;
11
54
  /** Inspection — number of remembered attempts (post-eviction). */
@@ -13,5 +56,7 @@ export interface CmsBootstrapCache {
13
56
  }
14
57
  export declare function createCmsBootstrapCache(opts?: CmsBootstrapCacheOptions): CmsBootstrapCache;
15
58
  export declare function ensureSessionBootstrapped(session: string, adapter: CmsAdapter | null | undefined, log: Logger): Promise<void>;
59
+ /** Warm the process-default cache. Runtime-scoped caches own their own `warm()`. */
60
+ export declare function warmSessionBootstrap(adapter: CmsAdapter | null | undefined, log: Logger): void;
16
61
  /** Test helper — clears the default process cache. Runtime-scoped caches own their own `reset()`. */
17
62
  export declare function _resetCmsBootstrapCache(): void;