@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.
- package/dist/agent/sites-agent-context.js +3 -2
- package/dist/agent/sites-agent-shared.js +1 -0
- package/dist/chat/anthropic-planner.js +3 -3
- package/dist/chat/chat-pipeline.js +122 -20
- package/dist/chat/gemini-planner.js +3 -3
- package/dist/chat/planner.js +7 -5
- package/dist/chat/prompts.js +6 -1
- package/dist/cms/adapter.d.ts +159 -1
- package/dist/cms/adapter.js +19 -1
- package/dist/cms/bootstrap.d.ts +46 -1
- package/dist/cms/bootstrap.js +126 -2
- package/dist/cms/index.d.ts +3 -2
- package/dist/cms/index.js +2 -1
- package/dist/errors.d.ts +9 -1
- package/dist/handler/auth.d.ts +79 -0
- package/dist/handler/auth.js +113 -0
- package/dist/handler/create-orchestrator.d.ts +205 -0
- package/dist/handler/create-orchestrator.js +1599 -0
- package/dist/http/access-tokens.d.ts +58 -0
- package/dist/http/access-tokens.js +161 -0
- package/dist/http/audio-actions.d.ts +121 -0
- package/dist/http/audio-actions.js +248 -0
- package/dist/http/blocks-actions.d.ts +31 -0
- package/dist/http/blocks-actions.js +31 -0
- package/dist/http/draft-provenance.d.ts +68 -0
- package/dist/http/draft-provenance.js +101 -0
- package/dist/http/history-actions.d.ts +58 -0
- package/dist/http/history-actions.js +169 -0
- package/dist/http/image-generate-actions.d.ts +268 -0
- package/dist/http/image-generate-actions.js +546 -0
- package/dist/http/ops-actions.d.ts +51 -0
- package/dist/http/ops-actions.js +79 -0
- package/dist/http/publish-actions.d.ts +153 -0
- package/dist/http/publish-actions.js +323 -0
- package/dist/http/restore-actions.d.ts +67 -0
- package/dist/http/restore-actions.js +145 -0
- package/dist/http/screenshot-actions.d.ts +108 -0
- package/dist/http/screenshot-actions.js +181 -0
- package/dist/http/session-actions.d.ts +35 -0
- package/dist/http/session-actions.js +98 -0
- package/dist/http/telemetry-feedback-actions.d.ts +53 -0
- package/dist/http/telemetry-feedback-actions.js +68 -0
- package/dist/http/unsplash-actions.d.ts +64 -0
- package/dist/http/unsplash-actions.js +81 -0
- package/dist/http/variations-actions.d.ts +102 -0
- package/dist/http/variations-actions.js +104 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +21 -1
- package/dist/nlp/deterministic-planner-refs.d.ts +1 -1
- package/dist/nlp/deterministic-planner-suggestions.d.ts +10 -0
- package/dist/nlp/deterministic-planner-suggestions.js +37 -11
- package/dist/nlp/plan-normalizer.js +18 -2
- package/dist/ops/ops-engine.js +219 -14
- package/dist/state/session-state.d.ts +56 -1
- package/dist/state/session-state.js +92 -6
- package/dist/state/sqlite-store-singleton.d.ts +22 -0
- package/dist/state/sqlite-store-singleton.js +49 -1
- package/dist/state/sqlite-store.d.ts +5 -0
- package/dist/state/sqlite-store.js +125 -2
- package/dist/telemetry/chat-telemetry.js +6 -1
- 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
|
|
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
|
|
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.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
-
import {
|
|
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:
|
|
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) :
|
|
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
|
-
|
|
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:
|
|
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
|
-
}, {
|
|
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
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
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
|
-
//
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
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
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
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 {
|
|
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:
|
|
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) :
|
|
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);
|
package/dist/chat/planner.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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) :
|
|
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);
|
package/dist/chat/prompts.js
CHANGED
|
@@ -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 ­ or &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 ­ or &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() {
|
package/dist/cms/adapter.d.ts
CHANGED
|
@@ -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;
|
package/dist/cms/adapter.js
CHANGED
|
@@ -1 +1,19 @@
|
|
|
1
|
-
|
|
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
|
+
}
|
package/dist/cms/bootstrap.d.ts
CHANGED
|
@@ -1,11 +1,54 @@
|
|
|
1
|
-
import type {
|
|
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;
|