@avocadostudio-ai/orchestrator-core 0.1.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/LICENSE +201 -0
- package/dist/agent/agent-context.d.ts +19 -0
- package/dist/agent/agent-context.js +67 -0
- package/dist/agent/agent-logger.d.ts +5 -0
- package/dist/agent/agent-logger.js +22 -0
- package/dist/agent/agent-loop-openai.d.ts +8 -0
- package/dist/agent/agent-loop-openai.js +172 -0
- package/dist/agent/agent-loop.d.ts +56 -0
- package/dist/agent/agent-loop.js +167 -0
- package/dist/agent/agent-provider.d.ts +28 -0
- package/dist/agent/agent-provider.js +63 -0
- package/dist/agent/agent-tools.d.ts +28 -0
- package/dist/agent/agent-tools.js +899 -0
- package/dist/agent/context/editing-guidelines.md +46 -0
- package/dist/agent/context/role.md +39 -0
- package/dist/agent/integration-prompt.d.ts +9 -0
- package/dist/agent/integration-prompt.js +154 -0
- package/dist/agent/sites-agent-context.d.ts +12 -0
- package/dist/agent/sites-agent-context.js +316 -0
- package/dist/agent/sites-agent-shared.d.ts +161 -0
- package/dist/agent/sites-agent-shared.js +1101 -0
- package/dist/agent/sites-agent-tools.d.ts +18 -0
- package/dist/agent/sites-agent-tools.js +1227 -0
- package/dist/chat/anthropic-cache.d.ts +20 -0
- package/dist/chat/anthropic-cache.js +54 -0
- package/dist/chat/anthropic-planner.d.ts +98 -0
- package/dist/chat/anthropic-planner.js +1012 -0
- package/dist/chat/changelog-coverage-validator.d.ts +37 -0
- package/dist/chat/changelog-coverage-validator.js +215 -0
- package/dist/chat/chat-pipeline-context.d.ts +211 -0
- package/dist/chat/chat-pipeline-context.js +249 -0
- package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
- package/dist/chat/chat-pipeline-deterministic.js +407 -0
- package/dist/chat/chat-pipeline-image.d.ts +86 -0
- package/dist/chat/chat-pipeline-image.js +897 -0
- package/dist/chat/chat-pipeline-shared.d.ts +69 -0
- package/dist/chat/chat-pipeline-shared.js +212 -0
- package/dist/chat/chat-pipeline-translation.d.ts +27 -0
- package/dist/chat/chat-pipeline-translation.js +417 -0
- package/dist/chat/chat-pipeline-ui.d.ts +14 -0
- package/dist/chat/chat-pipeline-ui.js +244 -0
- package/dist/chat/chat-pipeline.d.ts +99 -0
- package/dist/chat/chat-pipeline.js +3999 -0
- package/dist/chat/decomposer.d.ts +21 -0
- package/dist/chat/decomposer.js +65 -0
- package/dist/chat/gemini-planner.d.ts +70 -0
- package/dist/chat/gemini-planner.js +541 -0
- package/dist/chat/hallucination-validator.d.ts +36 -0
- package/dist/chat/hallucination-validator.js +110 -0
- package/dist/chat/locale-strings.d.ts +47 -0
- package/dist/chat/locale-strings.js +100 -0
- package/dist/chat/plan-json-schema.d.ts +133 -0
- package/dist/chat/plan-json-schema.js +112 -0
- package/dist/chat/planner-types.d.ts +120 -0
- package/dist/chat/planner-types.js +66 -0
- package/dist/chat/planner.d.ts +148 -0
- package/dist/chat/planner.js +1361 -0
- package/dist/chat/prompts.d.ts +67 -0
- package/dist/chat/prompts.js +356 -0
- package/dist/chat/provider-routing.d.ts +14 -0
- package/dist/chat/provider-routing.js +27 -0
- package/dist/chat/variation-pipeline.d.ts +135 -0
- package/dist/chat/variation-pipeline.js +837 -0
- package/dist/chat/vision-alt-generator.d.ts +35 -0
- package/dist/chat/vision-alt-generator.js +152 -0
- package/dist/cms/adapter.d.ts +62 -0
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/bootstrap.d.ts +17 -0
- package/dist/cms/bootstrap.js +85 -0
- package/dist/cms/editor-api-adapter.d.ts +23 -0
- package/dist/cms/editor-api-adapter.js +71 -0
- package/dist/cms/index.d.ts +4 -0
- package/dist/cms/index.js +3 -0
- package/dist/cms/json-file-adapter.d.ts +11 -0
- package/dist/cms/json-file-adapter.js +62 -0
- package/dist/demo-mode.d.ts +59 -0
- package/dist/demo-mode.js +201 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.js +129 -0
- package/dist/http/chat-stream-resumable.d.ts +108 -0
- package/dist/http/chat-stream-resumable.js +290 -0
- package/dist/http/chat-stream.d.ts +99 -0
- package/dist/http/chat-stream.js +92 -0
- package/dist/image/gdrive-client.d.ts +22 -0
- package/dist/image/gdrive-client.js +215 -0
- package/dist/image/image-helpers.d.ts +95 -0
- package/dist/image/image-helpers.js +488 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/jira/jira-approval.d.ts +22 -0
- package/dist/jira/jira-approval.js +51 -0
- package/dist/jira/jira-client.d.ts +44 -0
- package/dist/jira/jira-client.js +313 -0
- package/dist/jira/jira-poller.d.ts +46 -0
- package/dist/jira/jira-poller.js +184 -0
- package/dist/jira/jira-processor.d.ts +103 -0
- package/dist/jira/jira-processor.js +1085 -0
- package/dist/jira/jira-types.d.ts +117 -0
- package/dist/jira/jira-types.js +38 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +28 -0
- package/dist/migration/mcp-server-stdio.d.ts +8 -0
- package/dist/migration/mcp-server-stdio.js +672 -0
- package/dist/migration/migration-prompt.d.ts +7 -0
- package/dist/migration/migration-prompt.js +197 -0
- package/dist/migration/migration-tools.d.ts +17 -0
- package/dist/migration/migration-tools.js +159 -0
- package/dist/migration/scrape-cache.d.ts +9 -0
- package/dist/migration/scrape-cache.js +19 -0
- package/dist/nlp/deterministic-planner-context.d.ts +141 -0
- package/dist/nlp/deterministic-planner-context.js +362 -0
- package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
- package/dist/nlp/deterministic-planner-pages.js +170 -0
- package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
- package/dist/nlp/deterministic-planner-patches.js +508 -0
- package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
- package/dist/nlp/deterministic-planner-refs.js +164 -0
- package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
- package/dist/nlp/deterministic-planner-suggestions.js +579 -0
- package/dist/nlp/deterministic-planner.d.ts +85 -0
- package/dist/nlp/deterministic-planner.js +1631 -0
- package/dist/nlp/intent-detection.d.ts +309 -0
- package/dist/nlp/intent-detection.js +730 -0
- package/dist/nlp/intent-helpers.d.ts +15 -0
- package/dist/nlp/intent-helpers.js +243 -0
- package/dist/nlp/intent-patterns.d.ts +40 -0
- package/dist/nlp/intent-patterns.js +223 -0
- package/dist/nlp/plan-normalizer.d.ts +41 -0
- package/dist/nlp/plan-normalizer.js +1537 -0
- package/dist/ops/destructive-action-gate.d.ts +44 -0
- package/dist/ops/destructive-action-gate.js +90 -0
- package/dist/ops/ops-engine.d.ts +151 -0
- package/dist/ops/ops-engine.js +1394 -0
- package/dist/publish/diff-engine.d.ts +18 -0
- package/dist/publish/diff-engine.js +305 -0
- package/dist/publish/publish-helpers.d.ts +87 -0
- package/dist/publish/publish-helpers.js +521 -0
- package/dist/publish/publish-target-registry.d.ts +7 -0
- package/dist/publish/publish-target-registry.js +61 -0
- package/dist/publish/publish-target.d.ts +81 -0
- package/dist/publish/publish-target.js +1 -0
- package/dist/publish/targets/deploy-hook.d.ts +13 -0
- package/dist/publish/targets/deploy-hook.js +123 -0
- package/dist/publish/targets/git.d.ts +13 -0
- package/dist/publish/targets/git.js +55 -0
- package/dist/publish/targets/site-contract.d.ts +19 -0
- package/dist/publish/targets/site-contract.js +124 -0
- package/dist/state/content-source.d.ts +17 -0
- package/dist/state/content-source.js +1 -0
- package/dist/state/in-memory-content-source.d.ts +27 -0
- package/dist/state/in-memory-content-source.js +51 -0
- package/dist/state/session-lock.d.ts +13 -0
- package/dist/state/session-lock.js +29 -0
- package/dist/state/session-state.d.ts +310 -0
- package/dist/state/session-state.js +1083 -0
- package/dist/state/sqlite-store-singleton.d.ts +31 -0
- package/dist/state/sqlite-store-singleton.js +170 -0
- package/dist/state/sqlite-store.d.ts +135 -0
- package/dist/state/sqlite-store.js +421 -0
- package/dist/telemetry/chat-telemetry.d.ts +105 -0
- package/dist/telemetry/chat-telemetry.js +247 -0
- package/dist/telemetry/eval-candidate-store.d.ts +50 -0
- package/dist/telemetry/eval-candidate-store.js +120 -0
- package/dist/telemetry/feedback-store.d.ts +34 -0
- package/dist/telemetry/feedback-store.js +76 -0
- package/dist/telemetry/jira-telemetry.d.ts +57 -0
- package/dist/telemetry/jira-telemetry.js +68 -0
- package/dist/telemetry/migration-telemetry.d.ts +35 -0
- package/dist/telemetry/migration-telemetry.js +40 -0
- package/dist/telemetry/usage.d.ts +24 -0
- package/dist/telemetry/usage.js +80 -0
- package/dist/tools/builtin-registrations.d.ts +12 -0
- package/dist/tools/builtin-registrations.js +33 -0
- package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
- package/dist/tools/builtins/gdrive-browse.js +68 -0
- package/dist/tools/builtins/image-generate.d.ts +3 -0
- package/dist/tools/builtins/image-generate.js +211 -0
- package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
- package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
- package/dist/tools/builtins/unsplash-search.d.ts +3 -0
- package/dist/tools/builtins/unsplash-search.js +74 -0
- package/dist/tools/executor.d.ts +23 -0
- package/dist/tools/executor.js +169 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/registry.d.ts +21 -0
- package/dist/tools/registry.js +75 -0
- package/dist/tools/runtime.d.ts +27 -0
- package/dist/tools/runtime.js +48 -0
- package/dist/tools/schema-validator.d.ts +24 -0
- package/dist/tools/schema-validator.js +88 -0
- package/dist/tools/types.d.ts +86 -0
- package/dist/tools/types.js +1 -0
- package/dist/variation-images.d.ts +19 -0
- package/dist/variation-images.js +12 -0
- package/package.json +78 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { allowedBlockTypes, getBlockMeta, getPropDisplayName } from "@avocadostudio-ai/shared";
|
|
3
|
+
import { isLikelyClarificationFollowUp, isStandalonePageOperation } from "./intent-helpers.js";
|
|
4
|
+
import { versions, pendingClarificationBySession } from "../state/session-state.js";
|
|
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";
|
|
6
|
+
export const siteCapabilitiesSchema = z.object({
|
|
7
|
+
allowStructuralEdits: z.boolean(),
|
|
8
|
+
manifestStatus: z.enum(["loading", "ready", "degraded"]),
|
|
9
|
+
reason: z.string().optional(),
|
|
10
|
+
manifestVersion: z.number().int().positive().optional(),
|
|
11
|
+
blockCount: z.number().int().nonnegative().optional(),
|
|
12
|
+
checkedAt: z.string()
|
|
13
|
+
});
|
|
14
|
+
const businessContextObjectSchema = z.object({
|
|
15
|
+
purpose: z.string().optional(),
|
|
16
|
+
tone: z.string().optional(),
|
|
17
|
+
constraints: z.array(z.string()).optional(),
|
|
18
|
+
});
|
|
19
|
+
const siteContextObjectSchema = z.object({
|
|
20
|
+
siteId: z.string().optional(),
|
|
21
|
+
siteName: z.string().optional(),
|
|
22
|
+
purpose: z.string().optional(),
|
|
23
|
+
hosting: z.string().optional(),
|
|
24
|
+
tone: z.string().optional(),
|
|
25
|
+
constraints: z.array(z.string()).optional(),
|
|
26
|
+
gdriveFolderId: z.string().optional(),
|
|
27
|
+
});
|
|
28
|
+
export const chatAttachmentSchema = z.object({
|
|
29
|
+
id: z.string(),
|
|
30
|
+
kind: z.enum(["image", "pdf"]),
|
|
31
|
+
url: z.string(),
|
|
32
|
+
mimeType: z.string(),
|
|
33
|
+
name: z.string().optional(),
|
|
34
|
+
bytes: z.number().optional(),
|
|
35
|
+
});
|
|
36
|
+
export const chatRequestBodySchema = z.object({
|
|
37
|
+
session: z.string().optional(),
|
|
38
|
+
siteId: z.string().optional(),
|
|
39
|
+
componentsManifest: z.union([z.record(z.string(), z.unknown()), z.string()]).optional(),
|
|
40
|
+
siteCapabilities: z.union([siteCapabilitiesSchema, z.string()]).optional(),
|
|
41
|
+
sitePurpose: z.string().optional(),
|
|
42
|
+
siteHosting: z.string().optional(),
|
|
43
|
+
businessContext: z.union([businessContextObjectSchema, z.string()]).optional(),
|
|
44
|
+
siteContext: z.union([siteContextObjectSchema, z.string()]).optional(),
|
|
45
|
+
locale: z.string().optional(),
|
|
46
|
+
slug: z.string().optional(),
|
|
47
|
+
message: z.string().optional(),
|
|
48
|
+
modelKey: z.enum(["fast", "balanced", "reasoning", "codex"]).optional(),
|
|
49
|
+
provider: z.enum(["openai", "anthropic", "gemini"]).optional(),
|
|
50
|
+
activeBlockId: z.string().optional(),
|
|
51
|
+
activeBlockType: z.string().optional(),
|
|
52
|
+
activeEditablePath: z.string().optional(),
|
|
53
|
+
executionMode: z.enum(["auto", "plan_only", "apply_pending_plan", "discard_pending_plan", "continue_chain"]).optional(),
|
|
54
|
+
pendingPlanId: z.string().optional(),
|
|
55
|
+
continuationChainId: z.string().optional(),
|
|
56
|
+
attachments: z.array(chatAttachmentSchema).max(6).optional(),
|
|
57
|
+
});
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Shared normaliser used by all intent detectors below.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
export function normalizeForIntent(message) {
|
|
62
|
+
return message.toLowerCase().replace(/\s+/g, " ").trim();
|
|
63
|
+
}
|
|
64
|
+
// Backward-compatible re-exports — patterns now live in intent-patterns.ts
|
|
65
|
+
export { BLOCK_CATALOG_PATTERNS } from "./intent-patterns.js";
|
|
66
|
+
export function isBlockCatalogQuery(message) {
|
|
67
|
+
const m = normalizeForIntent(message);
|
|
68
|
+
return BLOCK_CATALOG_PATTERNS.some((re) => re.test(m));
|
|
69
|
+
}
|
|
70
|
+
function countMentionedBlockTypes(message) {
|
|
71
|
+
const matched = new Set();
|
|
72
|
+
for (const entry of BLOCK_TYPE_KEYWORDS) {
|
|
73
|
+
if (entry.pattern.test(message))
|
|
74
|
+
matched.add(entry.key);
|
|
75
|
+
}
|
|
76
|
+
return matched.size;
|
|
77
|
+
}
|
|
78
|
+
// A trailing positional-anchor phrase ("... below the hero", "... after the
|
|
79
|
+
// testimonials") names an EXISTING block used purely for placement — it is never
|
|
80
|
+
// a block to create. Strip it before scanning for block types so the anchor's
|
|
81
|
+
// own type isn't mistaken for new content, which produced phantom duplicate
|
|
82
|
+
// blocks (planner issue #18). The anchor is resolved separately from the full
|
|
83
|
+
// message, so removing it here does not affect placement.
|
|
84
|
+
//
|
|
85
|
+
// Requires an article after the preposition ("below THE hero") so adjectival
|
|
86
|
+
// uses like "a before-and-after gallery" are left intact. A coordinated list
|
|
87
|
+
// before the anchor ("add a hero and a cta below the header") is preserved
|
|
88
|
+
// because only the trailing anchor clause is removed.
|
|
89
|
+
const POSITIONAL_ANCHOR_PHRASE = /\s+\b(?:below|above|under(?:neath)?|beneath|after|before|beside|atop)\s+(?:the|a|an|our|my|that|this|its)\b.*$/i;
|
|
90
|
+
export function stripPositionalAnchorPhrase(message) {
|
|
91
|
+
return message.replace(POSITIONAL_ANCHOR_PHRASE, "").trim();
|
|
92
|
+
}
|
|
93
|
+
/** Return the ordered list of BlockType values mentioned in the message. */
|
|
94
|
+
export function extractMentionedBlockTypes(message) {
|
|
95
|
+
const m = normalizeForIntent(stripSiteContextEnvelope(message));
|
|
96
|
+
const found = [];
|
|
97
|
+
for (const entry of BLOCK_TYPE_KEYWORDS) {
|
|
98
|
+
const match = m.match(entry.pattern);
|
|
99
|
+
if (match && match.index !== undefined) {
|
|
100
|
+
// Avoid double-counting "card" when "cardgrid" already matched at the same position
|
|
101
|
+
if (entry.key === "card" && found.some((f) => f.key === "cardgrid" && Math.abs(f.index - match.index) <= 4))
|
|
102
|
+
continue;
|
|
103
|
+
found.push({ key: entry.key, index: match.index });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Return in order of appearance in the message
|
|
107
|
+
found.sort((a, b) => a.index - b.index);
|
|
108
|
+
return found.map((f) => KEYWORD_TO_BLOCK_TYPE[f.key]);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Returns true when the message is "add X in/to [the Y] heading/subheading/copy/..."
|
|
112
|
+
* — a field-content update phrased with "add", NOT a batch block add.
|
|
113
|
+
* Guards the loose fallback below from false positives when field-targeting
|
|
114
|
+
* prepositions are combined with block-type keyword aliases like "numbers"
|
|
115
|
+
* (Stats) or "copy" (RichText).
|
|
116
|
+
*/
|
|
117
|
+
export function isFieldContentUpdateRequest(message) {
|
|
118
|
+
return FIELD_CONTENT_UPDATE_PATTERN.test(stripSiteContextEnvelope(message));
|
|
119
|
+
}
|
|
120
|
+
export function isBatchAddRequest(message) {
|
|
121
|
+
const m = normalizeForIntent(stripSiteContextEnvelope(message));
|
|
122
|
+
if (BATCH_ADD_PATTERNS.some((re) => re.test(m)) || BATCH_PAGE_CREATE_PATTERNS.some((re) => re.test(m)))
|
|
123
|
+
return true;
|
|
124
|
+
if (BATCH_UPDATE_PATTERNS.some((re) => re.test(m)))
|
|
125
|
+
return true;
|
|
126
|
+
if (COUNTED_MULTI_BLOCK_ADD_PATTERN.test(m))
|
|
127
|
+
return true;
|
|
128
|
+
if (EACH_BLOCK_TYPE_PATTERN.test(m))
|
|
129
|
+
return true;
|
|
130
|
+
// "add X in/to Y heading/subheading/copy/..." is a field update, not a batch add.
|
|
131
|
+
// The loose fallback below would otherwise fire on messages where block-type
|
|
132
|
+
// keywords like "numbers" (Stats) or "copy" (RichText) happen to appear.
|
|
133
|
+
if (isFieldContentUpdateRequest(message))
|
|
134
|
+
return false;
|
|
135
|
+
// Drop the trailing positional anchor so "add a testimonials section below the
|
|
136
|
+
// hero" counts one type (Testimonials), not two — otherwise the anchor's type
|
|
137
|
+
// (Hero) inflates the count and the request is misread as a multi-block batch
|
|
138
|
+
// that adds a phantom duplicate of the anchor (#18).
|
|
139
|
+
const body = stripPositionalAnchorPhrase(m);
|
|
140
|
+
const mentionedBlockTypes = countMentionedBlockTypes(body);
|
|
141
|
+
const hasListSeparator = /,|\band\b|&|\bplus\b/.test(body);
|
|
142
|
+
const refersToBlocks = /\b(?:blocks?|components?|sections?|elements?|widgets?)\b/.test(body);
|
|
143
|
+
return ADD_ACTION_PATTERN.test(m) && mentionedBlockTypes >= 2 && (hasListSeparator || refersToBlocks);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Detects batch remove requests — any remove/delete/clear that implies
|
|
147
|
+
* multiple blocks should be affected. Broadly matches so the LLM can
|
|
148
|
+
* handle the nuance (typos, "this one", etc.) without strict single-op limits.
|
|
149
|
+
*/
|
|
150
|
+
export function isPageWideRewriteRequest(message) {
|
|
151
|
+
const m = normalizeForIntent(stripSiteContextEnvelope(message));
|
|
152
|
+
return PAGE_WIDE_REWRITE_PATTERNS.some((re) => re.test(m));
|
|
153
|
+
}
|
|
154
|
+
export function isBatchReorderRequest(message) {
|
|
155
|
+
const m = normalizeForIntent(stripSiteContextEnvelope(message));
|
|
156
|
+
return BATCH_REORDER_PATTERNS.some((re) => re.test(m));
|
|
157
|
+
}
|
|
158
|
+
export function isBatchRemoveRequest(message) {
|
|
159
|
+
const m = normalizeForIntent(stripSiteContextEnvelope(message));
|
|
160
|
+
const hasRemoveAction = /\b(?:remove|delete|clear)\b/.test(m);
|
|
161
|
+
if (!hasRemoveAction)
|
|
162
|
+
return false;
|
|
163
|
+
// Any "all/every/everything" + remove → batch
|
|
164
|
+
if (/\b(?:all|every|everything)\b/.test(m))
|
|
165
|
+
return true;
|
|
166
|
+
// "delete every section", "remove all blocks"
|
|
167
|
+
if (new RegExp(String.raw `\b(?:all|every)\s+${UNIT}\b`).test(m))
|
|
168
|
+
return true;
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Detects duplicate/copy/clone requests targeting a block on the current
|
|
173
|
+
* page. The fast LLM intent router's schema has no "duplicate" action
|
|
174
|
+
* (only add|move|update|remove|info|clarify), so these requests must
|
|
175
|
+
* bypass the router and go straight to the full planner, which supports
|
|
176
|
+
* the `duplicate_block` op.
|
|
177
|
+
*/
|
|
178
|
+
export function isDuplicateBlockRequest(message) {
|
|
179
|
+
const m = normalizeForIntent(stripSiteContextEnvelope(message));
|
|
180
|
+
// "duplicate X", "clone X", "copy X", "make a copy of X" — where X is any
|
|
181
|
+
// block reference. Guard against "copy" matching unrelated phrases like
|
|
182
|
+
// "the copy" (RichText body) by requiring a verb-y context.
|
|
183
|
+
if (/\b(?:duplicate|clone)\b/.test(m))
|
|
184
|
+
return true;
|
|
185
|
+
if (/\bmake\s+(?:a|another)\s+copy\b/.test(m))
|
|
186
|
+
return true;
|
|
187
|
+
// "copy the <block>", "copy this block" — verb use
|
|
188
|
+
if (/\bcopy\s+(?:this|that|the|a|an|another)\s+/.test(m))
|
|
189
|
+
return true;
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Detects "duplicate this page and modify the content" patterns — duplicate /
|
|
194
|
+
* clone / copy a page combined with a modification verb (populate, fill,
|
|
195
|
+
* suggest, update, modify, change, adjust, replace, rewrite). Used to bypass
|
|
196
|
+
* `chatStrictPrimaryOpMode` so the planner can emit `duplicate_page` plus
|
|
197
|
+
* follow-on `update_props` / `add_block` / `remove_block` ops in one plan.
|
|
198
|
+
*
|
|
199
|
+
* Without this, strict mode truncates duplicate-and-modify plans to just the
|
|
200
|
+
* `duplicate_page` op, leaving the user with an unmodified clone.
|
|
201
|
+
*/
|
|
202
|
+
export function isDuplicateAndModifyRequest(message) {
|
|
203
|
+
const m = normalizeForIntent(stripSiteContextEnvelope(message));
|
|
204
|
+
// Must mention a duplicate verb in a page context (not a block-level dup).
|
|
205
|
+
const duplicateVerb = /\b(?:duplicate|clone|copy)\b/.test(m);
|
|
206
|
+
if (!duplicateVerb)
|
|
207
|
+
return false;
|
|
208
|
+
// Page context: explicit "page", "this", "current", "selected", or a route mention.
|
|
209
|
+
const pageContext = /\b(?:page|this|current|selected)\b/.test(m) || /\/[a-z0-9/_-]+/i.test(m);
|
|
210
|
+
if (!pageContext)
|
|
211
|
+
return false;
|
|
212
|
+
// Modification verbs that imply additional ops beyond the bare duplicate.
|
|
213
|
+
const modifyVerb = /\b(?:populate|fill|suggest|propose|recommend|generate|write|draft|describe)\b/.test(m) ||
|
|
214
|
+
/\b(?:modify|adjust|tweak|customi[sz]e|tailor|adapt|repurpose|reskin|rebrand|refresh|refocus)\b/.test(m) ||
|
|
215
|
+
/\b(?:update|change|replace|rewrite|swap|edit|repopulate)\b/.test(m) ||
|
|
216
|
+
/\b(?:and\s+(?:then\s+)?)?(?:make|turn|convert|transform)\s+it\s+(?:into|about|for)\b/.test(m);
|
|
217
|
+
return modifyVerb;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Detects "show me a plan first" / "plan it before doing" phrasing — the user
|
|
221
|
+
* wants the planner to assemble ops but hold them behind the approval gate
|
|
222
|
+
* instead of auto-applying. Used by the chat pipeline to override
|
|
223
|
+
* `executionMode` to `plan_only` when the message asks for review-before-apply.
|
|
224
|
+
*/
|
|
225
|
+
export function requestsPlanFirst(message) {
|
|
226
|
+
const m = normalizeForIntent(stripSiteContextEnvelope(message));
|
|
227
|
+
// "make a plan first", "plan it first", "draft a plan", "show me a plan",
|
|
228
|
+
// "propose a plan", "plan before applying", "plan before doing", "plan it before"
|
|
229
|
+
if (/\b(?:make|draft|propose|prepare|create|show)\s+(?:me\s+)?(?:a|the)?\s*plan\b/.test(m))
|
|
230
|
+
return true;
|
|
231
|
+
if (/\bplan\s+(?:it|this|first|before)\b/.test(m))
|
|
232
|
+
return true;
|
|
233
|
+
if (/\bbefore\s+(?:doing|applying|making|executing|building)\b.*\bplan\b/.test(m))
|
|
234
|
+
return true;
|
|
235
|
+
if (/\bplan\b.*\bbefore\s+(?:doing|applying|making|executing|building)\b/.test(m))
|
|
236
|
+
return true;
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
// "what blocks/sections are on this page?", "list the blocks on this page",
|
|
240
|
+
// "what's on this page?" — a read-only inventory of the CURRENT page's blocks.
|
|
241
|
+
// Distinct from the block CATALOG ("what blocks can I add?") and must never be
|
|
242
|
+
// planned as an edit (issue #20). The "(this|the|current) page" / "on this page"
|
|
243
|
+
// scope is what separates it from the catalog query.
|
|
244
|
+
// An edit instruction riding along with an inventory phrase — a mutation verb, a
|
|
245
|
+
// comparative ("bigger"), or a follow-on clause ("… then delete the last one", "…
|
|
246
|
+
// and rewrite them"). The loose `(list|show|describe) … sections … page` branch
|
|
247
|
+
// would otherwise swallow "show the hero section on this page bigger" as a
|
|
248
|
+
// read-only answer and silently drop the edit (#20 over-match). A pure inventory
|
|
249
|
+
// query carries none of these. Bias toward NOT classifying as info on a hit — the
|
|
250
|
+
// normal planner can still answer, but it won't drop an edit.
|
|
251
|
+
const PAGE_INVENTORY_EDIT_CUE = /\b(?:change|edit|update|rewrite|reword|rephrase|replace|swap|delete|remove|add|insert|move|reorder|rearrange|shorten|lengthen|expand|collapse|hide|reformat|restyle|then|instead)\b|\b(?:bigger|smaller|larger|shorter|longer|bolder|taller|wider|narrower)\b/;
|
|
252
|
+
export function isPageBlockListQuery(message) {
|
|
253
|
+
const m = normalizeForIntent(message);
|
|
254
|
+
if (PAGE_INVENTORY_EDIT_CUE.test(m))
|
|
255
|
+
return false;
|
|
256
|
+
return (/\b(?:what|which)\s+(?:blocks?|sections?|elements?)\s+(?:are|is|does|do|exist|live|sit)\b[^?]*\b(?:this|the|current)\s+page\b/.test(m) ||
|
|
257
|
+
/\b(?:list|show|tell me|describe|give me)\b[^?]*\b(?:blocks?|sections?|elements?)\b[^?]*\b(?:this|the|current)\s+page\b/.test(m) ||
|
|
258
|
+
/\bwhat(?:'s| is| are)\b[^?]*\bon\s+(?:this|the|current)\s+page\b/.test(m) ||
|
|
259
|
+
/\b(?:this|the|current)\s+page\b[^?]*\b(?:blocks?|sections?)\b[^?]*\b(?:have|has|contains?|includes?)\b/.test(m));
|
|
260
|
+
}
|
|
261
|
+
export function isInfoQuery(message) {
|
|
262
|
+
const m = normalizeForIntent(message);
|
|
263
|
+
return (BLOCK_CATALOG_PATTERNS.some((re) => re.test(m)) ||
|
|
264
|
+
isPageBlockListQuery(message) ||
|
|
265
|
+
/\bwhat\s+can\s+i\s+(change|edit)\b/.test(m) ||
|
|
266
|
+
/\bwhat\s+content\b/.test(m) ||
|
|
267
|
+
/\bcontent\s+elements?\b/.test(m) ||
|
|
268
|
+
/\b(which|what)\s+fields?\b/.test(m) ||
|
|
269
|
+
/\bwhat\s+prop(ertie)?s?\b/.test(m) ||
|
|
270
|
+
/\b(?:what|which)\s+(?:tabs?|items?|cards?|features?|links?|images?|pages?|columns?)\s+(?:do|does|can|are)\s+(?:we|i|you|it|this)\s+have\b/.test(m));
|
|
271
|
+
}
|
|
272
|
+
export function isPageListQuery(message) {
|
|
273
|
+
const m = normalizeForIntent(message);
|
|
274
|
+
// Exclude messages that contain page creation/modification verbs — those are action
|
|
275
|
+
// requests that happen to mention "the pages"/"all pages", not listing queries.
|
|
276
|
+
// (A bare "…on all pages" otherwise matches PAGE_LIST_PATTERNS and hijacks a cross-page
|
|
277
|
+
// edit into a read-only page list — see planner-quality issue #17A.)
|
|
278
|
+
if (/\b(?:add|create|generate|build|make|draft|update|link|remove|delete|change|set|replace|edit|rewrite|rename|move|swap|adjust|reorder|reorganize|restructure|translate)\b.*\bpages?\b/.test(m))
|
|
279
|
+
return false;
|
|
280
|
+
return PAGE_LIST_PATTERNS.some((re) => re.test(m));
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* True when the message asks for a mutation that spans the whole site rather
|
|
284
|
+
* than the current page ("update the CTA on all pages", "make the footer
|
|
285
|
+
* site-wide", "change X everywhere"). The single-page deterministic path can't
|
|
286
|
+
* honor this — it would silently narrow the edit to the current page — so the
|
|
287
|
+
* gate defers these to the LLM, whose ops carry pageSlug and can fan out across
|
|
288
|
+
* pages or clarify. See planner-quality issue #29.
|
|
289
|
+
*/
|
|
290
|
+
export function hasCrossPageScope(message) {
|
|
291
|
+
return CROSS_PAGE_SCOPE_PATTERN.test(normalizeForIntent(stripSiteContextEnvelope(message)));
|
|
292
|
+
}
|
|
293
|
+
export function isContentQuery(message) {
|
|
294
|
+
const m = normalizeForIntent(message);
|
|
295
|
+
// Exclude messages with clear edit/mutation verbs
|
|
296
|
+
if (/\b(?:add|create|remove|delete|update|change|replace|move|rewrite|translate|rename)\b/.test(m))
|
|
297
|
+
return false;
|
|
298
|
+
return CONTENT_QUERY_PATTERNS.some((re) => re.test(m));
|
|
299
|
+
}
|
|
300
|
+
export function isAdviceQuery(message) {
|
|
301
|
+
const m = normalizeForIntent(message);
|
|
302
|
+
// Exclude structural action requests that happen to contain "should we/I"
|
|
303
|
+
if (/\b(reorder|reorganize|restructure|rearrange|sort|move)\b/.test(m) && /\bpages?\b/.test(m))
|
|
304
|
+
return false;
|
|
305
|
+
// Exclude explicit edit verbs — "review copy for clarity" is an edit, not advice
|
|
306
|
+
// But allow "should we/I add X?" phrasing — those are advice, not commands
|
|
307
|
+
if (!/\bshould\s+(we|i)\b/.test(m) && /\b(?:add|create|remove|delete|update|change|replace|move|rewrite|translate|rename)\b/.test(m))
|
|
308
|
+
return false;
|
|
309
|
+
return (/\b(is it good|is this good|should (we|i)|do you recommend|would you recommend)\b/.test(m) ||
|
|
310
|
+
/\bwhat do you think\b/.test(m) ||
|
|
311
|
+
/\bwhat (can|should) be improved\b/.test(m) ||
|
|
312
|
+
/\bhow can (this|the) page be improved\b/.test(m) ||
|
|
313
|
+
/\bhow (can|should) i improve (this|the) page\b/.test(m) ||
|
|
314
|
+
/\bimprovements?\b/.test(m) ||
|
|
315
|
+
/\bis faq\b/.test(m) ||
|
|
316
|
+
/\bshould .*faq\b/.test(m) ||
|
|
317
|
+
/\bgood idea\b/.test(m) ||
|
|
318
|
+
/\b(?:audit|review|check|inspect|analyze)\s+(?:the\s+)?(?:this\s+)?(?:page|site|content|copy|text)\b/.test(m));
|
|
319
|
+
}
|
|
320
|
+
export function adviceResponse(args) {
|
|
321
|
+
const { body, current, plannerSource, modelUsed, modelKey } = args;
|
|
322
|
+
const message = (body.message ?? "").toLowerCase();
|
|
323
|
+
const pageLabel = current.slug === "/" ? "this home page" : `this page (${current.slug})`;
|
|
324
|
+
const hasFaq = current.blocks.some((block) => block.type === "FAQAccordion");
|
|
325
|
+
const hasHero = current.blocks.some((block) => block.type === "Hero");
|
|
326
|
+
const hasCta = current.blocks.some((block) => block.type === "CTA");
|
|
327
|
+
if (/\bfaq\b/.test(message)) {
|
|
328
|
+
const summary = hasFaq
|
|
329
|
+
? `Yes, FAQ can work on ${pageLabel}, but keep it concise and near the bottom so it supports decisions without distracting from the main content.`
|
|
330
|
+
: `FAQ is usually a good fit on ${pageLabel} when visitors may have objections (pricing, process, trust, support).`;
|
|
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)
|
|
392
|
+
continue;
|
|
393
|
+
if (block.type === "Hero") {
|
|
394
|
+
const heading = (p.heading ?? p.title ?? "");
|
|
395
|
+
if (!p.imageUrl && !p.backgroundImage) {
|
|
396
|
+
suggestions.push("Add a hero image");
|
|
397
|
+
}
|
|
398
|
+
else if (heading && heading.length > 60) {
|
|
399
|
+
suggestions.push("Shorten the hero headline to under 60 characters");
|
|
400
|
+
}
|
|
401
|
+
else if (heading) {
|
|
402
|
+
suggestions.push("Rewrite the hero headline to be more action-oriented");
|
|
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");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (block.type === "CTA") {
|
|
412
|
+
const btn = (p.buttonText ?? p.ctaText ?? "");
|
|
413
|
+
if (btn.length > 20) {
|
|
414
|
+
suggestions.push("Shorten the CTA button text");
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
suggestions.push("Strengthen the CTA copy to create urgency");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// Fallback if no content-specific suggestions were derived
|
|
422
|
+
if (suggestions.length === 0) {
|
|
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
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const summary = `For ${pageLabel} with ${current.blocks.length} block${current.blocks.length === 1 ? "" : "s"}: ${missingTypes.length > 0 ? `consider adding ${missingTypes.slice(0, 3).join(", ")}` : "all major sections are covered — focus on refining content"}.`;
|
|
431
|
+
return {
|
|
432
|
+
code: 200,
|
|
433
|
+
payload: {
|
|
434
|
+
status: "advice",
|
|
435
|
+
summary,
|
|
436
|
+
changes,
|
|
437
|
+
suggestions,
|
|
438
|
+
mentionedSlugs: [current.slug],
|
|
439
|
+
previewVersion: versions.get(body.session ?? "dev") ?? 0,
|
|
440
|
+
plannerSource,
|
|
441
|
+
modelUsed,
|
|
442
|
+
modelKey
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
// A pending clarification only contextualizes the answer that immediately follows it.
|
|
447
|
+
// One left unanswered for longer than this is stale (a prior session's leftover, or the
|
|
448
|
+
// user simply moved on) and must not be allowed to compose onto a later, unrelated
|
|
449
|
+
// message (#26).
|
|
450
|
+
const PENDING_CLARIFICATION_TTL_MS = 10 * 60 * 1000;
|
|
451
|
+
export function plannerMessageWithPendingContext(session, message) {
|
|
452
|
+
const pending = pendingClarificationBySession.get(session);
|
|
453
|
+
if (!pending)
|
|
454
|
+
return message;
|
|
455
|
+
const age = Date.now() - new Date(pending.updatedAt).getTime();
|
|
456
|
+
if (!Number.isFinite(age) || age > PENDING_CLARIFICATION_TTL_MS) {
|
|
457
|
+
pendingClarificationBySession.delete(session);
|
|
458
|
+
return message;
|
|
459
|
+
}
|
|
460
|
+
if (isStandalonePageOperation(message))
|
|
461
|
+
return message;
|
|
462
|
+
if (!isLikelyClarificationFollowUp(message))
|
|
463
|
+
return message;
|
|
464
|
+
return `${pending.baseRequest}\nClarification from user: ${message}`;
|
|
465
|
+
}
|
|
466
|
+
function parseJsonObjectMaybe(value) {
|
|
467
|
+
if (!value)
|
|
468
|
+
return null;
|
|
469
|
+
if (typeof value === "string") {
|
|
470
|
+
try {
|
|
471
|
+
const parsed = JSON.parse(value);
|
|
472
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (typeof value === "object")
|
|
479
|
+
return value;
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
function normalizeConstraintList(value) {
|
|
483
|
+
if (Array.isArray(value)) {
|
|
484
|
+
return value.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
485
|
+
}
|
|
486
|
+
if (typeof value === "string") {
|
|
487
|
+
return value
|
|
488
|
+
.split(/[,\n]/g)
|
|
489
|
+
.map((item) => item.trim())
|
|
490
|
+
.filter(Boolean);
|
|
491
|
+
}
|
|
492
|
+
return [];
|
|
493
|
+
}
|
|
494
|
+
/** Build the site context lines without wrapping in a message. Returns null if empty. */
|
|
495
|
+
export function buildSiteContextBlock(args) {
|
|
496
|
+
const businessContext = parseJsonObjectMaybe(args?.businessContext);
|
|
497
|
+
const siteContext = parseJsonObjectMaybe(args?.siteContext);
|
|
498
|
+
const purpose = (typeof siteContext?.purpose === "string" ? siteContext.purpose.trim() : "") ||
|
|
499
|
+
(typeof businessContext?.purpose === "string" ? businessContext.purpose.trim() : "") ||
|
|
500
|
+
(typeof args?.sitePurpose === "string" ? args.sitePurpose.trim() : "");
|
|
501
|
+
const tone = (typeof siteContext?.tone === "string" ? siteContext.tone.trim() : "") ||
|
|
502
|
+
(typeof businessContext?.tone === "string" ? businessContext.tone.trim() : "");
|
|
503
|
+
const constraints = [
|
|
504
|
+
...normalizeConstraintList(siteContext?.constraints),
|
|
505
|
+
...normalizeConstraintList(businessContext?.constraints)
|
|
506
|
+
];
|
|
507
|
+
const siteName = typeof siteContext?.siteName === "string" ? siteContext.siteName.trim() : "";
|
|
508
|
+
const pageTemplates = Array.isArray(siteContext?.pageTemplates)
|
|
509
|
+
? siteContext.pageTemplates
|
|
510
|
+
.filter((t) => typeof t?.name === "string" && typeof t?.description === "string" && t.name.trim() && t.description.trim())
|
|
511
|
+
.map((t) => `- ${t.name.trim()}: ${t.description.trim()}`)
|
|
512
|
+
: [];
|
|
513
|
+
const lines = [
|
|
514
|
+
purpose ? `Site purpose: ${purpose}` : null,
|
|
515
|
+
tone ? `Preferred tone: ${tone}` : null,
|
|
516
|
+
constraints.length > 0 ? `Constraints: ${constraints.join("; ")}` : null,
|
|
517
|
+
siteName ? `Site name: ${siteName}` : null,
|
|
518
|
+
pageTemplates.length > 0 ? `Page templates:\n${pageTemplates.join("\n")}` : null,
|
|
519
|
+
args?.pageDirectory ? `Pages:\n${args.pageDirectory}` : null
|
|
520
|
+
].filter((line) => Boolean(line));
|
|
521
|
+
if (lines.length === 0)
|
|
522
|
+
return null;
|
|
523
|
+
return lines.join("\n");
|
|
524
|
+
}
|
|
525
|
+
export function withSiteContext(message, args) {
|
|
526
|
+
const block = buildSiteContextBlock(args);
|
|
527
|
+
if (!block)
|
|
528
|
+
return message;
|
|
529
|
+
return `${message}\n\n[site context]\n${block}\n[/site context]`;
|
|
530
|
+
}
|
|
531
|
+
export function stripSiteContextEnvelope(message) {
|
|
532
|
+
return message.replace(/\n?\[site context\][\s\S]*?\[\/site context\]\s*$/i, "").trim();
|
|
533
|
+
}
|
|
534
|
+
export function extractSiteContextLineValue(message, label) {
|
|
535
|
+
const lowerLabel = label.toLowerCase();
|
|
536
|
+
const section = message.match(/\[site context\]([\s\S]*?)\[\/site context\]/i)?.[1];
|
|
537
|
+
if (!section)
|
|
538
|
+
return undefined;
|
|
539
|
+
const line = section
|
|
540
|
+
.split("\n")
|
|
541
|
+
.map((item) => item.trim())
|
|
542
|
+
.find((item) => item.toLowerCase().startsWith(`${lowerLabel}:`));
|
|
543
|
+
if (!line)
|
|
544
|
+
return undefined;
|
|
545
|
+
const value = line.slice(line.indexOf(":") + 1).trim();
|
|
546
|
+
return value || undefined;
|
|
547
|
+
}
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// Block prop helpers used by infoResponse
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
function editablePropsFromBlock(block) {
|
|
552
|
+
if (!block || !block.props || typeof block.props !== "object")
|
|
553
|
+
return [];
|
|
554
|
+
return Object.keys(block.props);
|
|
555
|
+
}
|
|
556
|
+
// A short headline for a block, for the "what's on this page?" inventory. Tries the
|
|
557
|
+
// common title-bearing props in priority order and truncates so the listing stays
|
|
558
|
+
// scannable.
|
|
559
|
+
function blockHeadingSnippet(block) {
|
|
560
|
+
const props = (block?.props ?? {});
|
|
561
|
+
for (const key of ["heading", "title", "headline", "eyebrow", "label", "quote", "text"]) {
|
|
562
|
+
const value = props[key];
|
|
563
|
+
if (typeof value === "string" && value.trim()) {
|
|
564
|
+
const text = value.trim().replace(/\s+/g, " ");
|
|
565
|
+
return text.length > 48 ? `${text.slice(0, 47).trimEnd()}…` : text;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return "";
|
|
569
|
+
}
|
|
570
|
+
function promptFromPropKey(propKey, blockType) {
|
|
571
|
+
const labels = {
|
|
572
|
+
heading: "Change heading to \"...\"",
|
|
573
|
+
subheading: "Change subheading to \"...\"",
|
|
574
|
+
ctaText: "Change CTA text to \"...\"",
|
|
575
|
+
ctaHref: "Change CTA link to \"/...\"",
|
|
576
|
+
imageUrl: "Update hero image (e.g. from Unsplash: cherries)",
|
|
577
|
+
imageAlt: "Change image alt text to \"...\"",
|
|
578
|
+
secondaryCtaText: "Add secondary CTA button \"...\"",
|
|
579
|
+
secondaryCtaHref: "Change secondary CTA link to \"/...\"",
|
|
580
|
+
body: "Edit body text to \"...\"",
|
|
581
|
+
title: "Change title to \"...\"",
|
|
582
|
+
description: "Change description to \"...\"",
|
|
583
|
+
features: "Update feature list",
|
|
584
|
+
items: "Update items",
|
|
585
|
+
cards: "Update cards"
|
|
586
|
+
};
|
|
587
|
+
if (labels[propKey])
|
|
588
|
+
return labels[propKey];
|
|
589
|
+
const human = blockType ? getPropDisplayName(blockType, propKey) : propKey;
|
|
590
|
+
return human === propKey ? `Change ${propKey} to "..."` : `Edit ${human.toLowerCase()}`;
|
|
591
|
+
}
|
|
592
|
+
const ORDINALS = ["first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth"];
|
|
593
|
+
function humanizeArrayPath(root) {
|
|
594
|
+
const match = root.match(/^([a-zA-Z_]+)\[(\d+)\]$/);
|
|
595
|
+
if (!match)
|
|
596
|
+
return root;
|
|
597
|
+
const [, listName, indexStr] = match;
|
|
598
|
+
const index = Number(indexStr);
|
|
599
|
+
const ordinal = ORDINALS[index] ?? `#${index + 1}`;
|
|
600
|
+
const singular = {
|
|
601
|
+
cards: "card",
|
|
602
|
+
features: "feature",
|
|
603
|
+
items: "item",
|
|
604
|
+
stats: "stat",
|
|
605
|
+
columns: "column"
|
|
606
|
+
};
|
|
607
|
+
const noun = singular[listName] ?? listName.replace(/s$/, "");
|
|
608
|
+
return `the ${ordinal} ${noun}`;
|
|
609
|
+
}
|
|
610
|
+
function childSuggestions(args) {
|
|
611
|
+
const { selected, editablePath } = args;
|
|
612
|
+
const path = editablePath.trim();
|
|
613
|
+
if (!path)
|
|
614
|
+
return [];
|
|
615
|
+
const root = path.split(".")[0] ?? path;
|
|
616
|
+
const human = humanizeArrayPath(root);
|
|
617
|
+
if (selected.type === "CardGrid" && root.startsWith("cards[")) {
|
|
618
|
+
return [
|
|
619
|
+
`Update ${human}'s title to \"...\"`,
|
|
620
|
+
`Update ${human}'s description to \"...\"`,
|
|
621
|
+
`Update ${human}'s CTA text to \"...\"`,
|
|
622
|
+
`Update ${human}'s CTA link to \"/...\"`
|
|
623
|
+
];
|
|
624
|
+
}
|
|
625
|
+
if (selected.type === "FeatureGrid" && root.startsWith("features[")) {
|
|
626
|
+
return [`Update ${human}'s title to \"...\"`, `Update ${human}'s description to \"...\"`];
|
|
627
|
+
}
|
|
628
|
+
if (selected.type === "Testimonials" && root.startsWith("items[")) {
|
|
629
|
+
return [`Update ${human}'s quote to \"...\"`, `Update ${human}'s author to \"...\"`];
|
|
630
|
+
}
|
|
631
|
+
if (selected.type === "FAQAccordion" && root.startsWith("items[")) {
|
|
632
|
+
return [`Update ${human}'s question to \"...\"`, `Update ${human}'s answer to \"...\"`];
|
|
633
|
+
}
|
|
634
|
+
return [`Update ${human} ...`];
|
|
635
|
+
}
|
|
636
|
+
export function infoResponse(args) {
|
|
637
|
+
const { body, current, plannerSource, modelUsed, modelKey } = args;
|
|
638
|
+
// "what blocks are on this page?" — inventory the CURRENT page deterministically.
|
|
639
|
+
// Checked before the catalog branch so "list the blocks on this page" reports the
|
|
640
|
+
// page's blocks (not the available types) and always returns a non-empty answer (#20).
|
|
641
|
+
if (isPageBlockListQuery(body.message ?? "")) {
|
|
642
|
+
const blocks = current.blocks;
|
|
643
|
+
const pageName = current.title?.trim() || current.slug;
|
|
644
|
+
const lines = blocks.map((b, i) => {
|
|
645
|
+
const label = getBlockMeta(b.type)?.displayName ?? b.type;
|
|
646
|
+
const snippet = blockHeadingSnippet(b);
|
|
647
|
+
return snippet ? `${i + 1}. ${label} — “${snippet}”` : `${i + 1}. ${label}`;
|
|
648
|
+
});
|
|
649
|
+
return {
|
|
650
|
+
code: 200,
|
|
651
|
+
payload: {
|
|
652
|
+
status: "info",
|
|
653
|
+
summary: blocks.length === 0
|
|
654
|
+
? `The ${pageName} page has no blocks yet.`
|
|
655
|
+
: `The ${pageName} page has ${blocks.length} ${blocks.length === 1 ? "block" : "blocks"} in this order:`,
|
|
656
|
+
changes: lines,
|
|
657
|
+
previewVersion: versions.get(body.session ?? "dev") ?? 0,
|
|
658
|
+
plannerSource,
|
|
659
|
+
modelUsed,
|
|
660
|
+
modelKey
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
if (isBlockCatalogQuery(body.message ?? "")) {
|
|
665
|
+
return {
|
|
666
|
+
code: 200,
|
|
667
|
+
payload: {
|
|
668
|
+
status: "info",
|
|
669
|
+
summary: `You can add these block types: ${allowedBlockTypes.join(", ")}.`,
|
|
670
|
+
changes: ["Tip: specify position, e.g. \u201cadd Testimonials below Hero\u201d."],
|
|
671
|
+
suggestions: [
|
|
672
|
+
"Add Testimonials below Hero",
|
|
673
|
+
"Add CardGrid at the end",
|
|
674
|
+
"Add FeatureGrid after Hero",
|
|
675
|
+
"Add FAQAccordion before CTA",
|
|
676
|
+
"Add CTA at the end"
|
|
677
|
+
],
|
|
678
|
+
previewVersion: versions.get(body.session ?? "dev") ?? 0,
|
|
679
|
+
plannerSource,
|
|
680
|
+
modelUsed,
|
|
681
|
+
modelKey
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const selected = body.activeBlockId && current.blocks.find((b) => b.id === body.activeBlockId)
|
|
686
|
+
? current.blocks.find((b) => b.id === body.activeBlockId)
|
|
687
|
+
: null;
|
|
688
|
+
if (selected) {
|
|
689
|
+
const keys = editablePropsFromBlock(selected);
|
|
690
|
+
const childPath = String(body.activeEditablePath ?? "");
|
|
691
|
+
const suggestions = childPath ? childSuggestions({ selected, editablePath: childPath }) : keys.slice(0, 4).map((k) => promptFromPropKey(k, selected.type));
|
|
692
|
+
const humanChild = childPath ? humanizeArrayPath(childPath.split(".")[0] ?? childPath) : "";
|
|
693
|
+
const summary = childPath
|
|
694
|
+
? `Focused on ${humanChild} in ${selected.type}.`
|
|
695
|
+
: `You can edit ${selected.type} fields: ${keys.join(", ")}.`;
|
|
696
|
+
return {
|
|
697
|
+
code: 200,
|
|
698
|
+
payload: {
|
|
699
|
+
status: "info",
|
|
700
|
+
summary,
|
|
701
|
+
changes: childPath
|
|
702
|
+
? [`Selected block: ${selected.id}`, `Focused path: ${childPath}`]
|
|
703
|
+
: [`Selected block: ${selected.id}`, "Tip: click a field name in your prompt, e.g. \u201cchange heading to \u2026\u201d."],
|
|
704
|
+
suggestions,
|
|
705
|
+
previewVersion: versions.get(body.session ?? "dev") ?? 0,
|
|
706
|
+
plannerSource,
|
|
707
|
+
modelUsed,
|
|
708
|
+
modelKey
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
const firstByType = new Map();
|
|
713
|
+
for (const block of current.blocks) {
|
|
714
|
+
if (!firstByType.has(block.type))
|
|
715
|
+
firstByType.set(block.type, block);
|
|
716
|
+
}
|
|
717
|
+
const byType = Array.from(firstByType.values()).map((b) => `${b.type}: ${editablePropsFromBlock(b).join(", ")}`);
|
|
718
|
+
return {
|
|
719
|
+
code: 200,
|
|
720
|
+
payload: {
|
|
721
|
+
status: "info",
|
|
722
|
+
summary: "Select a block to get precise editable fields. Current page supports:",
|
|
723
|
+
changes: byType,
|
|
724
|
+
previewVersion: versions.get(body.session ?? "dev") ?? 0,
|
|
725
|
+
plannerSource,
|
|
726
|
+
modelUsed,
|
|
727
|
+
modelKey
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
}
|