@avocadostudio-ai/orchestrator-core 0.8.0 → 0.9.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/chat/chat-pipeline-context.d.ts +2 -0
- package/dist/chat/chat-pipeline.js +14 -8
- package/dist/handler/create-orchestrator.d.ts +22 -1
- package/dist/handler/create-orchestrator.js +11 -0
- package/dist/http/screenshot-actions.d.ts +1 -1
- package/dist/http/screenshot-actions.js +1 -1
- package/dist/nlp/deterministic-planner-context.d.ts +1 -0
- package/dist/nlp/deterministic-planner-context.js +8 -0
- package/dist/nlp/deterministic-planner-suggestions.d.ts +19 -0
- package/dist/nlp/deterministic-planner-suggestions.js +24 -0
- package/dist/nlp/deterministic-planner.d.ts +1 -1
- package/dist/nlp/deterministic-planner.js +6 -3
- package/dist/nlp/intent-detection.d.ts +6 -10
- package/dist/nlp/intent-detection.js +157 -147
- package/dist/nlp/intent-patterns.js +12 -1
- package/package.json +4 -4
|
@@ -55,6 +55,7 @@ export declare function compactPlannerContextPack(args: {
|
|
|
55
55
|
}>;
|
|
56
56
|
}[];
|
|
57
57
|
pageMeta: import("@avocadostudio-ai/shared").PageMeta | null;
|
|
58
|
+
pageObservations: import("../nlp/intent-detection.ts").PageObservation[];
|
|
58
59
|
pageIntent: string;
|
|
59
60
|
recentSuccessfulEdits: {
|
|
60
61
|
at: string;
|
|
@@ -132,6 +133,7 @@ export declare function minimalPlannerContextPack(args: {
|
|
|
132
133
|
}>;
|
|
133
134
|
}[];
|
|
134
135
|
pageMeta: import("@avocadostudio-ai/shared").PageMeta | null;
|
|
136
|
+
pageObservations: import("../nlp/intent-detection.ts").PageObservation[];
|
|
135
137
|
pageIntent: string;
|
|
136
138
|
recentSuccessfulEdits: {
|
|
137
139
|
at: string;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { blockManifestSchema } from "@avocadostudio-ai/shared";
|
|
3
3
|
import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, isVariationRequestMessage, variationVerbIntent, resolveEffectiveSlug, throwIfCanceled, raceCancel, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
|
|
4
|
-
import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery,
|
|
4
|
+
import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
|
|
5
5
|
import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
|
|
6
6
|
import { versions, pendingClarificationBySession, chatHistoryBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
|
|
7
7
|
import { getSiteAssets } from "../state/site-assets.js";
|
|
8
8
|
import { loadPendingPlan, savePendingPlan, clearPendingPlan } from "../durable/pending-plan-store.js";
|
|
9
9
|
import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isRepairEligibleCategory, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
|
|
10
10
|
import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
|
|
11
|
-
import { clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
|
|
11
|
+
import { clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, withKeylessNotice, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
|
|
12
12
|
import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
|
|
13
13
|
import { isDemoModeEnabled, splitDemoOps, getDemoAllowedBlockTypes } from "../demo-mode.js";
|
|
14
14
|
import { isCancelError as _isCancelError, OperationError, OrchestrationError } from "../errors.js";
|
|
@@ -919,11 +919,17 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
919
919
|
const info = infoResponse({ body, current, plannerSource, modelUsed, modelKey });
|
|
920
920
|
return { code: info.code, payload: withDebugPayload(info.payload, { outcome: "info" }) };
|
|
921
921
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
922
|
+
/*
|
|
923
|
+
* There is no deterministic branch for "review this page" any more.
|
|
924
|
+
*
|
|
925
|
+
* There was, and it answered in twelve milliseconds from a template — which
|
|
926
|
+
* is why a page review read like a linter. The planner prompt already has the
|
|
927
|
+
* rule (PAGE FEEDBACK -> content_answer, "specific, reasoned recommendations
|
|
928
|
+
* based on the page topic and content, not a generic checklist"); it was
|
|
929
|
+
* never reached, because two detectors claimed the same message and the
|
|
930
|
+
* canned one was tested first. What survives is `pageObservations`, which the
|
|
931
|
+
* context pack carries so the model reviews with the checked facts in hand.
|
|
932
|
+
*/
|
|
927
933
|
if (body.message && isPageListQuery(body.message)) {
|
|
928
934
|
const directory = buildPageDirectory(body.session);
|
|
929
935
|
const draft = getSessionDraft(body.session);
|
|
@@ -2961,7 +2967,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2961
2967
|
markPlanningStart();
|
|
2962
2968
|
try {
|
|
2963
2969
|
emitStatusTone("planning");
|
|
2964
|
-
const demoPlan = demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType);
|
|
2970
|
+
const demoPlan = withKeylessNotice(demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType));
|
|
2965
2971
|
markPlanningFinish();
|
|
2966
2972
|
const outcome = await respondFromPlan(demoPlan, "demo", applyMode, undefined, "demo");
|
|
2967
2973
|
if (outcome.done)
|
|
@@ -97,6 +97,27 @@ export interface CreateOrchestratorConfig {
|
|
|
97
97
|
* - no adapter: explicit `siteId` wins; otherwise `body.siteId` is used.
|
|
98
98
|
*/
|
|
99
99
|
siteId?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Human-readable name for this site, reported on `/status/planner`.
|
|
102
|
+
*
|
|
103
|
+
* The editor otherwise title-cases the site id, so a project scaffolded into
|
|
104
|
+
* `my-shop/` is greeted as "My Shop". That is a reasonable guess and a poor
|
|
105
|
+
* one for anything whose directory name is not its name.
|
|
106
|
+
*/
|
|
107
|
+
siteName?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Whether this mount is serving the shipped Avocado Hub demo content rather
|
|
110
|
+
* than a real site's, reported on `/status/planner`.
|
|
111
|
+
*
|
|
112
|
+
* The editor's curated first-run suggestions — the ones written against the
|
|
113
|
+
* demo's actual pages — used to be keyed on a hardcoded allowlist of site
|
|
114
|
+
* ids that only ever matched sites inside this monorepo. A scaffolded
|
|
115
|
+
* project's id is whatever the user called their directory, so the new front
|
|
116
|
+
* door was structurally excluded from the onboarding written for it. The
|
|
117
|
+
* question the editor was really asking is "is this the demo content?", so
|
|
118
|
+
* that is the question the orchestrator now answers.
|
|
119
|
+
*/
|
|
120
|
+
demoContent?: boolean;
|
|
100
121
|
/**
|
|
101
122
|
* Register the site's block schemas before the orchestrator's first chat
|
|
102
123
|
* request. Use this instead of relying on side-effect import order: in
|
|
@@ -150,7 +171,7 @@ export interface CreateOrchestratorConfig {
|
|
|
150
171
|
* The route this site renders orchestrator drafts on. Either a prefix the
|
|
151
172
|
* page slug is appended to (`/avocado`) or a template naming where the slug
|
|
152
173
|
* goes (`/preview/{slug}/draft`). Defaults to `/preview-draft`, which is what
|
|
153
|
-
* `create-
|
|
174
|
+
* `create-avocado-site` scaffolds.
|
|
154
175
|
*
|
|
155
176
|
* A site that wired Avocado into its own app almost certainly has its own
|
|
156
177
|
* route here — declare it, or every draft screenshot photographs a 404.
|
|
@@ -1054,6 +1054,17 @@ export function createOrchestrator(config = {}) {
|
|
|
1054
1054
|
return jsonResponse({
|
|
1055
1055
|
plannerSource: providers[0] ?? "demo",
|
|
1056
1056
|
availableProviders: providers,
|
|
1057
|
+
/*
|
|
1058
|
+
* Identity, so the editor's onboarding copy can be keyed on what
|
|
1059
|
+
* this mount actually is rather than on a list of ids it was
|
|
1060
|
+
* compiled with. `demoContent` is what `create-avocado-site` sets on
|
|
1061
|
+
* a scaffolded project.
|
|
1062
|
+
*/
|
|
1063
|
+
site: {
|
|
1064
|
+
...(config.siteId ? { id: config.siteId } : {}),
|
|
1065
|
+
...(config.siteName ? { name: config.siteName } : {}),
|
|
1066
|
+
demoContent: config.demoContent === true
|
|
1067
|
+
},
|
|
1057
1068
|
features: {
|
|
1058
1069
|
googleDrive: false,
|
|
1059
1070
|
unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* instead (useful for before/after comparisons).
|
|
18
18
|
*
|
|
19
19
|
* `/preview-draft` is only the *default*, though, and it was hardcoded — which
|
|
20
|
-
* meant this route worked on sites `create-
|
|
20
|
+
* meant this route worked on sites `create-avocado-site` scaffolded and on no
|
|
21
21
|
* others. An existing site that wires Avocado into its own app has its own
|
|
22
22
|
* draft route (one such site's is `/avocado/<lang>/<slug>`), and the
|
|
23
23
|
* screenshot an agent took to check its own work photographed that site's 404
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* instead (useful for before/after comparisons).
|
|
18
18
|
*
|
|
19
19
|
* `/preview-draft` is only the *default*, though, and it was hardcoded — which
|
|
20
|
-
* meant this route worked on sites `create-
|
|
20
|
+
* meant this route worked on sites `create-avocado-site` scaffolded and on no
|
|
21
21
|
* others. An existing site that wires Avocado into its own app has its own
|
|
22
22
|
* draft route (one such site's is `/avocado/<lang>/<slug>`), and the
|
|
23
23
|
* screenshot an agent took to check its own work photographed that site's 404
|
|
@@ -133,6 +133,7 @@ export declare function plannerContextPack(args: {
|
|
|
133
133
|
}>;
|
|
134
134
|
}[];
|
|
135
135
|
pageMeta: import("@avocadostudio-ai/shared").PageMeta | null;
|
|
136
|
+
pageObservations: import("./intent-detection.ts").PageObservation[];
|
|
136
137
|
pageIntent: string;
|
|
137
138
|
recentSuccessfulEdits: {
|
|
138
139
|
at: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getSessionDraft, getRecentEdits, getSiteConfig, orderSlugsHomeFirst } from "../state/session-state.js";
|
|
2
2
|
import { getLibraryMount } from "../handler/library-mount.js";
|
|
3
3
|
import { resolveReferencesFromMessage } from "./deterministic-planner-refs.js";
|
|
4
|
+
import { pageObservations } from "./intent-detection.js";
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// Path traversal
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -411,6 +412,13 @@ export function plannerContextPack(args) {
|
|
|
411
412
|
return { id: b.id, type: b.type, props: textHints, arrayProps: arrProps };
|
|
412
413
|
}),
|
|
413
414
|
pageMeta: currentPage.meta ?? null,
|
|
415
|
+
/*
|
|
416
|
+
* Facts about this page that reading props does not reveal: a declared
|
|
417
|
+
* image field with nothing in it, a page nothing links out of, a missing
|
|
418
|
+
* meta description. Cheap to compute and impossible to infer from the
|
|
419
|
+
* outline above, so a review is grounded in them rather than guessing.
|
|
420
|
+
*/
|
|
421
|
+
pageObservations: pageObservations(currentPage),
|
|
414
422
|
pageIntent: pageIntentSummary({ slug, currentPage }),
|
|
415
423
|
recentSuccessfulEdits: getRecentEdits(session, slug),
|
|
416
424
|
resolvedReferences: resolveReferencesFromMessage({ message, currentPage, activeBlockId }),
|
|
@@ -56,4 +56,23 @@ type BlockContract = {
|
|
|
56
56
|
* get a contract derived from their JSON schema.
|
|
57
57
|
*/
|
|
58
58
|
export declare function blockContractsSummary(manifest?: BlockManifest): Record<string, BlockContract>;
|
|
59
|
+
/**
|
|
60
|
+
* With no provider key in the environment `resolvePlannerSource` answers
|
|
61
|
+
* "demo", and every request is planned by the rule-based planner above. It
|
|
62
|
+
* handles a useful slice of literal edits — `change the hero headline to "X"` —
|
|
63
|
+
* and answers everything else with a clarifying question.
|
|
64
|
+
*
|
|
65
|
+
* That question was the defect. Someone who scaffolds the demo, types "Rewrite
|
|
66
|
+
* the hero headline" and is asked "what section should I change and what
|
|
67
|
+
* exactly should be updated?" reads it as a planner that cannot understand
|
|
68
|
+
* plain English — not as a planner that was never given a key. Shipping the
|
|
69
|
+
* demo keyless is deliberate, so that you can look around before spending
|
|
70
|
+
* anything; nothing in the product ever said so, which turned the most likely
|
|
71
|
+
* first action into a dead end.
|
|
72
|
+
*
|
|
73
|
+
* So when the rules produce no operations, say why. The suggestions are kept:
|
|
74
|
+
* they are the phrasings this planner *can* execute without a key.
|
|
75
|
+
*/
|
|
76
|
+
export declare const KEYLESS_PLANNER_NOTICE: string;
|
|
77
|
+
export declare function withKeylessNotice(plan: EditPlan): EditPlan;
|
|
59
78
|
export {};
|
|
@@ -603,3 +603,27 @@ export function blockContractsSummary(manifest) {
|
|
|
603
603
|
}
|
|
604
604
|
return result;
|
|
605
605
|
}
|
|
606
|
+
/**
|
|
607
|
+
* With no provider key in the environment `resolvePlannerSource` answers
|
|
608
|
+
* "demo", and every request is planned by the rule-based planner above. It
|
|
609
|
+
* handles a useful slice of literal edits — `change the hero headline to "X"` —
|
|
610
|
+
* and answers everything else with a clarifying question.
|
|
611
|
+
*
|
|
612
|
+
* That question was the defect. Someone who scaffolds the demo, types "Rewrite
|
|
613
|
+
* the hero headline" and is asked "what section should I change and what
|
|
614
|
+
* exactly should be updated?" reads it as a planner that cannot understand
|
|
615
|
+
* plain English — not as a planner that was never given a key. Shipping the
|
|
616
|
+
* demo keyless is deliberate, so that you can look around before spending
|
|
617
|
+
* anything; nothing in the product ever said so, which turned the most likely
|
|
618
|
+
* first action into a dead end.
|
|
619
|
+
*
|
|
620
|
+
* So when the rules produce no operations, say why. The suggestions are kept:
|
|
621
|
+
* they are the phrasings this planner *can* execute without a key.
|
|
622
|
+
*/
|
|
623
|
+
export const KEYLESS_PLANNER_NOTICE = "There's no AI key configured, so I'm running on the built-in demo planner — it only handles simple, literal edits. " +
|
|
624
|
+
"Add ANTHROPIC_API_KEY (or OPENAI_API_KEY) to .env.local and restart the dev server to chat for real.";
|
|
625
|
+
export function withKeylessNotice(plan) {
|
|
626
|
+
if (plan.ops.length > 0)
|
|
627
|
+
return plan;
|
|
628
|
+
return { ...plan, summary_for_user: KEYLESS_PLANNER_NOTICE };
|
|
629
|
+
}
|
|
@@ -78,7 +78,7 @@ export declare function compileDeterministicPlan(args: {
|
|
|
78
78
|
locale?: string;
|
|
79
79
|
}): EditPlan | null;
|
|
80
80
|
export { extractAudienceTarget, extractAudienceTargets, titleCaseWords, addAudienceSuffix, audiencePatchForBlock, coercePatchForBlock, parseIndexedPath, inferSimpleFieldPatchFromMessage, isRewriteRequest, isTranslationRequest, shouldKeepRichTextTitleOnTranslate, inferFieldHintFromMessage, rewriteFromExisting, coercePatchForEditablePath, quotedText, buildListAppendPatch } from "./deterministic-planner-patches.ts";
|
|
81
|
-
export { editablePropsFromBlock, promptFromPropKey, userFacingPropNames, ORDINALS, humanizeArrayPath, childSuggestions, clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, titleCaseSentence, pageMetaContractSummary, blockContractsSummary } from "./deterministic-planner-suggestions.ts";
|
|
81
|
+
export { editablePropsFromBlock, promptFromPropKey, userFacingPropNames, ORDINALS, humanizeArrayPath, childSuggestions, clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, withKeylessNotice, KEYLESS_PLANNER_NOTICE, titleCaseSentence, pageMetaContractSummary, blockContractsSummary } from "./deterministic-planner-suggestions.ts";
|
|
82
82
|
export { nextAvailableSlug, createPageBlocks, buildCreatePagePlan, isPageRouteRenameRequest } from "./deterministic-planner-pages.ts";
|
|
83
83
|
export { readPathValue, resolveImageUrlForAltField, fetchImageAsBase64, resolveAttachmentsForLlm, selectedBlockSnapshot, arrayPropLengths, pageIntentSummary, plannerContextPack } from "./deterministic-planner-context.ts";
|
|
84
84
|
export type { ResolvedLlmAttachment } from "./deterministic-planner-context.ts";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { allowedBlockTypes, getBlockMeta, IMAGE_PLACEHOLDER } from "@avocadostudio-ai/shared";
|
|
2
|
+
import { allowedBlockTypes, catalogueBlockTypes, getBlockMeta, IMAGE_PLACEHOLDER } from "@avocadostudio-ai/shared";
|
|
3
3
|
import { extractRouteMentions, normalizeRouteCandidate, parseCreatePageRequest, requestsContentGeneration, toSeedSlug } from "./intent-helpers.js";
|
|
4
4
|
import { isBatchAddRequest, isFieldContentUpdateRequest, stripSiteContextEnvelope, stripPositionalAnchorPhrase, extractMentionedBlockTypes, hasCrossPageScope } from "./intent-detection.js";
|
|
5
5
|
import { defaultPropsForType, inferBlockTypeFromText, nextBlockId } from "./plan-normalizer.js";
|
|
@@ -1521,7 +1521,10 @@ export function compileDeterministicPlan(args) {
|
|
|
1521
1521
|
}
|
|
1522
1522
|
return {
|
|
1523
1523
|
intent: "needs_clarification",
|
|
1524
|
-
|
|
1524
|
+
// The types this site can draw, not every type registered in the process —
|
|
1525
|
+
// the built-ins register transitively, so `allowedBlockTypes` would ask a
|
|
1526
|
+
// person to pick from blocks their site has no renderer for.
|
|
1527
|
+
summary_for_user: st(args.locale, "add.specifyType", { types: catalogueBlockTypes().map(blockLabel).join(", ") }),
|
|
1525
1528
|
change_log: assumptions,
|
|
1526
1529
|
ops: []
|
|
1527
1530
|
};
|
|
@@ -1625,7 +1628,7 @@ export function compileDeterministicPlan(args) {
|
|
|
1625
1628
|
// Re-exports from split files (preserve public API)
|
|
1626
1629
|
// ---------------------------------------------------------------------------
|
|
1627
1630
|
export { extractAudienceTarget, extractAudienceTargets, titleCaseWords, addAudienceSuffix, audiencePatchForBlock, coercePatchForBlock, parseIndexedPath, inferSimpleFieldPatchFromMessage, isRewriteRequest, isTranslationRequest, shouldKeepRichTextTitleOnTranslate, inferFieldHintFromMessage, rewriteFromExisting, coercePatchForEditablePath, quotedText, buildListAppendPatch } from "./deterministic-planner-patches.js";
|
|
1628
|
-
export { editablePropsFromBlock, promptFromPropKey, userFacingPropNames, ORDINALS, humanizeArrayPath, childSuggestions, clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, titleCaseSentence, pageMetaContractSummary, blockContractsSummary } from "./deterministic-planner-suggestions.js";
|
|
1631
|
+
export { editablePropsFromBlock, promptFromPropKey, userFacingPropNames, ORDINALS, humanizeArrayPath, childSuggestions, clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, withKeylessNotice, KEYLESS_PLANNER_NOTICE, titleCaseSentence, pageMetaContractSummary, blockContractsSummary } from "./deterministic-planner-suggestions.js";
|
|
1629
1632
|
export { nextAvailableSlug, createPageBlocks, buildCreatePagePlan, isPageRouteRenameRequest } from "./deterministic-planner-pages.js";
|
|
1630
1633
|
export { readPathValue, resolveImageUrlForAltField, fetchImageAsBase64, resolveAttachmentsForLlm, selectedBlockSnapshot, arrayPropLengths, pageIntentSummary, plannerContextPack } from "./deterministic-planner-context.js";
|
|
1631
1634
|
export { inferAddedBlockTypeFromMessage, resolveBlockRef, ordinalToIndex, resolveByDescriptor, resolveReferencesFromMessage } from "./deterministic-planner-refs.js";
|
|
@@ -269,17 +269,13 @@ export declare function isPageListQuery(message: string): boolean;
|
|
|
269
269
|
*/
|
|
270
270
|
export declare function hasCrossPageScope(message: string): boolean;
|
|
271
271
|
export declare function isContentQuery(message: string): boolean;
|
|
272
|
-
export
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
modelUsed: string;
|
|
278
|
-
modelKey: ModelKey;
|
|
279
|
-
}): {
|
|
280
|
-
code: number;
|
|
281
|
-
payload: ChatResult;
|
|
272
|
+
export type PageObservation = {
|
|
273
|
+
/** What is true, stated so a reader can check it. */
|
|
274
|
+
fact: string;
|
|
275
|
+
/** The edit that would resolve it, phrased as a command the planner can run. */
|
|
276
|
+
fix: string;
|
|
282
277
|
};
|
|
278
|
+
export declare function pageObservations(current: PageDoc): PageObservation[];
|
|
283
279
|
export declare function plannerMessageWithPendingContext(session: string, message: string): string;
|
|
284
280
|
/** Build the site context lines without wrapping in a message. Returns null if empty. */
|
|
285
281
|
/** How many documents ride along in a planner request. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { catalogueBlockTypes, getBlockMeta, getPropDisplayName } from "@avocadostudio-ai/shared";
|
|
3
3
|
import { isLikelyClarificationFollowUp, isStandalonePageOperation } from "./intent-helpers.js";
|
|
4
4
|
import { versions, pendingClarificationBySession } from "../state/session-state.js";
|
|
5
5
|
import { UNIT, BLOCK_CATALOG_PATTERNS, BATCH_ADD_PATTERNS, BATCH_UPDATE_PATTERNS, BATCH_PAGE_CREATE_PATTERNS, COUNTED_MULTI_BLOCK_ADD_PATTERN, ADD_ACTION_PATTERN, BLOCK_TYPE_KEYWORDS, KEYWORD_TO_BLOCK_TYPE, EACH_BLOCK_TYPE_PATTERN, PAGE_WIDE_REWRITE_PATTERNS, BATCH_REORDER_PATTERNS, PAGE_LIST_PATTERNS, CONTENT_QUERY_PATTERNS, FIELD_CONTENT_UPDATE_PATTERN, CROSS_PAGE_SCOPE_PATTERN } from "./intent-patterns.js";
|
|
@@ -297,151 +297,143 @@ export function isContentQuery(message) {
|
|
|
297
297
|
return false;
|
|
298
298
|
return CONTENT_QUERY_PATTERNS.some((re) => re.test(m));
|
|
299
299
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const changes = hasFaq
|
|
332
|
-
? ["Current state: FAQ already exists on this page.", "Recommendation: keep 3-6 high-intent questions."]
|
|
333
|
-
: ["Current state: no FAQ block detected on this page.", "Recommendation: add a compact FAQ section near the bottom."];
|
|
334
|
-
return {
|
|
335
|
-
code: 200,
|
|
336
|
-
payload: {
|
|
337
|
-
status: "advice",
|
|
338
|
-
summary,
|
|
339
|
-
changes,
|
|
340
|
-
suggestions: hasFaq
|
|
341
|
-
? ["Move FAQ to bottom", "Rewrite FAQ questions for this audience", "Keep FAQ, but reduce to 4 questions"]
|
|
342
|
-
: ["Add FAQ section with 4 questions at the bottom", "Add FAQ below testimonials", "Skip FAQ on this page"],
|
|
343
|
-
mentionedSlugs: [current.slug],
|
|
344
|
-
previewVersion: versions.get(body.session ?? "dev") ?? 0,
|
|
345
|
-
plannerSource,
|
|
346
|
-
modelUsed,
|
|
347
|
-
modelKey
|
|
348
|
-
}
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
// Analyze existing blocks on the page
|
|
352
|
-
const existingTypes = new Set(current.blocks.map((b) => b.type));
|
|
353
|
-
const presentList = current.blocks.map((b) => {
|
|
354
|
-
const meta = getBlockMeta(b.type);
|
|
355
|
-
return meta ? `${meta.displayName}` : b.type;
|
|
356
|
-
});
|
|
357
|
-
const missingTypes = allowedBlockTypes.filter((t) => !existingTypes.has(t));
|
|
358
|
-
const changes = [];
|
|
359
|
-
changes.push(`Current blocks (${current.blocks.length}): ${presentList.join(", ") || "none"}.`);
|
|
360
|
-
if (!hasHero)
|
|
361
|
-
changes.push("Missing: Hero — consider adding a headline section at the top.");
|
|
362
|
-
if (!hasCta)
|
|
363
|
-
changes.push("Missing: CTA — add a call-to-action to drive conversions.");
|
|
364
|
-
if (!existingTypes.has("Testimonials") && !existingTypes.has("Stats"))
|
|
365
|
-
changes.push("Missing: social proof (Testimonials or Stats) — builds trust.");
|
|
366
|
-
if (!existingTypes.has("FAQAccordion"))
|
|
367
|
-
changes.push("Missing: FAQ — addresses objections and improves SEO.");
|
|
368
|
-
// Build contextual suggestions based on what's actually missing
|
|
369
|
-
const suggestions = [];
|
|
370
|
-
const suggestionPriority = [
|
|
371
|
-
{ type: "Hero", label: "Add a Hero section at the top" },
|
|
372
|
-
{ type: "CTA", label: "Add a CTA section to drive conversions" },
|
|
373
|
-
{ type: "Testimonials", label: "Add Testimonials for social proof" },
|
|
374
|
-
{ type: "Stats", label: "Add Stats to highlight key numbers" },
|
|
375
|
-
{ type: "FAQAccordion", label: "Add FAQ at the bottom" },
|
|
376
|
-
{ type: "FeatureGrid", label: "Add a Feature Grid to list benefits" },
|
|
377
|
-
{ type: "CardGrid", label: "Add a Card Grid for related content" },
|
|
378
|
-
{ type: "ContactForm", label: "Add a Contact Form" },
|
|
379
|
-
{ type: "Footer", label: "Add a Footer with links" }
|
|
380
|
-
];
|
|
381
|
-
for (const item of suggestionPriority) {
|
|
382
|
-
if (!existingTypes.has(item.type) && suggestions.length < 4)
|
|
383
|
-
suggestions.push(item.label);
|
|
384
|
-
}
|
|
385
|
-
// If page has all common blocks, suggest content improvements based on actual props
|
|
386
|
-
if (suggestions.length === 0) {
|
|
387
|
-
for (const block of current.blocks) {
|
|
388
|
-
if (suggestions.length >= 4)
|
|
389
|
-
break;
|
|
390
|
-
const p = block.props;
|
|
391
|
-
if (!p)
|
|
300
|
+
/** `imageUrl` -> `image url`, for a field whose site never gave it a label. */
|
|
301
|
+
function humanizePropKey(key) {
|
|
302
|
+
return key
|
|
303
|
+
.replace(/\[\]\./g, " ")
|
|
304
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
305
|
+
.replace(/[_-]+/g, " ")
|
|
306
|
+
.trim()
|
|
307
|
+
.toLowerCase();
|
|
308
|
+
}
|
|
309
|
+
export function pageObservations(current) {
|
|
310
|
+
const observations = [];
|
|
311
|
+
if (current.blocks.length === 0)
|
|
312
|
+
return observations;
|
|
313
|
+
const metaFor = (type) => getBlockMeta(type);
|
|
314
|
+
const nameOf = (type) => metaFor(type)?.displayName ?? type;
|
|
315
|
+
const categoryOf = (type) => metaFor(type)?.category;
|
|
316
|
+
const presentTypes = new Set(current.blocks.map((block) => block.type));
|
|
317
|
+
const addableTypes = catalogueBlockTypes().filter((type) => !presentTypes.has(type) && !metaFor(type)?.chrome);
|
|
318
|
+
/** Every declared field value on a block, list items included. */
|
|
319
|
+
const eachField = (block, visit) => {
|
|
320
|
+
const meta = metaFor(block.type);
|
|
321
|
+
if (!meta)
|
|
322
|
+
return;
|
|
323
|
+
const props = (block.props ?? {});
|
|
324
|
+
for (const [key, field] of Object.entries(meta.fields)) {
|
|
325
|
+
if (!field.internal)
|
|
326
|
+
visit(key, field, props[key]);
|
|
327
|
+
}
|
|
328
|
+
for (const [listKey, listMeta] of Object.entries(meta.listFields ?? {})) {
|
|
329
|
+
const items = props[listKey];
|
|
330
|
+
if (!Array.isArray(items))
|
|
392
331
|
continue;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
if (block.type === "Stats") {
|
|
406
|
-
const items = (p.items ?? p.stats);
|
|
407
|
-
if (items?.some((it) => (it.label ?? it.description ?? "").length > 30)) {
|
|
408
|
-
suggestions.push("Rewrite the stats labels to be shorter and number-driven");
|
|
332
|
+
for (const item of items) {
|
|
333
|
+
if (!item || typeof item !== "object")
|
|
334
|
+
continue;
|
|
335
|
+
const row = item;
|
|
336
|
+
const branch = listMeta.discriminator
|
|
337
|
+
? listMeta.itemFieldsByType?.[String(row[listMeta.discriminator])] ?? listMeta.itemFields
|
|
338
|
+
: listMeta.itemFields;
|
|
339
|
+
for (const [key, field] of Object.entries(branch)) {
|
|
340
|
+
if (!field.internal)
|
|
341
|
+
visit(`${listKey}[].${key}`, field, row[key]);
|
|
409
342
|
}
|
|
410
343
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
const filled = (value) => typeof value === "string" && value.trim().length > 0;
|
|
347
|
+
let hasImageAnywhere = false;
|
|
348
|
+
let hasLinkAnywhere = false;
|
|
349
|
+
/*
|
|
350
|
+
* Keyed by block type + field rather than pushed per occurrence: an empty
|
|
351
|
+
* avatar on every row of a testimonials list is one finding about one field,
|
|
352
|
+
* and the first version of this reported it once per row.
|
|
353
|
+
*/
|
|
354
|
+
const emptyImages = new Map();
|
|
355
|
+
for (const block of current.blocks) {
|
|
356
|
+
eachField(block, (key, field, value) => {
|
|
357
|
+
// `getPropDisplayName` takes (blockType, propKey) in that order, and falls
|
|
358
|
+
// back to the raw key when a field declares no label — so a site that
|
|
359
|
+
// never labelled its props would otherwise be told about "imageUrl".
|
|
360
|
+
const fieldLabel = field.label ?? humanizePropKey(getPropDisplayName(block.type, key));
|
|
361
|
+
if (field.kind === "image" || field.kind === "imageList") {
|
|
362
|
+
if (filled(value) || (Array.isArray(value) && value.length > 0))
|
|
363
|
+
hasImageAnywhere = true;
|
|
416
364
|
else {
|
|
417
|
-
|
|
365
|
+
const id = `${block.type}:${key}`;
|
|
366
|
+
const seen = emptyImages.get(id);
|
|
367
|
+
if (seen)
|
|
368
|
+
seen.count += 1;
|
|
369
|
+
else
|
|
370
|
+
emptyImages.set(id, { block: nameOf(block.type), label: fieldLabel, count: 1 });
|
|
418
371
|
}
|
|
419
372
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (hasHero)
|
|
424
|
-
suggestions.push("Rewrite the hero headline");
|
|
425
|
-
if (hasCta)
|
|
426
|
-
suggestions.push("Strengthen the CTA copy");
|
|
427
|
-
suggestions.push("Tighten the copy across all sections");
|
|
428
|
-
}
|
|
373
|
+
if ((field.kind === "url" || field.kind === "link") && filled(value))
|
|
374
|
+
hasLinkAnywhere = true;
|
|
375
|
+
});
|
|
429
376
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
377
|
+
/*
|
|
378
|
+
* A structural gap is only worth raising when the page really lacks the thing
|
|
379
|
+
* *and* this site has something to fill it with. The filler types are
|
|
380
|
+
* alternatives, so they are offered as a choice.
|
|
381
|
+
*
|
|
382
|
+
* The category is read off the *catalogue*, never off the page: an earlier
|
|
383
|
+
* version asked "is any block on this page of category media" and told a home
|
|
384
|
+
* page carrying a hero with a photograph that it was all text. A block's
|
|
385
|
+
* category says what kind of block it is. Only the value says what is on the
|
|
386
|
+
* page.
|
|
387
|
+
*/
|
|
388
|
+
const addableIn = (category) => addableTypes.filter((type) => categoryOf(type) === category);
|
|
389
|
+
const orList = (types) => types.slice(0, 3).map(nameOf).join(" or ");
|
|
390
|
+
if (!hasImageAnywhere) {
|
|
391
|
+
const media = addableIn("media");
|
|
392
|
+
observations.push({
|
|
393
|
+
fact: media.length > 0
|
|
394
|
+
? `No image anywhere on this page — every block is text. This site can add: ${orList(media)}.`
|
|
395
|
+
: "No image anywhere on this page — every block is text.",
|
|
396
|
+
fix: media.length > 0 ? `Add ${nameOf(media[0])}` : "Add an image to the first section"
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
if (!hasLinkAnywhere) {
|
|
400
|
+
const conversion = addableIn("conversion");
|
|
401
|
+
observations.push({
|
|
402
|
+
fact: conversion.length > 0
|
|
403
|
+
? `Nothing on this page links anywhere — no button or link has a destination. This site can add: ${orList(conversion)}.`
|
|
404
|
+
: "Nothing on this page links anywhere — no button or link has a destination.",
|
|
405
|
+
fix: conversion.length > 0 ? `Add ${nameOf(conversion[0])}` : "Give the buttons a destination"
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
/*
|
|
409
|
+
* Only worth saying when the page has images elsewhere. On a page with none,
|
|
410
|
+
* "no image anywhere" above is the finding, and one "this block's image is
|
|
411
|
+
* empty" per block underneath it is the same fact restated per row.
|
|
412
|
+
*/
|
|
413
|
+
for (const hole of hasImageAnywhere ? [...emptyImages.values()].slice(0, 3) : []) {
|
|
414
|
+
const many = hole.count > 1 ? ` (${hole.count} of them)` : "";
|
|
415
|
+
observations.push({
|
|
416
|
+
fact: `${hole.block} declares ${hole.label.toLowerCase()} and it is empty${many}.`,
|
|
417
|
+
fix: `Add ${hole.label.toLowerCase()} to ${hole.block}`
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
const meta = current.meta;
|
|
421
|
+
if (!filled(meta?.description)) {
|
|
422
|
+
observations.push({
|
|
423
|
+
fact: "This page has no meta description — search results and link previews fall back to whatever text comes first.",
|
|
424
|
+
fix: "Write a meta description for this page"
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/*
|
|
428
|
+
* No length check, and there was one for an afternoon. It flagged any
|
|
429
|
+
* single-line `text` field over sixty characters as an overlong headline, and
|
|
430
|
+
* the first thing it found on a real page was the hero's *subheading* at 160
|
|
431
|
+
* characters — which is prose, and fine. Nothing in the manifest says which
|
|
432
|
+
* text field is a headline and which is a paragraph, so the check asserted a
|
|
433
|
+
* role it could not know. Judging whether a sentence is too long is the
|
|
434
|
+
* model's job, and the model can now see the sentence.
|
|
435
|
+
*/
|
|
436
|
+
return observations;
|
|
445
437
|
}
|
|
446
438
|
// A pending clarification only contextualizes the answer that immediately follows it.
|
|
447
439
|
// One left unanswered for longer than this is stale (a prior session's leftover, or the
|
|
@@ -676,19 +668,37 @@ export function infoResponse(args) {
|
|
|
676
668
|
};
|
|
677
669
|
}
|
|
678
670
|
if (isBlockCatalogQuery(body.message ?? "")) {
|
|
671
|
+
/*
|
|
672
|
+
* "What can I add?" is a question about this site, and it used to be
|
|
673
|
+
* answered with `allowedBlockTypes` — every type registered in the process,
|
|
674
|
+
* which importing `@avocadostudio-ai/shared` at all fills with the
|
|
675
|
+
* built-ins. A site rendering its own types was handed a list of thirty-two
|
|
676
|
+
* and five worked examples naming blocks it does not have, down to the
|
|
677
|
+
* placement anchors ("add Testimonials below Hero" on a page with neither).
|
|
678
|
+
* Every name here now comes from the catalogue, and the anchor from a block
|
|
679
|
+
* that is actually on the page.
|
|
680
|
+
*/
|
|
681
|
+
const catalogue = catalogueBlockTypes().filter((type) => !getBlockMeta(type)?.chrome);
|
|
682
|
+
const nameOf = (type) => getBlockMeta(type)?.displayName ?? type;
|
|
683
|
+
const presentTypes = new Set(current.blocks.map((block) => block.type));
|
|
684
|
+
const addable = catalogue.filter((type) => !presentTypes.has(type));
|
|
685
|
+
// A real block on this page, to anchor an example against. Undefined on an
|
|
686
|
+
// empty page, where "at the end" is the only placement that means anything.
|
|
687
|
+
const anchor = current.blocks.length > 0 ? nameOf(current.blocks[current.blocks.length - 1].type) : undefined;
|
|
688
|
+
const examples = (addable.length > 0 ? addable : catalogue).slice(0, 5).map((type, i) => anchor && i % 2 === 1 ? `Add ${nameOf(type)} below ${anchor}` : `Add ${nameOf(type)} at the end`);
|
|
679
689
|
return {
|
|
680
690
|
code: 200,
|
|
681
691
|
payload: {
|
|
682
692
|
status: "info",
|
|
683
|
-
summary:
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
"Add CTA at the end"
|
|
693
|
+
summary: catalogue.length > 0
|
|
694
|
+
? `You can add these block types: ${catalogue.map(nameOf).join(", ")}.`
|
|
695
|
+
: "This site has not registered any block types yet, so there is nothing to add.",
|
|
696
|
+
changes: [
|
|
697
|
+
anchor
|
|
698
|
+
? `Tip: specify position, e.g. \u201cadd ${nameOf((addable[0] ?? catalogue[0]))} below ${anchor}\u201d.`
|
|
699
|
+
: "Tip: specify position, e.g. \u201cadd it at the end\u201d."
|
|
691
700
|
],
|
|
701
|
+
suggestions: examples,
|
|
692
702
|
previewVersion: versions.get(body.session ?? "dev") ?? 0,
|
|
693
703
|
plannerSource,
|
|
694
704
|
modelUsed,
|
|
@@ -210,7 +210,18 @@ export const CONTENT_QUERY_PATTERNS = [
|
|
|
210
210
|
/\bsummarize\s+(?:this|the)\s+(?:page|content)\b/,
|
|
211
211
|
/\bwhat\s+(?:blocks?|sections?)\s+(?:are|is)\s+(?:on|in)\s+(?:this|the)\s+page\b/,
|
|
212
212
|
/\b(?:list|show|tell me)\b.*\b(?:all|every)\b.*\b(?:blocks?|sections?|components?)\b.*\b(?:on|in)\b/,
|
|
213
|
-
/\b(?:audit|review|check|inspect|analyze)\s+(?:the\s+)?(?:page|content|copy|text|links?|images?|buttons?|hero|cta|testimonials?|faq|feature\s*grid|card\s*grid|stats?|footer|contact\s*form|rich\s*text|two\s*column|carousel|gallery|tabs?|table|quote|video|embed|banner|section|block)\b/,
|
|
213
|
+
/\b(?:audit|review|check|inspect|analyze)\s+(?:the\s+|this\s+|my\s+)?(?:page|site|content|copy|text|links?|images?|buttons?|hero|cta|testimonials?|faq|feature\s*grid|card\s*grid|stats?|footer|contact\s*form|rich\s*text|two\s*column|carousel|gallery|tabs?|table|quote|video|embed|banner|section|block)\b/,
|
|
214
|
+
/*
|
|
215
|
+
* Page-feedback questions. These used to be intercepted by `isAdviceQuery`
|
|
216
|
+
* and answered from a template; they are judgement questions, so they belong
|
|
217
|
+
* to the model, and matching them here is what gets the model the full props
|
|
218
|
+
* it needs to answer about actual copy rather than about block names.
|
|
219
|
+
*/
|
|
220
|
+
/\b(?:what do you think|any (?:thoughts|feedback|suggestions)|is (?:it|this) good)\b/,
|
|
221
|
+
/\bwhat\s+(?:can|should)\s+(?:be\s+)?improve/,
|
|
222
|
+
/\bhow\s+(?:can|should)\s+(?:i|we)?\s*improve\b/,
|
|
223
|
+
/\bimprovements?\b/,
|
|
224
|
+
/\b(?:do|would)\s+you\s+recommend\b/,
|
|
214
225
|
/\bdescribe\s+(?:this|the|that)\s+(?:image|photo|picture|icon|logo|illustration|page|site|content|section)\b/,
|
|
215
226
|
];
|
|
216
227
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/orchestrator-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
21
21
|
"better-sqlite3": "^12.9.0",
|
|
22
22
|
"openai": "^4.87.1",
|
|
23
|
-
"sharp": "^0.
|
|
23
|
+
"sharp": "^0.35.4",
|
|
24
24
|
"zod": "^4.3.6",
|
|
25
|
-
"@avocadostudio-ai/shared": "^0.
|
|
26
|
-
"@avocadostudio-ai/migration-sdk": "^0.
|
|
25
|
+
"@avocadostudio-ai/shared": "^0.9.0",
|
|
26
|
+
"@avocadostudio-ai/migration-sdk": "^0.9.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|