@avocadostudio-ai/orchestrator-core 0.13.1 → 0.14.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.
@@ -4,12 +4,13 @@ import { blockManifestSchema } from "@avocadostudio-ai/shared";
4
4
  import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, isVariationRequestMessage, variationVerbIntent, resolveEffectiveSlug, throwIfCanceled, raceCancel, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
5
5
  import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
6
6
  import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
7
- import { versions, pendingClarificationBySession, chatHistoryBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
7
+ import { versions, pendingClarificationBySession, chatHistoryBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage, getSuggestionMemory, recordSuggestionsShown, recordSuggestionClicked } from "../state/session-state.js";
8
8
  import { getSiteAssets } from "../state/site-assets.js";
9
9
  import { loadPendingPlan, savePendingPlan, clearPendingPlan } from "../durable/pending-plan-store.js";
10
10
  import { envFlag } from "../env-flags.js";
11
11
  import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isRepairEligibleCategory, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
12
12
  import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
13
+ import { buildSuggestions, toLegacySuggestions } from "../nlp/suggestion-engine.js";
13
14
  import { clarificationSuggestions, postEditSuggestions, keepDemoExecutable, demoPlanFromMessage, withKeylessNotice, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
14
15
  import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
15
16
  import { isDemoModeEnabled, splitDemoOps, getDemoAllowedBlockTypes } from "../demo-mode.js";
@@ -186,17 +187,49 @@ export function setGeneratePlanWithAnthropicForTests(fn) {
186
187
  generatePlanWithAnthropicImpl = fn ?? generatePlanWithAnthropic;
187
188
  }
188
189
  /**
189
- * Suggestions, narrowed to what will answer when there is no key.
190
+ * Rank, validate and record the pills for one turn.
190
191
  *
191
- * A no-op the moment any provider is configured — with a model behind the chat,
192
- * every phrasing these generators produce is answerable, which is what they
193
- * were written against. Without one the answer comes from a substring matcher
194
- * that knows a handful of literal phrases, and the rest are dead buttons.
192
+ * Replaces `usableSuggestions`, which did one job — drop the chips a keyless
193
+ * demo planner cannot answer — and did it only on the keyless path. That left
194
+ * the strongest instrument in the file pointed at the path that matters least:
195
+ * the demo chips were verified executable, and the keyed chips almost every
196
+ * real user sees went to the wire exactly as the model wrote them.
197
+ *
198
+ * Now every candidate — model, page gap, refinement, structure, site context —
199
+ * passes the same validator and competes on the same scale, the keyless
200
+ * executability filter still runs last on the path that needs it, and what was
201
+ * shown is recorded so the next turn can stop offering it.
195
202
  */
196
- function usableSuggestions(suggestions, source, slug, body) {
197
- if (source !== "demo")
198
- return suggestions;
199
- return keepDemoExecutable(suggestions, slug, body.activeBlockId, body.activeBlockType);
203
+ function resolveSuggestions(args) {
204
+ const memory = args.session ? getSuggestionMemory(args.session) : { shown: [], clicked: [] };
205
+ let ranked = buildSuggestions({
206
+ page: args.page,
207
+ mode: args.mode,
208
+ plan: args.plan,
209
+ modelSuggestions: args.modelSuggestions,
210
+ fallbackSuggestions: args.fallbackSuggestions,
211
+ shown: new Set(memory.shown),
212
+ clicked: new Set(memory.clicked),
213
+ availableSlugs: args.availableSlugs,
214
+ sitePurpose: args.body.sitePurpose,
215
+ siteTone: typeof args.body.siteContext === "object" ? args.body.siteContext?.tone : undefined
216
+ });
217
+ /*
218
+ * The keyless path, last. With no provider the answer comes from
219
+ * `demoPlanFromMessage`, a short list of literal English matchers, and a pill
220
+ * is sent verbatim on click — so a phrasing the matcher does not know is a
221
+ * dead button on the first screen of an install whose banner promises "no API
222
+ * key needed to look around". Returning fewer, or none, is the intended
223
+ * outcome.
224
+ */
225
+ if (args.source === "demo") {
226
+ const executable = new Set(keepDemoExecutable(ranked.map((entry) => entry.prompt), args.slug, args.body.activeBlockId, args.body.activeBlockType));
227
+ ranked = ranked.filter((entry) => executable.has(entry.prompt));
228
+ }
229
+ if (args.session && ranked.length > 0) {
230
+ recordSuggestionsShown(args.session, ranked.map((entry) => entry.id));
231
+ }
232
+ return { suggestions: toLegacySuggestions(ranked), suggestionsV2: ranked };
200
233
  }
201
234
  let demoPlanFromMessageImpl = demoPlanFromMessage;
202
235
  export function setDemoPlanFromMessageForTests(fn) {
@@ -739,7 +772,22 @@ export async function runChatPipeline(ctx, body, options) {
739
772
  promptHash,
740
773
  promptExcerpt,
741
774
  promptLength: plannerMessage.length,
742
- totalDurationMs: doneAtMs
775
+ totalDurationMs: doneAtMs,
776
+ /*
777
+ * Read straight off the payload rather than plumbed through the
778
+ * pipeline: this is the one place that sees every finished turn,
779
+ * and `suggestionsV2` is already on the object. `clickedSuggestionId`
780
+ * on the same row is what closes the loop — it says this turn was a
781
+ * click on a pill offered earlier, and the `opCount` recorded for
782
+ * the turn says whether that click was worth making.
783
+ */
784
+ ...(payload.suggestionsV2?.length
785
+ ? {
786
+ suggestionIds: payload.suggestionsV2.map((entry) => entry.id),
787
+ suggestionSources: payload.suggestionsV2.map((entry) => entry.source)
788
+ }
789
+ : {}),
790
+ ...(body.clickedSuggestionId ? { clickedSuggestionId: body.clickedSuggestionId } : {})
743
791
  });
744
792
  }
745
793
  return stageTimeline.slice();
@@ -779,6 +827,15 @@ export async function runChatPipeline(ctx, body, options) {
779
827
  promptLength: plannerMessage.length,
780
828
  totalDurationMs: 0
781
829
  });
830
+ /*
831
+ * A clicked pill is one the user acted on: it must not be offered again, and
832
+ * the ranker drops a clicked id outright. Recorded here, before any branch
833
+ * can return, so it holds for every outcome — including the ones that answer
834
+ * without planning anything.
835
+ */
836
+ if (body.session && body.clickedSuggestionId) {
837
+ recordSuggestionClicked(body.session, body.clickedSuggestionId);
838
+ }
782
839
  if (executionMode === "auto") {
783
840
  const existingPendingPlan = await loadPendingPlan(body.session);
784
841
  const normalizedIncomingMessage = typeof body.message === "string" ? body.message.trim() : "";
@@ -1127,7 +1184,15 @@ export async function runChatPipeline(ctx, body, options) {
1127
1184
  summary: "I need one more detail before applying this safely.",
1128
1185
  changes: [],
1129
1186
  mentionedSlugs: [effectiveSlug],
1130
- suggestions: usableSuggestions(clarificationSuggestions({ body, current, selected }), args.source, effectiveSlug, body),
1187
+ ...resolveSuggestions({
1188
+ fallbackSuggestions: clarificationSuggestions({ body, current, selected }),
1189
+ mode: "clarification",
1190
+ page: current,
1191
+ source: args.source,
1192
+ slug: effectiveSlug,
1193
+ session: body.session,
1194
+ body
1195
+ }),
1131
1196
  previewVersion: versions.get(body.session) ?? 0,
1132
1197
  plannerSource: args.source,
1133
1198
  modelUsed,
@@ -1751,7 +1816,16 @@ export async function runChatPipeline(ctx, body, options) {
1751
1816
  summary: resolvedPlan.summary_for_user,
1752
1817
  changes: resolvedPlan.change_log,
1753
1818
  mentionedSlugs: collectMentionedSlugsFromPlan(resolvedPlan, effectiveSlug),
1754
- suggestions: usableSuggestions(resolvedPlan.suggested_next_actions ?? clarificationSuggestions({ body, current, selected }), source, effectiveSlug, body),
1819
+ ...resolveSuggestions({
1820
+ modelSuggestions: resolvedPlan.suggested_next_actions,
1821
+ fallbackSuggestions: clarificationSuggestions({ body, current, selected }),
1822
+ mode: "clarification",
1823
+ page: current,
1824
+ source,
1825
+ slug: effectiveSlug,
1826
+ session: body.session,
1827
+ body
1828
+ }),
1755
1829
  previewVersion: versions.get(body.session) ?? 0,
1756
1830
  plannerSource: source,
1757
1831
  modelUsed,
@@ -2655,7 +2729,20 @@ export async function runChatPipeline(ctx, body, options) {
2655
2729
  summary: futureToPastTense(resolvedPlan.summary_for_user),
2656
2730
  changes: [...opChangeLogEntries, ...metaChangeLogEntries, ...aiInsightChanges, ...skippedSummary],
2657
2731
  mentionedSlugs: collectMentionedSlugsFromPlan(resolvedPlan, updatedSlug ?? effectiveSlug),
2658
- suggestions: usableSuggestions(resolvedPlan.suggested_next_actions ?? postEditSuggestions({ plan: resolvedPlan, current, body }), plannerSource, updatedSlug ?? effectiveSlug, body),
2732
+ ...resolveSuggestions({
2733
+ modelSuggestions: resolvedPlan.suggested_next_actions,
2734
+ fallbackSuggestions: postEditSuggestions({ plan: resolvedPlan, current, body }),
2735
+ mode: "followup",
2736
+ // The post-apply page, not `current`: a gap the edit just filled
2737
+ // must not still be offered as a suggestion, and `current` is the
2738
+ // pre-apply snapshot.
2739
+ page: getSessionDraft(body.session).get(updatedSlug ?? effectiveSlug) ?? current,
2740
+ plan: resolvedPlan,
2741
+ source: plannerSource,
2742
+ slug: updatedSlug ?? effectiveSlug,
2743
+ session: body.session,
2744
+ body
2745
+ }),
2659
2746
  previewVersion,
2660
2747
  focusBlockId,
2661
2748
  updatedSlug,
@@ -134,7 +134,23 @@ function buildLightweightPlannerPrompt(opts) {
134
134
  RULE_ICON_FORMAT,
135
135
  "Use future tense in summary_for_user and change_log — your output streams to the user while the plan is still being generated, before any ops have been applied. Say 'Will update the heading…' or 'Will replace the Hero image…', never 'Updated…' or 'Updating…'. The system flips to past tense automatically once ops are applied.",
136
136
  "For edit_plan: summary_for_user must be ONE short sentence (max ~20 words).",
137
- "After planning ops, include suggested_next_actions: 2-4 short imperative phrases the user could type next (max 6 words each). Every suggestion must be an action the user can perform inside this editor (editing content, adding/removing sections, changing images, rewriting copy) — restricted to the block types listed in the block catalogue provided in context (Hero, FeatureGrid, Testimonials, FAQAccordion, CTA, Card, CardGrid, RichText, TwoColumn, Banner, Carousel, Embed, Footer, Gallery, Quote, SiteHeader, Stats, Table, Tabs, Video). NEVER suggest unsupported features like forms, email capture, contact forms, subscribe boxes, newsletter signups, popups, modals, or anything requiring custom code. Never suggest actions outside the editor's scope such as A/B testing, analytics, performance monitoring, user research, or marketing strategy. 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', 'Use a warmer opening'). Remaining suggestions can target neighboring fields or blocks. NEVER suggest undoing, reverting, or restoring earlier content. You cannot read the undo history, so such a suggestion resolves to either a refusal or an invented replacement presented as the original — the editor's own Undo button is the affordance for that, and it already works.",
137
+ /*
138
+ * The block list here used to be the twenty Avocado built-ins, spelled out
139
+ * verbatim, on every request — including requests from sites whose
140
+ * catalogue is entirely their own. On those the prompt was actively
141
+ * steering the model toward blocks the site has no renderer for, and
142
+ * `add_block` for one of those applies cleanly, reports success and draws
143
+ * nothing. `opts.effectiveBlockTypes` is the list that is true for THIS
144
+ * site, and it was already on the options object.
145
+ *
146
+ * The long tail of "NEVER suggest forms / popups / A/B tests / undo" is
147
+ * shortened, not because the model may now do those things but because
148
+ * saying it at length three times across two prompts never stopped it:
149
+ * nothing checked the output. `suggestion-engine.ts` now rejects every one
150
+ * of them on every candidate whatever its source. What is left is the short
151
+ * form, which still saves the model from spending a slot on one.
152
+ */
153
+ `After planning ops, include suggested_next_actions: 2-4 short imperative phrases the user could type next (max 6 words each). Each must be a complete instruction the editor can execute \u2014 name the change, not just the field ("Make it shorter", not "Edit heading"). The only block types on this site are: ${opts.effectiveBlockTypes.join(", ")}. Never suggest a section outside that list, anything needing custom code (forms, popups, checkout), anything outside the editor (A/B tests, analytics), or undoing/reverting. When the plan contains exactly one update_props op on a text field, the first 1-2 suggestions MUST refine that same field.`,
138
154
  opts.selectedBlockId.length > 0
139
155
  ? `Selected block is ${opts.selectedBlockId}. Target only this block in ops when the request edits the current page. IGNORE this selection when the request operates on a different scope — creating, duplicating, renaming, removing, or moving a page; editing site config; or naming a different page — and emit ops only for the requested scope. Never add bonus ops on the selected block to satisfy this rule.`
140
156
  : "Respect explicit user target references when present.",
@@ -250,7 +266,24 @@ function sectionVoice(opts, hasNativeTools) {
250
266
  if (hasNativeTools) {
251
267
  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.");
252
268
  }
253
- lines.push("In summary_for_user, use simple markdown for readability: **bold** for key terms or labels, and bullet lists (- item) when listing multiple items, recommendations, or observations. Keep it scannable — avoid walls of text.", "When rewriting text, return plain text unless the prop is a rich-text prop (see below) or the user explicitly asks for markdown formatting. Do not wrap the entire rewrite in **bold** markers.", RULE_RICH_TEXT_PROPS, "For copy in German or similar long-compound languages, insert soft hyphen opportunities in long compounds where helpful for responsive line wrapping. Use the Unicode soft hyphen character (U+00AD), never HTML entities like ­ or ­.", opts.provider !== "openai" ? BLOCK_NAME_PRIVACY_ANTHROPIC : BLOCK_NAME_PRIVACY_OPENAI, "", "### suggested_next_actions", "2-4 short imperative phrases the user could type next (max 6 words each). Each MUST be a logical follow-up to the specific change just made — not a generic action. NEVER suggest 'Open /X' or any navigation to a page that the current plan is creating, duplicating, or otherwise still pending — the page won't exist until the user approves the plan, and there is no special navigation chip handler (suggestions are sent verbatim as the next chat command). Suggest plan refinements instead (e.g. 'Use a punchier hero headline', 'Add a card grid for spotlights', 'Drop the FAQ section'). Ask yourself: 'what would the user likely want to do next given THIS edit?' When the plan contains exactly one update_props op that changes a text field, the first 1-2 suggestions MUST be refinements of that same field (e.g. 'Make it shorter', 'Try a bolder tone', 'Use a warmer opening'). For example, after rewriting stats labels, suggest refining the same section ('Make the numbers bigger', 'Add a stat about X') — not unrelated actions like 'Change title' or 'Add a Testimonials section'. For needs_clarification, suggest the most likely concrete answers. Omit suggested_next_actions entirely if no contextual follow-up is obvious. Every suggestion must be an action the user can perform inside this editor — restricted to the block types listed in blockContracts / blockCatalogue for THIS site, or SEO/site-config edits. Never suggest adding a section this site has no block for; the catalogue is the whole list, not a sample of a larger one. NEVER suggest unsupported features: no forms, no email capture, no contact forms, no subscribe boxes, no newsletter signups, no popups/modals, no chat widgets, no live video, no payment/checkout — these require custom code the editor cannot produce. Never suggest actions outside the editor's scope such as A/B testing, analytics, performance monitoring, user research, or marketing strategy. NEVER suggest undoing, reverting, or restoring earlier content. You cannot read the undo history, so such a suggestion resolves to either a refusal or an invented replacement presented as the original — the editor's own Undo button is the affordance for that, and it already works.");
269
+ lines.push("In summary_for_user, use simple markdown for readability: **bold** for key terms or labels, and bullet lists (- item) when listing multiple items, recommendations, or observations. Keep it scannable — avoid walls of text.", "When rewriting text, return plain text unless the prop is a rich-text prop (see below) or the user explicitly asks for markdown formatting. Do not wrap the entire rewrite in **bold** markers.", RULE_RICH_TEXT_PROPS, "For copy in German or similar long-compound languages, insert soft hyphen opportunities in long compounds where helpful for responsive line wrapping. Use the Unicode soft hyphen character (U+00AD), never HTML entities like ­ or ­.", opts.provider !== "openai" ? BLOCK_NAME_PRIVACY_ANTHROPIC : BLOCK_NAME_PRIVACY_OPENAI, "", "### suggested_next_actions",
270
+ /*
271
+ * Shortened from about 1,900 characters, and the deleted half was all
272
+ * prohibition: no forms, no email capture, no popups, no chat widgets, no
273
+ * payment, no A/B tests, no analytics, no user research, no undo, no block
274
+ * outside the catalogue. Every one of those was also stated in the
275
+ * lightweight prompt, and none of them was ever checked \u2014
276
+ * `suggested_next_actions` went to the wire exactly as written.
277
+ *
278
+ * They are predicates now, in `suggestion-engine.ts`, applied to every
279
+ * candidate whatever produced it. The short form stays so the model does
280
+ * not spend a slot on a suggestion that will be dropped; what it no longer
281
+ * has to do is carry the enforcement.
282
+ *
283
+ * What is kept in full is the part no validator can check: that a good
284
+ * suggestion is a follow-up to THIS edit rather than a generic next move.
285
+ */
286
+ "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 \u2014 not a generic action. 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', 'Use a warmer opening'). After rewriting stats labels, refine the same section ('Make the numbers bigger', 'Add a stat about X') \u2014 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. Each must name the change and not just the field: 'Make it shorter', never 'Edit heading' \u2014 the planner knows the field and not the value, so the latter can only be answered with a question. NEVER suggest 'Open /X' or any navigation to a page the current plan is still creating; suggestions are sent verbatim as the next chat command and there is no navigation handler. Restricted to the block types in blockContracts / blockCatalogue for THIS site, or SEO/site-config edits \u2014 the catalogue is the whole list, not a sample. No forms, popups, checkout or anything else needing custom code; nothing outside the editor such as A/B testing or analytics; never undo, revert or restore.");
254
287
  return lines;
255
288
  }
256
289
  /*
@@ -31,7 +31,9 @@ import { runChatPipeline, collectMentionedSlugsFromOps } from "../chat/chat-pipe
31
31
  import { createChatTelemetryStore } from "../telemetry/chat-telemetry.js";
32
32
  import { createToolRuntime } from "../tools/runtime.js";
33
33
  import { envFlag } from "../env-flags.js";
34
- import { loadStateFromDisk, scopedSessionKey, getSessionPages, getPage, getSiteConfig, setSiteConfig, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, schedulePersistState, normalizeSiteId, publishStatusBySession, pushPublishLogEntry, persistenceHealth, persistenceWarning } from "../state/session-state.js";
34
+ import { loadStateFromDisk, scopedSessionKey, getSessionPages, getPage, getSiteConfig, setSiteConfig, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, schedulePersistState, normalizeSiteId, publishStatusBySession, pushPublishLogEntry, persistenceHealth, persistenceWarning, getSuggestionMemory } from "../state/session-state.js";
35
+ import { buildSuggestions } from "../nlp/suggestion-engine.js";
36
+ import { keepDemoExecutable } from "../nlp/deterministic-planner-suggestions.js";
35
37
  import { defaultModelLookup, defaultProviders } from "../chat/model-defaults.js";
36
38
  import { historyStatus, historyLog, historyUndoAction, historyRedoAction, historyRestoreAction, historyDiscardAction } from "../http/history-actions.js";
37
39
  import { whoamiAction } from "../http/session-actions.js";
@@ -261,6 +263,7 @@ const SUPPORTED_ROUTES = [
261
263
  "POST /publish",
262
264
  "GET /status/planner",
263
265
  "GET /draft/pages",
266
+ "GET /suggestions",
264
267
  "GET /draft/slugs",
265
268
  "POST /draft/bootstrap",
266
269
  "GET+PUT /draft/site-config",
@@ -1208,6 +1211,62 @@ export function createOrchestrator(config = {}) {
1208
1211
  return jsonResponse({ error: "not found" }, { status: 404, cors });
1209
1212
  return jsonResponse(structuredClone(page), { status: 200, cors });
1210
1213
  }
1214
+ /*
1215
+ * The pills for a page nobody has chatted about yet.
1216
+ *
1217
+ * The editor used to derive its opening suggestions in the browser, from
1218
+ * the list of block *types* on the page and a table of translated strings.
1219
+ * It could not see what was in those blocks, so it offered "Add an FAQ
1220
+ * section" to a page that already had one until a guard was written for
1221
+ * that specific case, and it had no way at all to notice that the hero's
1222
+ * image was empty or that the page had no meta description.
1223
+ *
1224
+ * The engine can see all of that, and it is the same engine that ranks the
1225
+ * pills after every later turn — so the first screen and the tenth now
1226
+ * answer "what next?" the same way.
1227
+ */
1228
+ if (request.method === "GET" && path === "/suggestions") {
1229
+ const runtime = await getRuntime();
1230
+ await runtime.ready;
1231
+ const session = url.searchParams.get("session") ?? undefined;
1232
+ const siteId = url.searchParams.get("siteId") ?? undefined;
1233
+ const slug = url.searchParams.get("slug");
1234
+ if (!session || !slug) {
1235
+ return jsonResponse({ error: "session and slug are required" }, { status: 400, cors });
1236
+ }
1237
+ const scopedSession = scope(session, siteId);
1238
+ await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
1239
+ const page = getPage(scopedSession, slug);
1240
+ if (!page)
1241
+ return jsonResponse({ error: "not found" }, { status: 404, cors });
1242
+ const memory = getSuggestionMemory(scopedSession);
1243
+ let suggestionsV2 = buildSuggestions({
1244
+ page,
1245
+ shown: new Set(memory.shown),
1246
+ clicked: new Set(memory.clicked),
1247
+ siteTone: url.searchParams.get("tone") ?? undefined
1248
+ });
1249
+ /*
1250
+ * With no provider configured a pill is answered by `demoPlanFromMessage`
1251
+ * — a short list of literal English matchers — and the pill text is sent
1252
+ * verbatim on click, so anything the matcher does not know is a dead
1253
+ * button. These are the first three a new user sees, under a banner
1254
+ * promising no API key is needed to look around. The caller says whether
1255
+ * that is the situation; the editor is already showing that badge.
1256
+ */
1257
+ if (url.searchParams.get("keyless") === "true") {
1258
+ const executable = new Set(keepDemoExecutable(suggestionsV2.map((entry) => entry.prompt), slug));
1259
+ suggestionsV2 = suggestionsV2.filter((entry) => executable.has(entry.prompt));
1260
+ }
1261
+ /*
1262
+ * Deliberately NOT recorded as shown. The welcome row is re-fetched
1263
+ * whenever the site's identity resolves late — which in production is
1264
+ * every load, because the auth handshake makes `/status/planner` lose the
1265
+ * race — so recording here would decay a pill on the same screen that
1266
+ * first drew it.
1267
+ */
1268
+ return jsonResponse({ suggestions: suggestionsV2.map((entry) => entry.prompt), suggestionsV2 }, { status: 200, cors });
1269
+ }
1211
1270
  // Page list. Also flips the editor's `hasBootstrapped` gate — until this
1212
1271
  // returns a non-empty list, the property panel never enables its fetch.
1213
1272
  if (request.method === "GET" && path === "/draft/slugs") {
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, Cm
6
6
  export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaUploader, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaUploader, type CmsMediaSourceConfig } from "./cms/index.ts";
7
7
  export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.ts";
8
8
  export type { PublishTarget, PublishContext, PublishOutcome, PublishStatus, PublishResult } from "./publish/publish-target.ts";
9
+ export { buildSuggestions, validateSuggestion, toLegacySuggestions, suggestionId, SUGGESTION_LIMIT } from "./nlp/suggestion-engine.ts";
10
+ export type { Suggestion, SuggestionCandidate, SuggestionContext, SuggestionKind, SuggestionMode, SuggestionSource, ValidationFailure } from "./nlp/suggestion-engine.ts";
9
11
  export { SqliteDurableStore, InMemoryDurableStore, getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral, type SqliteDurableStoreOptions, type InMemoryDurableStoreOptions, type DurableStore, type FindingInput, type FindingRecord, type FindingQuery, type FindingSeverity, type FindingStatus, type FindingEvidence, type CheckRunInput, type CheckRunRecord, type CheckRunPatch, type CheckRunTrigger, type MemoryInput, type MemoryRecord, type MemoryQuery, type MemoryScope, type MemoryKind, type MemorySource, type MemoryStatus, type CorrectionInput, type CorrectionRecord, type CorrectionQuery, type CorrectionOutcome, type ProposalInput, type ProposalRecord, type ProposalQuery, type ProposalStatus } from "./durable/index.ts";
10
12
  export { runDraftChecks, runChecksForSession, scheduleChecksAfterApply, scheduleChecksAfterPublish, cancelScheduledChecks, fingerprintFor, DRAFT_RULES, walkPageFields, fieldText, type RunChecksArgs, type CheckRule, type CheckContext, type RuleFinding, type FieldEntry, type SiteView } from "./checks/index.ts";
11
13
  export { runChecksAction, listFindingsAction, listCheckRunsAction, updateFindingAction, type RunChecksParams, type ListFindingsParams, type UpdateFindingParams, type ChecksScope } from "./http/checks-actions.ts";
package/dist/index.js CHANGED
@@ -40,6 +40,17 @@ export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource,
40
40
  // integration point for "S3, GitLab Pages, Netlify, a CMS API, a custom CI/CD
41
41
  // pipeline". Only the export was missing.
42
42
  export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.js";
43
+ // The suggestion engine. `Suggestion` is public because it is a field on
44
+ // `ChatResult` — a consumer installing from the registry that reads
45
+ // `suggestionsV2` off a chat response has no other specifier that reaches the
46
+ // type, and an unreachable type on a public response shape is the defect this
47
+ // file's header describes.
48
+ //
49
+ // `validateSuggestion` and `buildSuggestions` are public for the other half:
50
+ // a library-mode host rendering its own pill UI should be able to rank and
51
+ // check candidates with the same rules the orchestrator applies, rather than
52
+ // reimplementing the list of things the editor cannot build.
53
+ export { buildSuggestions, validateSuggestion, toLegacySuggestions, suggestionId, SUGGESTION_LIMIT } from "./nlp/suggestion-engine.js";
43
54
  // The durable substrate for findings, memory, corrections and proposals. It is
44
55
  // public because the implementation is meant to be replaceable: a library-mode
45
56
  // host on a serverless platform has no persistent disk for a SQLite file, and
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { type BlockType, type EditPlan, type PageDoc, type PublishDiff, type Operation } from "@avocadostudio-ai/shared";
3
+ import type { Suggestion } from "./suggestion-engine.ts";
3
4
  import { type ModelKey } from "../state/session-state.ts";
4
5
  import { type GuardrailErrorCategory } from "../errors.ts";
5
6
  export type { GuardrailErrorCategory };
@@ -97,6 +98,7 @@ export declare const chatRequestBodySchema: z.ZodObject<{
97
98
  name: z.ZodOptional<z.ZodString>;
98
99
  bytes: z.ZodOptional<z.ZodNumber>;
99
100
  }, z.core.$strip>>>;
101
+ clickedSuggestionId: z.ZodOptional<z.ZodString>;
100
102
  }, z.core.$strip>;
101
103
  export type ChatRequestBody = z.infer<typeof chatRequestBodySchema>;
102
104
  /** Per-op outcome surfaced to the editor for the plan-preview card. Mirrors the engine's OpResult. */
@@ -113,6 +115,17 @@ export type ChatResult = {
113
115
  changes: string[];
114
116
  mentionedSlugs?: string[];
115
117
  suggestions?: string[];
118
+ /**
119
+ * The same suggestions, with the structure the pills are actually ranked and
120
+ * measured with: a stable id to join telemetry on, a label that can be
121
+ * shorter than the command it sends, and the source and evidence behind it.
122
+ *
123
+ * Additive on purpose. `suggestions: string[]` is part of this package's
124
+ * published surface and site-sdk and every other integrator read it;
125
+ * it stays, and it is a projection of this field
126
+ * (`suggestionsV2.map(s => s.prompt)`), so the two cannot disagree.
127
+ */
128
+ suggestionsV2?: Suggestion[];
116
129
  validationErrors?: unknown;
117
130
  previewVersion: number;
118
131
  focusBlockId?: string;
@@ -54,6 +54,18 @@ export const chatRequestBodySchema = z.object({
54
54
  pendingPlanId: z.string().optional(),
55
55
  continuationChainId: z.string().optional(),
56
56
  attachments: z.array(chatAttachmentSchema).max(6).optional(),
57
+ /**
58
+ * Set when this message came from clicking a suggestion pill rather than from
59
+ * typing.
60
+ *
61
+ * Carried on the request the click already sends, rather than through a
62
+ * separate endpoint: the click *is* a chat turn, so there is nothing to
63
+ * correlate afterwards and no extra round trip to lose. It is what makes a
64
+ * pill measurable — until now nothing recorded which suggestions were shown,
65
+ * which were clicked, or whether a click produced any operations, so the
66
+ * question "are the pills any good" had no answer at all.
67
+ */
68
+ clickedSuggestionId: z.string().max(64).optional(),
57
69
  });
58
70
  // ---------------------------------------------------------------------------
59
71
  // Shared normaliser used by all intent detectors below.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * One engine for suggestion pills.
3
+ *
4
+ * A pill is the only thing in this product that speaks first. Everything else
5
+ * the editor does, it does because somebody typed it. That makes a pill worth
6
+ * more than its size on screen and also more dangerous: `onSuggestionClick` is
7
+ * `text => submitChat(text)`, so the label is the prompt, sent verbatim, and a
8
+ * pill the planner cannot answer is a button that does nothing.
9
+ *
10
+ * Five separate pieces of code used to answer "what next?" — the editor's
11
+ * welcome builder, the model's `suggested_next_actions`, three deterministic
12
+ * fallbacks, six hardcoded arrays, and the property panel's field pills. They
13
+ * shared no type, no vocabulary and no quality bar, and every one of them ended
14
+ * in `.slice(0, 4)` over an append-ordered list, so the order a pill appeared
15
+ * in was the order somebody had typed it into a source file.
16
+ *
17
+ * This module replaces the ranking half of that. Candidates arrive from several
18
+ * cheap sources — the model among them, with a high prior but no privilege —
19
+ * pass one validator, and are scored against each other. The model can lose.
20
+ *
21
+ * The validator is the other half of the point. The planner prompts carry about
22
+ * 1,400 characters of "NEVER suggest forms / popups / A/B tests / undo /
23
+ * questions / blocks this site cannot draw", stated three times across two
24
+ * prompts and enforced nowhere: `suggested_next_actions` went to the wire
25
+ * untouched. Every one of those rules is a predicate here, applied to every
26
+ * candidate regardless of where it came from.
27
+ */
28
+ import { type EditPlan, type PageDoc } from "@avocadostudio-ai/shared";
29
+ export type SuggestionKind =
30
+ /** Refines the thing the last edit just touched. */
31
+ "refine"
32
+ /** Fills a hole the page actually has — an empty image, a missing description. */
33
+ | "fill-gap"
34
+ /** Adds a section this site has a renderer for. */
35
+ | "add-section"
36
+ /** Page/site structure: new pages, ordering, navigation. */
37
+ | "structure"
38
+ /** Search metadata. */
39
+ | "seo" | "other";
40
+ export type SuggestionSource = "model" | "page-gap" | "refinement" | "structure" | "context";
41
+ export type Suggestion = {
42
+ /**
43
+ * Stable across turns for the same prompt — this is the join key that session
44
+ * memory dedupes on and that telemetry reports, so it must not embed a
45
+ * timestamp, a turn index or anything else that moves.
46
+ */
47
+ id: string;
48
+ /** What the pill reads. Short enough to fit a chip. */
49
+ label: string;
50
+ /**
51
+ * What is sent on click. May be longer and more specific than the label —
52
+ * splitting the two is what lets a pill read "Add author photos" while
53
+ * sending a command the planner can actually execute. Before this existed,
54
+ * one string had to be both good chip copy and a complete instruction, and
55
+ * that tension is why the "Edit heading"-shaped pills had to be deleted
56
+ * outright rather than reworded.
57
+ */
58
+ prompt: string;
59
+ kind: SuggestionKind;
60
+ source: SuggestionSource;
61
+ score: number;
62
+ /** Why this was offered. Carried into telemetry; never shown to the user. */
63
+ evidence?: string;
64
+ };
65
+ export type SuggestionCandidate = {
66
+ label: string;
67
+ prompt?: string;
68
+ kind: SuggestionKind;
69
+ source: SuggestionSource;
70
+ /** Source-level confidence before page-state scoring. 0..1. */
71
+ prior: number;
72
+ evidence?: string;
73
+ };
74
+ /**
75
+ * What kind of turn these pills belong to — which changes what a good pill is.
76
+ *
77
+ * A `followup` pill is a standalone command: it is sent verbatim and has to
78
+ * stand on its own, so "Edit heading" is a dead button.
79
+ *
80
+ * A `clarification` pill is an *answer to a question the assistant just asked*.
81
+ * It does not travel alone: `plannerMessageWithPendingContext` composes it with
82
+ * the request that triggered the clarification, so "Edit heading" arrives at
83
+ * the planner as the original sentence plus "Clarification from user: Edit
84
+ * heading" — which resolves. Applying the follow-up rules here would delete
85
+ * every answer to the question and leave the user a row of unrelated
86
+ * suggestions instead.
87
+ */
88
+ export type SuggestionMode = "followup" | "clarification";
89
+ export type SuggestionContext = {
90
+ page: PageDoc;
91
+ mode?: SuggestionMode;
92
+ /** The plan that just ran (or is held for approval), when there was one. */
93
+ plan?: EditPlan;
94
+ /** `suggested_next_actions` as the model returned them. */
95
+ modelSuggestions?: string[];
96
+ /**
97
+ * The path's own deterministic fallback — `clarificationSuggestions` or
98
+ * `postEditSuggestions`. A candidate source like any other, so it competes
99
+ * instead of being used only when the model said nothing.
100
+ */
101
+ fallbackSuggestions?: string[];
102
+ /** Ids already shown to this session. */
103
+ shown?: ReadonlySet<string>;
104
+ /** Ids this session has clicked. */
105
+ clicked?: ReadonlySet<string>;
106
+ /** Free-text site purpose / tone, when the user has filled it in. */
107
+ sitePurpose?: string;
108
+ siteTone?: string;
109
+ /** Slugs that exist on the site, for structure candidates. */
110
+ availableSlugs?: string[];
111
+ limit?: number;
112
+ };
113
+ /** Three, not four. See `rank`. */
114
+ export declare const SUGGESTION_LIMIT = 3;
115
+ /**
116
+ * A content hash of the prompt, normalized so that casing and trailing
117
+ * punctuation do not mint a new id for the same suggestion. Deliberately not a
118
+ * cryptographic hash — this is a dedupe key, not a security boundary, and a
119
+ * short stable string is easier to read in a telemetry row.
120
+ */
121
+ export declare function suggestionId(prompt: string): string;
122
+ export type ValidationFailure = "empty" | "too_long" | "question_or_offer" | "undo" | "unsupported_feature" | "underspecified" | "unrenderable_block";
123
+ export declare function validateSuggestion(text: string, opts?: {
124
+ mode?: SuggestionMode;
125
+ }): ValidationFailure | null;
126
+ /**
127
+ * The model's own suggestions, as candidates.
128
+ *
129
+ * A high prior — the model is the only source that read the user's sentence and
130
+ * the page's topic, and when it is right it is more specific than anything
131
+ * derivable. But it competes, and when it returns four variations on "add a
132
+ * section" the diversity rule in `rank` will keep one of them.
133
+ */
134
+ export declare function modelCandidates(raw: string[] | undefined): SuggestionCandidate[];
135
+ /**
136
+ * Holes the page actually has.
137
+ *
138
+ * `pageObservations` already walks every declared field and returns
139
+ * `{ fact, fix }`, where `fix` is phrased as a command the planner can run. It
140
+ * was computed on every turn and handed to the model as prose — the one
141
+ * candidate source grounded in what is wrong with the page in front of the
142
+ * user, and the pill builders never saw it.
143
+ */
144
+ export declare function pageGapCandidates(page: PageDoc): SuggestionCandidate[];
145
+ /**
146
+ * Refinements of the field the last edit just touched.
147
+ *
148
+ * The planner prompt asks for this in so many words — "When the plan contains
149
+ * exactly one update_props op that changes a text field, the first 1-2
150
+ * suggestions MUST be refinements of that same field" — and nothing has ever
151
+ * guaranteed it. Deriving it costs nothing and does.
152
+ */
153
+ export declare function refinementCandidates(plan: EditPlan | undefined, page: PageDoc): SuggestionCandidate[];
154
+ /**
155
+ * Sections this site can draw and this page does not have.
156
+ *
157
+ * Read off `catalogueBlockTypes()` — never off the built-in names. A site's
158
+ * catalogue is the whole list, not a sample of a larger one.
159
+ */
160
+ export declare function structureCandidates(page: PageDoc, availableSlugs?: string[]): SuggestionCandidate[];
161
+ /**
162
+ * The site's own voice, when the user has told us what it is.
163
+ *
164
+ * `sitePurpose` and tone reach the planner's context pack already; until now
165
+ * the only pills that used them were the welcome ones, and only as a template.
166
+ */
167
+ export declare function contextCandidates(args: {
168
+ sitePurpose?: string;
169
+ siteTone?: string;
170
+ }): SuggestionCandidate[];
171
+ /**
172
+ * Score, deduplicate, diversify, cap.
173
+ *
174
+ * The cap is three rather than four, and returning fewer than three — or none —
175
+ * is a valid answer. Three real suggestions beat six of which one works, and an
176
+ * empty row leaves whatever the assistant said as the thing on screen, which is
177
+ * usually the more useful object anyway.
178
+ */
179
+ export declare function rank(candidates: SuggestionCandidate[], ctx: SuggestionContext): Suggestion[];
180
+ /**
181
+ * The path's own deterministic fallback, as candidates.
182
+ *
183
+ * Given a slightly lower prior than the model's: when both have an opinion the
184
+ * model read the user's sentence and this did not. It still competes, and on
185
+ * the clarification path — where it is answering a specific question — it
186
+ * routinely wins.
187
+ */
188
+ export declare function fallbackCandidates(raw: string[] | undefined): SuggestionCandidate[];
189
+ export declare function buildSuggestions(ctx: SuggestionContext): Suggestion[];
190
+ /** The legacy wire shape: what `suggestions: string[]` has always carried. */
191
+ export declare function toLegacySuggestions(suggestions: Suggestion[]): string[];
@@ -0,0 +1,477 @@
1
+ /**
2
+ * One engine for suggestion pills.
3
+ *
4
+ * A pill is the only thing in this product that speaks first. Everything else
5
+ * the editor does, it does because somebody typed it. That makes a pill worth
6
+ * more than its size on screen and also more dangerous: `onSuggestionClick` is
7
+ * `text => submitChat(text)`, so the label is the prompt, sent verbatim, and a
8
+ * pill the planner cannot answer is a button that does nothing.
9
+ *
10
+ * Five separate pieces of code used to answer "what next?" — the editor's
11
+ * welcome builder, the model's `suggested_next_actions`, three deterministic
12
+ * fallbacks, six hardcoded arrays, and the property panel's field pills. They
13
+ * shared no type, no vocabulary and no quality bar, and every one of them ended
14
+ * in `.slice(0, 4)` over an append-ordered list, so the order a pill appeared
15
+ * in was the order somebody had typed it into a source file.
16
+ *
17
+ * This module replaces the ranking half of that. Candidates arrive from several
18
+ * cheap sources — the model among them, with a high prior but no privilege —
19
+ * pass one validator, and are scored against each other. The model can lose.
20
+ *
21
+ * The validator is the other half of the point. The planner prompts carry about
22
+ * 1,400 characters of "NEVER suggest forms / popups / A/B tests / undo /
23
+ * questions / blocks this site cannot draw", stated three times across two
24
+ * prompts and enforced nowhere: `suggested_next_actions` went to the wire
25
+ * untouched. Every one of those rules is a predicate here, applied to every
26
+ * candidate regardless of where it came from.
27
+ */
28
+ import { catalogueBlockTypes, getBlockMeta } from "@avocadostudio-ai/shared";
29
+ import { pageObservations } from "./intent-detection.js";
30
+ /** Three, not four. See `rank`. */
31
+ export const SUGGESTION_LIMIT = 3;
32
+ // ---------------------------------------------------------------------------
33
+ // Identity
34
+ // ---------------------------------------------------------------------------
35
+ /**
36
+ * A content hash of the prompt, normalized so that casing and trailing
37
+ * punctuation do not mint a new id for the same suggestion. Deliberately not a
38
+ * cryptographic hash — this is a dedupe key, not a security boundary, and a
39
+ * short stable string is easier to read in a telemetry row.
40
+ */
41
+ export function suggestionId(prompt) {
42
+ const normalized = prompt.trim().toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ");
43
+ let h1 = 0x811c9dc5;
44
+ let h2 = 0x01000193;
45
+ for (let i = 0; i < normalized.length; i += 1) {
46
+ const c = normalized.charCodeAt(i);
47
+ h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
48
+ h2 = Math.imul(h2 + c, 0x85ebca6b) >>> 0;
49
+ }
50
+ return `s_${h1.toString(36)}${h2.toString(36)}`;
51
+ }
52
+ /**
53
+ * Phrased as a question or an offer rather than a command.
54
+ *
55
+ * The prompt has told the model "NEVER phrase suggestions as questions
56
+ * ('Would you like me to…?')" since the page-feedback rule was written. A pill
57
+ * that reads "Would you like me to shorten it?" sends that string to the
58
+ * planner, which answers the question instead of doing the thing.
59
+ */
60
+ const QUESTION_OR_OFFER = /^\s*(would|should|shall|could|can|do|does|did|is|are|want|how|what|why|when|where|which|who)\b|^\s*i\s+(can|could|will|would|might)\b|^\s*(let me|let's|maybe|perhaps|consider)\b/i;
61
+ /**
62
+ * Undo, revert, restore.
63
+ *
64
+ * The model cannot read the undo history, so such a suggestion resolves to
65
+ * either a refusal or an invented replacement presented as the original. The
66
+ * editor's own Undo button is the affordance, and it works.
67
+ */
68
+ const UNDO_REQUEST = /\b(undo|revert|restore|roll\s?back|rolled back|put\s+(?:it|them|that|the\s+\w+)\s+back|bring\s+back|go back to the (?:previous|old|earlier))\b/i;
69
+ /**
70
+ * Capabilities the editor does not have. Each of these requires code the block
71
+ * pipeline cannot produce, so the planner's only honest answer is a refusal —
72
+ * after the user has spent a click and, on a keyed plan, a model call finding
73
+ * that out.
74
+ */
75
+ const UNSUPPORTED_FEATURE = new RegExp([
76
+ // interactive widgets requiring custom code
77
+ "\\bforms?\\b", "\\bcontact form\\b", "\\bemail capture\\b", "\\bsubscribe\\b",
78
+ "\\bnewsletter\\b", "\\bsign[- ]?up form\\b", "\\bpop[- ]?ups?\\b", "\\bmodals?\\b",
79
+ "\\bchat widget\\b", "\\blive chat\\b", "\\bpayments?\\b", "\\bcheckout\\b",
80
+ "\\bshopping cart\\b", "\\bbooking (?:system|engine)\\b", "\\blogin\\b",
81
+ // out-of-scope activities
82
+ "\\ba/?b test", "\\bsplit test", "\\banalytics\\b", "\\bheat ?maps?\\b",
83
+ "\\bperformance monitoring\\b", "\\buser research\\b", "\\bmarketing strategy\\b",
84
+ "\\bconversion tracking\\b", "\\bemail campaign\\b"
85
+ ].join("|"), "i");
86
+ /**
87
+ * Names the target and not the change.
88
+ *
89
+ * "Edit heading", "Edit CTA", "Update link" — the planner knows the field and
90
+ * not the value, so the only possible answer is "edit it to what?". Three
91
+ * clean-room runs measured the same outcome: zero operations, and on a keyed
92
+ * plan about $0.012 and five seconds to be asked a question the pill existed to
93
+ * avoid. `postEditSuggestions` learned this and stopped emitting the shape; the
94
+ * model never did, because nothing checked its output.
95
+ *
96
+ * Verbs that already imply "produce new content" — rewrite, shorten, punch up —
97
+ * are not caught: those are complete instructions on their own. Only the pure
98
+ * mutation verbs need a direction to be actionable.
99
+ */
100
+ const MUTATION_VERB = /^\s*(edit|change|update|modify|adjust|tweak|set|customi[sz]e)\b/i;
101
+ const DIRECTION_MARKER = /\b(to|into|so|with|for|as|using|toward|towards|about|around|more|less|fewer|shorter|longer|bolder|warmer|punchier|friendlier|clearer|simpler|stronger|tighter|bigger|smaller)\b|["'“”]|\d/i;
102
+ /**
103
+ * Words a user would read as the name of a section, mapped to the built-in type
104
+ * behind them.
105
+ *
106
+ * This vocabulary exists to answer one question: is this pill proposing to add
107
+ * a block this site has no renderer for? `add_block` for an unregistered type
108
+ * applies cleanly, reports success and draws nothing — so the suggestion is not
109
+ * merely unhelpful, it is a trap, and `advice-catalogue.test.ts` documents the
110
+ * same trap being sprung on the advice path for months.
111
+ *
112
+ * Checked ONLY in an add/create context. A site whose hero is called
113
+ * `adv_heroSplit` still has a hero, and "Rewrite the hero headline" is a fine
114
+ * suggestion there — the planner resolves that by position and content, not by
115
+ * type name. It is only *adding* that needs a renderer to exist.
116
+ */
117
+ const BUILTIN_SECTION_WORDS = [
118
+ [/\btestimonials?\b/i, "Testimonials"],
119
+ [/\bfaq\b|\bfrequently asked\b|\baccordion\b/i, "FAQAccordion"],
120
+ [/\bfeature grid\b|\bfeatures grid\b|\bfeature section\b/i, "FeatureGrid"],
121
+ [/\bcard grid\b|\bcards? section\b/i, "CardGrid"],
122
+ [/\bcall[- ]to[- ]action\b|\bcta\b/i, "CTA"],
123
+ [/\bhero\b/i, "Hero"],
124
+ [/\bbanner\b/i, "Banner"],
125
+ [/\bcarousel\b|\bslider\b/i, "Carousel"],
126
+ [/\bgallery\b/i, "Gallery"],
127
+ [/\bquote\b/i, "Quote"],
128
+ [/\bstats?\b|\bstatistics\b/i, "Stats"],
129
+ [/\btable\b/i, "Table"],
130
+ [/\btabs\b/i, "Tabs"],
131
+ [/\bvideo\b/i, "Video"],
132
+ [/\bfooter\b/i, "Footer"],
133
+ [/\brich ?text\b|\btext section\b/i, "RichText"],
134
+ [/\btwo[- ]column\b/i, "TwoColumn"],
135
+ [/\bembed\b/i, "Embed"]
136
+ ];
137
+ const ADD_INTENT = /^\s*(add|create|insert|include|append|put in|drop in)\b/i;
138
+ /**
139
+ * Does this site have something that answers to this name?
140
+ *
141
+ * Checked against the catalogue's own display names and types first, so a site
142
+ * that calls its testimonials block `pba_voices` with display name "Voices"
143
+ * still matches a suggestion that says "voices". Only when nothing in the
144
+ * catalogue answers do we fall back to asking whether the phrase names an
145
+ * Avocado built-in that this site does not have.
146
+ */
147
+ function catalogueAnswersTo(text) {
148
+ const lower = text.toLowerCase();
149
+ for (const type of catalogueBlockTypes()) {
150
+ const meta = getBlockMeta(type);
151
+ if (meta?.chrome)
152
+ continue;
153
+ const display = meta?.displayName;
154
+ if (display && lower.includes(display.toLowerCase()))
155
+ return true;
156
+ // Split camelCase / snake-prefixed type names into words: `adv_eventCards`
157
+ // reads as "event cards", which is how a suggestion would spell it.
158
+ const words = type
159
+ .replace(/^[a-z]{2,4}_/, "")
160
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
161
+ .toLowerCase();
162
+ if (words.length > 2 && lower.includes(words))
163
+ return true;
164
+ }
165
+ return false;
166
+ }
167
+ function namesUnrenderableBlock(text) {
168
+ if (!ADD_INTENT.test(text))
169
+ return false;
170
+ if (catalogueAnswersTo(text))
171
+ return false;
172
+ const catalogue = new Set(catalogueBlockTypes());
173
+ for (const [pattern, builtinType] of BUILTIN_SECTION_WORDS) {
174
+ if (pattern.test(text) && !catalogue.has(builtinType))
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+ /** Longest a pill may be before it stops reading as a pill. */
180
+ const MAX_LABEL_CHARS = 60;
181
+ export function validateSuggestion(text, opts) {
182
+ const trimmed = text.trim();
183
+ if (trimmed.length === 0)
184
+ return "empty";
185
+ if (trimmed.length > MAX_LABEL_CHARS * 2)
186
+ return "too_long";
187
+ if (trimmed.endsWith("?") || QUESTION_OR_OFFER.test(trimmed))
188
+ return "question_or_offer";
189
+ if (UNDO_REQUEST.test(trimmed))
190
+ return "undo";
191
+ if (UNSUPPORTED_FEATURE.test(trimmed))
192
+ return "unsupported_feature";
193
+ // Skipped in clarification mode: the pill is an answer composed with the
194
+ // pending request, not a standalone command. See `SuggestionMode`.
195
+ if (opts?.mode !== "clarification" && MUTATION_VERB.test(trimmed) && !DIRECTION_MARKER.test(trimmed)) {
196
+ return "underspecified";
197
+ }
198
+ if (namesUnrenderableBlock(trimmed))
199
+ return "unrenderable_block";
200
+ return null;
201
+ }
202
+ // ---------------------------------------------------------------------------
203
+ // Candidate sources
204
+ // ---------------------------------------------------------------------------
205
+ /**
206
+ * The model's own suggestions, as candidates.
207
+ *
208
+ * A high prior — the model is the only source that read the user's sentence and
209
+ * the page's topic, and when it is right it is more specific than anything
210
+ * derivable. But it competes, and when it returns four variations on "add a
211
+ * section" the diversity rule in `rank` will keep one of them.
212
+ */
213
+ export function modelCandidates(raw) {
214
+ if (!raw || raw.length === 0)
215
+ return [];
216
+ return raw.slice(0, 6).map((text) => ({
217
+ label: text.trim(),
218
+ kind: inferKind(text),
219
+ source: "model",
220
+ prior: 0.8,
221
+ evidence: "planner suggested_next_actions"
222
+ }));
223
+ }
224
+ function inferKind(text) {
225
+ if (/\bmeta description\b|\bseo\b|\bsearch result\b|\bpage title\b/i.test(text))
226
+ return "seo";
227
+ if (/\bpage\b/i.test(text) && /^\s*(add|create|rename|delete|reorder|move)\b/i.test(text))
228
+ return "structure";
229
+ if (/^\s*(add|create|insert|include)\b/i.test(text))
230
+ return "add-section";
231
+ if (/^\s*(rewrite|shorten|lengthen|punch|polish|make|try|use)\b/i.test(text))
232
+ return "refine";
233
+ return "other";
234
+ }
235
+ /**
236
+ * Holes the page actually has.
237
+ *
238
+ * `pageObservations` already walks every declared field and returns
239
+ * `{ fact, fix }`, where `fix` is phrased as a command the planner can run. It
240
+ * was computed on every turn and handed to the model as prose — the one
241
+ * candidate source grounded in what is wrong with the page in front of the
242
+ * user, and the pill builders never saw it.
243
+ */
244
+ export function pageGapCandidates(page) {
245
+ return pageObservations(page).map((observation) => ({
246
+ label: observation.fix,
247
+ kind: /meta description|search result/i.test(observation.fact) ? "seo" : "fill-gap",
248
+ source: "page-gap",
249
+ // Below the model's prior: a gap is real but it is not necessarily what the
250
+ // user is thinking about right now. It wins when the model offers nothing
251
+ // better, which on a page with holes in it is often.
252
+ prior: 0.65,
253
+ evidence: observation.fact
254
+ }));
255
+ }
256
+ /**
257
+ * Refinements of the field the last edit just touched.
258
+ *
259
+ * The planner prompt asks for this in so many words — "When the plan contains
260
+ * exactly one update_props op that changes a text field, the first 1-2
261
+ * suggestions MUST be refinements of that same field" — and nothing has ever
262
+ * guaranteed it. Deriving it costs nothing and does.
263
+ */
264
+ export function refinementCandidates(plan, page) {
265
+ if (!plan)
266
+ return [];
267
+ const updates = plan.ops.filter((op) => op.op === "update_props");
268
+ if (updates.length !== 1)
269
+ return [];
270
+ const [op] = updates;
271
+ if (!op || op.op !== "update_props")
272
+ return [];
273
+ const patch = op.patch;
274
+ const textKeys = Object.entries(patch)
275
+ .filter(([, value]) => typeof value === "string" && value.trim().length > 0)
276
+ .map(([key]) => key);
277
+ if (textKeys.length === 0)
278
+ return [];
279
+ const block = page.blocks.find((entry) => entry.id === op.blockId);
280
+ const noun = describeField(block?.type, textKeys[0]);
281
+ return [
282
+ { label: "Make it shorter", kind: "refine", source: "refinement", prior: 0.75, evidence: `refines ${noun}` },
283
+ { label: "Try a bolder tone", kind: "refine", source: "refinement", prior: 0.7, evidence: `refines ${noun}` },
284
+ { label: `Rewrite ${noun} again`, kind: "refine", source: "refinement", prior: 0.6, evidence: `refines ${noun}` }
285
+ ];
286
+ }
287
+ function describeField(blockType, key) {
288
+ const meta = blockType ? getBlockMeta(blockType) : undefined;
289
+ const label = meta?.fields?.[key]?.label;
290
+ if (label)
291
+ return `the ${label.toLowerCase()}`;
292
+ const humanized = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
293
+ return `the ${humanized}`;
294
+ }
295
+ /**
296
+ * Sections this site can draw and this page does not have.
297
+ *
298
+ * Read off `catalogueBlockTypes()` — never off the built-in names. A site's
299
+ * catalogue is the whole list, not a sample of a larger one.
300
+ */
301
+ export function structureCandidates(page, availableSlugs) {
302
+ const present = new Set(page.blocks.map((block) => block.type));
303
+ const candidates = [];
304
+ const addable = catalogueBlockTypes().filter((type) => !present.has(type) && !getBlockMeta(type)?.chrome);
305
+ /*
306
+ * Conversion first, then content, then media. On a page that has no way to
307
+ * convert, adding one is the change most likely to matter; layout and
308
+ * navigation blocks are chrome-adjacent and rarely what someone wants
309
+ * suggested. Without this the order is registration order, which is not a
310
+ * ranking — it is the order somebody wrote the imports in.
311
+ *
312
+ * The category is read off the catalogue, which is the only list that knows
313
+ * what this site can draw.
314
+ */
315
+ const byCategory = (category) => addable.filter((type) => getBlockMeta(type)?.category === category);
316
+ const ordered = [...byCategory("conversion"), ...byCategory("content"), ...byCategory("media")];
317
+ for (const type of ordered.slice(0, 3)) {
318
+ const name = getBlockMeta(type)?.displayName ?? type;
319
+ candidates.push({
320
+ label: `Add ${name}`,
321
+ prompt: `Add a ${name} section to this page`,
322
+ kind: "add-section",
323
+ source: "structure",
324
+ prior: 0.5,
325
+ evidence: `${type} is in this site's catalogue and not on this page`
326
+ });
327
+ }
328
+ if (availableSlugs && availableSlugs.length > 0 && !availableSlugs.includes("/about")) {
329
+ candidates.push({
330
+ label: "Create an /about page",
331
+ kind: "structure",
332
+ source: "structure",
333
+ prior: 0.35,
334
+ evidence: "site has no /about"
335
+ });
336
+ }
337
+ return candidates;
338
+ }
339
+ /**
340
+ * The site's own voice, when the user has told us what it is.
341
+ *
342
+ * `sitePurpose` and tone reach the planner's context pack already; until now
343
+ * the only pills that used them were the welcome ones, and only as a template.
344
+ */
345
+ export function contextCandidates(args) {
346
+ const candidates = [];
347
+ const tone = args.siteTone?.trim();
348
+ if (tone && tone.length < 30) {
349
+ candidates.push({
350
+ label: `Rewrite this page to sound more ${tone}`,
351
+ kind: "refine",
352
+ source: "context",
353
+ prior: 0.45,
354
+ evidence: `site tone is "${tone}"`
355
+ });
356
+ }
357
+ return candidates;
358
+ }
359
+ // ---------------------------------------------------------------------------
360
+ // Ranking
361
+ // ---------------------------------------------------------------------------
362
+ /**
363
+ * Score, deduplicate, diversify, cap.
364
+ *
365
+ * The cap is three rather than four, and returning fewer than three — or none —
366
+ * is a valid answer. Three real suggestions beat six of which one works, and an
367
+ * empty row leaves whatever the assistant said as the thing on screen, which is
368
+ * usually the more useful object anyway.
369
+ */
370
+ export function rank(candidates, ctx) {
371
+ const shown = ctx.shown ?? new Set();
372
+ const clicked = ctx.clicked ?? new Set();
373
+ const limit = ctx.limit ?? SUGGESTION_LIMIT;
374
+ const scored = [];
375
+ const seenIds = new Set();
376
+ for (const candidate of candidates) {
377
+ const label = candidate.label.trim();
378
+ const prompt = (candidate.prompt ?? label).trim();
379
+ const failure = validateSuggestion(prompt, { mode: ctx.mode });
380
+ if (failure)
381
+ continue;
382
+ const id = suggestionId(prompt);
383
+ if (seenIds.has(id))
384
+ continue;
385
+ seenIds.add(id);
386
+ /*
387
+ * Novelty. A pill the user was already offered and did not click is one
388
+ * they have declined once; offering it again every turn is how "Add
389
+ * Testimonials" came to appear on turn 1 and turn 9 of the same session.
390
+ * A clicked pill is dropped outright — it has been acted on, and the page
391
+ * has moved past it.
392
+ */
393
+ if (clicked.has(id))
394
+ continue;
395
+ const score = shown.has(id) ? candidate.prior - 0.3 : candidate.prior;
396
+ scored.push({
397
+ id,
398
+ label: label.length > MAX_LABEL_CHARS ? `${label.slice(0, MAX_LABEL_CHARS - 1).trimEnd()}…` : label,
399
+ prompt,
400
+ kind: candidate.kind,
401
+ source: candidate.source,
402
+ score,
403
+ evidence: candidate.evidence
404
+ });
405
+ }
406
+ scored.sort((a, b) => b.score - a.score);
407
+ /*
408
+ * Diversity. Three pills that all say "add a section" are one suggestion
409
+ * wearing three hats — the user's choice is not between them, it is between
410
+ * doing that and doing something else. At most one per kind until every kind
411
+ * has had a turn.
412
+ */
413
+ const picked = [];
414
+ const usedKinds = new Set();
415
+ for (const suggestion of scored) {
416
+ if (picked.length >= limit)
417
+ break;
418
+ if (usedKinds.has(suggestion.kind))
419
+ continue;
420
+ picked.push(suggestion);
421
+ usedKinds.add(suggestion.kind);
422
+ }
423
+ for (const suggestion of scored) {
424
+ if (picked.length >= limit)
425
+ break;
426
+ if (picked.some((entry) => entry.id === suggestion.id))
427
+ continue;
428
+ picked.push(suggestion);
429
+ }
430
+ return picked;
431
+ }
432
+ // ---------------------------------------------------------------------------
433
+ // Entry point
434
+ // ---------------------------------------------------------------------------
435
+ /**
436
+ * The path's own deterministic fallback, as candidates.
437
+ *
438
+ * Given a slightly lower prior than the model's: when both have an opinion the
439
+ * model read the user's sentence and this did not. It still competes, and on
440
+ * the clarification path — where it is answering a specific question — it
441
+ * routinely wins.
442
+ */
443
+ export function fallbackCandidates(raw) {
444
+ if (!raw || raw.length === 0)
445
+ return [];
446
+ return raw.map((text) => ({
447
+ label: text.trim(),
448
+ kind: inferKind(text),
449
+ source: "structure",
450
+ prior: 0.7,
451
+ evidence: "deterministic fallback for this path"
452
+ }));
453
+ }
454
+ export function buildSuggestions(ctx) {
455
+ /*
456
+ * A clarification asked a question. The only useful pills are answers to it,
457
+ * which is what the model's suggestions and the path's own fallback are —
458
+ * page gaps and section nudges are follow-ups to a *completed* edit and would
459
+ * change the subject while the question is still on screen.
460
+ */
461
+ if (ctx.mode === "clarification") {
462
+ return rank([...modelCandidates(ctx.modelSuggestions), ...fallbackCandidates(ctx.fallbackSuggestions)], ctx);
463
+ }
464
+ const candidates = [
465
+ ...modelCandidates(ctx.modelSuggestions),
466
+ ...fallbackCandidates(ctx.fallbackSuggestions),
467
+ ...refinementCandidates(ctx.plan, ctx.page),
468
+ ...pageGapCandidates(ctx.page),
469
+ ...structureCandidates(ctx.page, ctx.availableSlugs),
470
+ ...contextCandidates({ sitePurpose: ctx.sitePurpose, siteTone: ctx.siteTone })
471
+ ];
472
+ return rank(candidates, ctx);
473
+ }
474
+ /** The legacy wire shape: what `suggestions: string[]` has always carried. */
475
+ export function toLegacySuggestions(suggestions) {
476
+ return suggestions.map((suggestion) => suggestion.prompt);
477
+ }
@@ -164,6 +164,30 @@ export declare const pendingApprovalPlanBySession: Map<string, PendingApprovalPl
164
164
  */
165
165
  export type ImageSourcePreference = "unsplash" | "genai" | "either";
166
166
  export declare const imageSourcePreferenceBySession: Map<string, ImageSourcePreference>;
167
+ /**
168
+ * Which suggestion pills this session has already been shown, and which it
169
+ * clicked.
170
+ *
171
+ * A pill offered and not clicked is one the user has declined once. Nothing
172
+ * recorded that, so the same three chips were re-derived from the same page
173
+ * state every turn and "Add Testimonials" appeared on turn 1 and again on turn
174
+ * 9 of the same conversation. The ranker reads these to decay a repeat and to
175
+ * drop a suggestion that has been acted on.
176
+ *
177
+ * Ephemeral, like its neighbours above: a chat session is the right lifetime
178
+ * for "have I already offered you this", and there is nothing here worth
179
+ * carrying across a restart. Capped so a long session cannot grow it without
180
+ * bound.
181
+ */
182
+ export declare const SUGGESTION_MEMORY_CAP = 60;
183
+ export type SuggestionMemory = {
184
+ shown: string[];
185
+ clicked: string[];
186
+ };
187
+ export declare const suggestionMemoryBySession: Map<string, SuggestionMemory>;
188
+ export declare function getSuggestionMemory(session: string): SuggestionMemory;
189
+ export declare function recordSuggestionsShown(session: string, ids: string[]): void;
190
+ export declare function recordSuggestionClicked(session: string, id: string): void;
167
191
  export declare const publishStatusBySession: Map<string, PublishTracker>;
168
192
  /**
169
193
  * What the adapter behind a session says it can honour, recorded when the
@@ -99,6 +99,44 @@ export const pendingClarificationBySession = new Map();
99
99
  export const chatHistoryBySession = new Map();
100
100
  export const pendingApprovalPlanBySession = new Map();
101
101
  export const imageSourcePreferenceBySession = new Map();
102
+ /**
103
+ * Which suggestion pills this session has already been shown, and which it
104
+ * clicked.
105
+ *
106
+ * A pill offered and not clicked is one the user has declined once. Nothing
107
+ * recorded that, so the same three chips were re-derived from the same page
108
+ * state every turn and "Add Testimonials" appeared on turn 1 and again on turn
109
+ * 9 of the same conversation. The ranker reads these to decay a repeat and to
110
+ * drop a suggestion that has been acted on.
111
+ *
112
+ * Ephemeral, like its neighbours above: a chat session is the right lifetime
113
+ * for "have I already offered you this", and there is nothing here worth
114
+ * carrying across a restart. Capped so a long session cannot grow it without
115
+ * bound.
116
+ */
117
+ export const SUGGESTION_MEMORY_CAP = 60;
118
+ export const suggestionMemoryBySession = new Map();
119
+ export function getSuggestionMemory(session) {
120
+ return suggestionMemoryBySession.get(session) ?? { shown: [], clicked: [] };
121
+ }
122
+ function rememberSuggestionIds(session, field, ids) {
123
+ if (ids.length === 0)
124
+ return;
125
+ const memory = suggestionMemoryBySession.get(session) ?? { shown: [], clicked: [] };
126
+ // Newest last, deduplicated, oldest evicted first — so a suggestion that
127
+ // scrolled out of memory long ago can legitimately be offered again.
128
+ const merged = [...memory[field].filter((id) => !ids.includes(id)), ...ids];
129
+ suggestionMemoryBySession.set(session, {
130
+ ...memory,
131
+ [field]: merged.slice(-SUGGESTION_MEMORY_CAP)
132
+ });
133
+ }
134
+ export function recordSuggestionsShown(session, ids) {
135
+ rememberSuggestionIds(session, "shown", ids);
136
+ }
137
+ export function recordSuggestionClicked(session, id) {
138
+ rememberSuggestionIds(session, "clicked", [id]);
139
+ }
102
140
  export const publishStatusBySession = new Map();
103
141
  /**
104
142
  * What the adapter behind a session says it can honour, recorded when the
@@ -178,6 +216,7 @@ export function forgetSession(sessionKey) {
178
216
  // left behind here would rehydrate into the *next* session with this id.
179
217
  discardPendingProposalsForSession(sessionKey);
180
218
  imageSourcePreferenceBySession.delete(sessionKey);
219
+ suggestionMemoryBySession.delete(sessionKey);
181
220
  publishStatusBySession.delete(sessionKey);
182
221
  capabilitiesBySession.delete(sessionKey);
183
222
  return existed;
@@ -1134,7 +1173,12 @@ export function evictStaleEphemeralMaps() {
1134
1173
  }
1135
1174
  }
1136
1175
  // Maps without timestamps — cap at EPHEMERAL_MAP_CAP, drop oldest-inserted entries
1137
- for (const map of [continuationChainBySession, pendingClarificationBySession, imageSourcePreferenceBySession]) {
1176
+ for (const map of [
1177
+ continuationChainBySession,
1178
+ pendingClarificationBySession,
1179
+ imageSourcePreferenceBySession,
1180
+ suggestionMemoryBySession
1181
+ ]) {
1138
1182
  if (map.size > EPHEMERAL_MAP_CAP) {
1139
1183
  const excess = map.size - EPHEMERAL_MAP_CAP;
1140
1184
  let dropped = 0;
@@ -49,6 +49,20 @@ export type ChatTelemetryEntry = {
49
49
  toolAttempts?: number;
50
50
  toolErrorCode?: string;
51
51
  correlationId?: string;
52
+ /**
53
+ * Suggestion pills: what was offered on this turn, and whether this turn was
54
+ * itself a click on one.
55
+ *
56
+ * There was no telemetry of any kind on pills before this — `grep suggestion`
57
+ * across this directory returned nothing — so which chips get clicked, which
58
+ * are ignored, and whether a click produced any operations were all unknown.
59
+ * That made "the suggestions should be smarter" an unfalsifiable claim.
60
+ * `suggestionIds` joins to `clickedSuggestionId` on a later turn, and the
61
+ * `opCount` already on that turn says whether the click was worth the click.
62
+ */
63
+ suggestionIds?: string[];
64
+ suggestionSources?: string[];
65
+ clickedSuggestionId?: string;
52
66
  };
53
67
  type Logger = {
54
68
  info: (payload: Record<string, unknown>, message?: string) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -22,8 +22,8 @@
22
22
  "openai": "^4.87.1",
23
23
  "sharp": "^0.35.4",
24
24
  "zod": "^4.3.6",
25
- "@avocadostudio-ai/migration-sdk": "^0.13.1",
26
- "@avocadostudio-ai/shared": "^0.13.1"
25
+ "@avocadostudio-ai/migration-sdk": "^0.14.0",
26
+ "@avocadostudio-ai/shared": "^0.14.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@anthropic-ai/claude-agent-sdk": "^0.3.220",