@avocadostudio-ai/orchestrator-core 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/chat/anthropic-planner.d.ts +8 -0
  2. package/dist/chat/anthropic-planner.js +166 -12
  3. package/dist/chat/chat-pipeline-translation.d.ts +13 -0
  4. package/dist/chat/chat-pipeline-translation.js +109 -45
  5. package/dist/chat/chat-pipeline.d.ts +1 -1
  6. package/dist/chat/chat-pipeline.js +312 -54
  7. package/dist/chat/gemini-planner.d.ts +2 -0
  8. package/dist/chat/gemini-planner.js +2 -1
  9. package/dist/chat/planner-types.d.ts +15 -0
  10. package/dist/chat/planner-types.js +2 -2
  11. package/dist/chat/planner.d.ts +12 -0
  12. package/dist/chat/planner.js +16 -2
  13. package/dist/chat/prompts.d.ts +5 -0
  14. package/dist/chat/prompts.js +92 -9
  15. package/dist/chat/translation-chunking.d.ts +124 -0
  16. package/dist/chat/translation-chunking.js +371 -0
  17. package/dist/checks/field-walk.d.ts +42 -0
  18. package/dist/checks/field-walk.js +198 -0
  19. package/dist/checks/index.d.ts +5 -0
  20. package/dist/checks/index.js +4 -0
  21. package/dist/checks/page-weight.d.ts +22 -0
  22. package/dist/checks/page-weight.js +200 -0
  23. package/dist/checks/rules-draft.d.ts +2 -0
  24. package/dist/checks/rules-draft.js +439 -0
  25. package/dist/checks/run-checks.d.ts +42 -0
  26. package/dist/checks/run-checks.js +159 -0
  27. package/dist/checks/session-runner.d.ts +19 -0
  28. package/dist/checks/session-runner.js +99 -0
  29. package/dist/checks/types.d.ts +109 -0
  30. package/dist/checks/types.js +1 -0
  31. package/dist/cms/adapter.d.ts +74 -1
  32. package/dist/cms/adapter.js +1 -0
  33. package/dist/cms/index.d.ts +1 -1
  34. package/dist/cms/index.js +1 -1
  35. package/dist/cms/media-sources.d.ts +29 -1
  36. package/dist/cms/media-sources.js +188 -7
  37. package/dist/durable/durable-store-singleton.d.ts +37 -0
  38. package/dist/durable/durable-store-singleton.js +179 -0
  39. package/dist/durable/finding-impact.d.ts +30 -0
  40. package/dist/durable/finding-impact.js +53 -0
  41. package/dist/durable/in-memory-durable-store.d.ts +203 -0
  42. package/dist/durable/in-memory-durable-store.js +363 -0
  43. package/dist/durable/index.d.ts +5 -0
  44. package/dist/durable/index.js +4 -0
  45. package/dist/durable/pending-plan-store.d.ts +28 -0
  46. package/dist/durable/pending-plan-store.js +156 -0
  47. package/dist/durable/sqlite-durable-store.d.ts +71 -0
  48. package/dist/durable/sqlite-durable-store.js +631 -0
  49. package/dist/durable/types.d.ts +265 -0
  50. package/dist/durable/types.js +1 -0
  51. package/dist/handler/create-orchestrator.d.ts +4 -0
  52. package/dist/handler/create-orchestrator.js +283 -32
  53. package/dist/http/audio-actions.d.ts +1 -1
  54. package/dist/http/checks-actions.d.ts +39 -0
  55. package/dist/http/checks-actions.js +122 -0
  56. package/dist/http/history-actions.d.ts +44 -1
  57. package/dist/http/history-actions.js +122 -0
  58. package/dist/http/image-generate-actions.d.ts +2 -2
  59. package/dist/http/ops-actions.d.ts +2 -2
  60. package/dist/http/publish-actions.d.ts +15 -4
  61. package/dist/http/publish-actions.js +3 -3
  62. package/dist/http/restore-actions.d.ts +3 -3
  63. package/dist/http/screenshot-actions.d.ts +2 -2
  64. package/dist/http/session-actions.d.ts +1 -1
  65. package/dist/http/telemetry-feedback-actions.d.ts +2 -2
  66. package/dist/http/unsplash-actions.d.ts +2 -2
  67. package/dist/http/variations-actions.d.ts +2 -2
  68. package/dist/index.d.ts +9 -2
  69. package/dist/index.js +28 -1
  70. package/dist/nlp/deterministic-planner-context.d.ts +16 -0
  71. package/dist/nlp/deterministic-planner-context.js +33 -7
  72. package/dist/nlp/intent-detection.d.ts +16 -0
  73. package/dist/nlp/intent-detection.js +15 -1
  74. package/dist/nlp/plan-normalizer.js +66 -32
  75. package/dist/ops/destructive-action-gate.js +7 -2
  76. package/dist/ops/ops-engine.d.ts +12 -1
  77. package/dist/ops/ops-engine.js +41 -14
  78. package/dist/publish/publish-helpers.d.ts +12 -2
  79. package/dist/publish/publish-helpers.js +10 -3
  80. package/dist/publish/publish-selection.d.ts +84 -0
  81. package/dist/publish/publish-selection.js +113 -0
  82. package/dist/publish/publish-target-registry.js +1 -1
  83. package/dist/publish/publish-target.d.ts +1 -1
  84. package/dist/publish/targets/git.js +2 -2
  85. package/dist/state/session-state.js +8 -1
  86. package/dist/state/site-assets.d.ts +41 -0
  87. package/dist/state/site-assets.js +40 -0
  88. package/package.json +3 -3
@@ -3,13 +3,15 @@ import { blockManifestSchema } from "@avocadostudio-ai/shared";
3
3
  import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, isVariationRequestMessage, variationVerbIntent, resolveEffectiveSlug, throwIfCanceled, raceCancel, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
4
4
  import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isAdviceQuery, adviceResponse, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
5
5
  import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
6
- import { versions, pendingClarificationBySession, chatHistoryBySession, pendingApprovalPlanBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
7
- import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isDeterministicRepairEligible, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
6
+ import { versions, pendingClarificationBySession, chatHistoryBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
7
+ import { getSiteAssets } from "../state/site-assets.js";
8
+ import { loadPendingPlan, savePendingPlan, clearPendingPlan } from "../durable/pending-plan-store.js";
9
+ import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isRepairEligibleCategory, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
8
10
  import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
9
11
  import { clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
10
12
  import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
11
13
  import { isDemoModeEnabled, splitDemoOps, getDemoAllowedBlockTypes } from "../demo-mode.js";
12
- import { isCancelError as _isCancelError, OperationError } from "../errors.js";
14
+ import { isCancelError as _isCancelError, OperationError, OrchestrationError } from "../errors.js";
13
15
  import { acquireSessionLock } from "../state/session-lock.js";
14
16
  const ERROR_CATEGORIES = [
15
17
  "schema_violation",
@@ -38,7 +40,7 @@ import { executeToolCall } from "../tools/runtime.js";
38
40
  // Re-exports from extracted modules (for backwards compat with external importers)
39
41
  // ---------------------------------------------------------------------------
40
42
  export { sentenceCase, firstUrlFromText, preferredImageAltText, collectMentionedSlugsFromPlan, collectMentionedSlugsFromOps, normalizePlanCopyForUi, futureToPastTense } from "./chat-pipeline-ui.js";
41
- export { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, isNonEmptyString, findFullPageTranslationCoverageGap, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest } from "./chat-pipeline-translation.js";
43
+ export { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, isNonEmptyString, findFullPageTranslationCoverageGap, buildFullPageTranslationChecklist, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest } from "./chat-pipeline-translation.js";
42
44
  export { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, shouldEnableReasoningForMessage } from "./chat-pipeline-context.js";
43
45
  export { isRewriteLikeMessage, isPerformanceAwareMessage, isLikelyTextField, collectChangedTextFields, buildMetaChangeLogEntries, buildAiInsightChanges, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, deterministicSelectedTextRewritePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.js";
44
46
  export { blockHasImageUrlProp, parsePath, getValueAtPath, setValueAtPath, deleteValueAtPath, extractIndexedQueries, extractReferencedItemIndices, blockSupportsImageAtPath, detectImagePaths, imageQueryFromItem, shouldPopulateAllChildImages, findImageTargets, rewriteAddBlockToChildImageUpdate, withUnsplashHeroImage, shouldResolveCreatePageHeroImage, resolveHeroImageForCreatePage, detectImageOps } from "./chat-pipeline-image.js";
@@ -46,6 +48,7 @@ export { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGenerating
46
48
  // Internal imports from extracted modules (used by this file)
47
49
  import { collectMentionedSlugsFromPlan, normalizePlanCopyForUi, futureToPastTense, pastToFutureTense } from "./chat-pipeline-ui.js";
48
50
  import { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, findFullPageTranslationCoverageGap, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest } from "./chat-pipeline-translation.js";
51
+ import { planTranslationChunks, shouldChunkTranslation, generateChunkedTranslationPlan } from "./translation-chunking.js";
49
52
  import { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, classifyMessageComplexity, isRouterPlanTooShallow, shouldEnableReasoningForMessage } from "./chat-pipeline-context.js";
50
53
  import { buildAiInsightChanges, buildMetaChangeLogEntries, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.js";
51
54
  import { getValueAtPath, setValueAtPath, deleteValueAtPath, blockSupportsImageAtPath, detectImageOps, rewriteAddBlockToChildImageUpdate, withUnsplashHeroImage, resolveHeroImageForCreatePage } from "./chat-pipeline-image.js";
@@ -260,7 +263,7 @@ export async function runChatPipeline(ctx, body, options) {
260
263
  }
261
264
  const siteCapabilities = parsedCapabilities.data;
262
265
  if (executionMode === "discard_pending_plan") {
263
- const existing = pendingApprovalPlanBySession.get(body.session);
266
+ const existing = await loadPendingPlan(body.session);
264
267
  if (!existing) {
265
268
  const defaultProvider = ctx.availableProviders[0] ?? "openai";
266
269
  const defaultModelKey = process.env.OPENAI_MODEL_KEY ?? "balanced";
@@ -280,7 +283,7 @@ export async function runChatPipeline(ctx, body, options) {
280
283
  if (body.pendingPlanId && body.pendingPlanId !== existing.id) {
281
284
  return { code: 409, payload: { error: "pending plan mismatch" } };
282
285
  }
283
- pendingApprovalPlanBySession.delete(body.session);
286
+ await clearPendingPlan(body.session, "discarded");
284
287
  return {
285
288
  code: 200,
286
289
  payload: {
@@ -334,12 +337,26 @@ export async function runChatPipeline(ctx, body, options) {
334
337
  }
335
338
  const sanitizedMessage = sanitizeMessageForPlanning(body.message ?? "");
336
339
  const pageDirectory = buildPageDirectory(body.session);
340
+ /*
341
+ * The site's documents ride along with the page list.
342
+ *
343
+ * Without them "link the winter menu" has no referent: the planner knows the
344
+ * pages and the block schemas and nothing about the fifteen PDFs the site
345
+ * hosts, so it writes a path that looks right and is not — which is exactly
346
+ * how a link to a misspelled filename gets into a page. `undefined` when the
347
+ * site cannot enumerate its documents, in which case the block simply omits
348
+ * the section rather than claiming the site has none.
349
+ */
350
+ const siteDocuments = await getSiteAssets();
337
351
  const siteContextBlock = buildSiteContextBlock({
338
352
  sitePurpose: body.sitePurpose,
339
353
  siteHosting: body.siteHosting,
340
354
  businessContext: body.businessContext,
341
355
  siteContext: body.siteContext,
342
- pageDirectory: pageDirectory || undefined
356
+ pageDirectory: pageDirectory || undefined,
357
+ ...(siteDocuments?.length
358
+ ? { documents: siteDocuments.map((d) => ({ path: d.path, ...(d.name ? { name: d.name } : {}) })) }
359
+ : {})
343
360
  });
344
361
  // Site context is now passed to the LLM system prompt (cacheable) instead of the user message.
345
362
  let plannerMessage = plannerMessageWithPendingContext(body.session, sanitizedMessage);
@@ -731,7 +748,7 @@ export async function runChatPipeline(ctx, body, options) {
731
748
  totalDurationMs: 0
732
749
  });
733
750
  if (executionMode === "auto") {
734
- const existingPendingPlan = pendingApprovalPlanBySession.get(body.session);
751
+ const existingPendingPlan = await loadPendingPlan(body.session);
735
752
  const normalizedIncomingMessage = typeof body.message === "string" ? body.message.trim() : "";
736
753
  const normalizedPendingMessage = typeof existingPendingPlan?.originalMessage === "string"
737
754
  ? existingPendingPlan.originalMessage.trim()
@@ -1041,7 +1058,7 @@ export async function runChatPipeline(ctx, body, options) {
1041
1058
  strictJsonEnabled: isStrictJsonResponseEnabled()
1042
1059
  };
1043
1060
  const guardrailFailureResponse = (args) => {
1044
- const category = classifyGuardrailError(args.reason);
1061
+ const category = args.category ?? classifyGuardrailError(args.reason);
1045
1062
  ctx.chatTelemetry.push({
1046
1063
  id: chatRequestId,
1047
1064
  at: new Date().toISOString(),
@@ -1086,7 +1103,7 @@ export async function runChatPipeline(ctx, body, options) {
1086
1103
  status: "validation_error",
1087
1104
  summary: "I could not apply that change safely.",
1088
1105
  changes: [],
1089
- validationErrors: [formatValidationError(args.reason)],
1106
+ validationErrors: [formatValidationError(args.reason, category)],
1090
1107
  previewVersion: versions.get(body.session) ?? 0,
1091
1108
  plannerSource: args.source,
1092
1109
  modelUsed,
@@ -1098,6 +1115,71 @@ export async function runChatPipeline(ctx, body, options) {
1098
1115
  let usedNativeUnsplashTool = false;
1099
1116
  let usedNativeImageTool = false;
1100
1117
  let deferredNativeImageCalls = [];
1118
+ const streamApplyState = {
1119
+ appliedCount: 0,
1120
+ /**
1121
+ * Ops the stream actually took responsibility for — applied *or* skipped as
1122
+ * a benign no-op. `appliedCount` alone cannot answer "did the stream carry
1123
+ * the whole plan", because a legitimately unchanged op is not a gap.
1124
+ */
1125
+ processedCount: 0,
1126
+ failedAtIndex: null,
1127
+ hasStructuralOps: false,
1128
+ /** Snapshots per slug — `null` means the page didn't exist before streaming (needs removal on rollback). */
1129
+ rollbackSnapshots: new Map()
1130
+ };
1131
+ /*
1132
+ * Undo everything the streamed per-op apply landed on the draft.
1133
+ *
1134
+ * Declared up here, above `respondFromPlan`, because the invariant it exists
1135
+ * to hold is one only `respondFromPlan` can see: **a plan held for approval
1136
+ * has applied nothing.**
1137
+ *
1138
+ * The stream decides what to defer one op at a time, from the op alone, and
1139
+ * three of the four things that hold a plan are properties of the whole plan
1140
+ * — `multi_page_plan` fires on touched-slug count with no destructive op
1141
+ * required, and the compound-image hold fires on a plan that is not
1142
+ * image-only. An additive six-op plan trips both defer lists' blind spot: it
1143
+ * streams clean onto the page, and *then* the gate holds it. The user is
1144
+ * shown an "Approve" card for content the draft already contains, and the
1145
+ * `undoSnapshot` threaded into the applied path never reaches the held path,
1146
+ * so they cannot even undo it. Re-running the same prompt then collides with
1147
+ * the leftovers — `Block id b_featuregrid_wellness already exists` — which is
1148
+ * the visible symptom and not the bug.
1149
+ *
1150
+ * Rolling back where the decision is made, rather than teaching the per-op
1151
+ * defer list to predict it, is what keeps the invariant true for the next
1152
+ * hold condition somebody adds.
1153
+ */
1154
+ const rollbackStreamedApply = (reason) => {
1155
+ if (streamApplyState.rollbackSnapshots.size === 0)
1156
+ return [];
1157
+ const rolledBackSlugs = [];
1158
+ for (const [slug, snapshot] of streamApplyState.rollbackSnapshots) {
1159
+ if (snapshot) {
1160
+ setPage(body.session, { ...snapshot, slug });
1161
+ }
1162
+ else {
1163
+ removePage(body.session, slug);
1164
+ }
1165
+ rolledBackSlugs.push(slug);
1166
+ }
1167
+ ctx.log.warn({
1168
+ event: "streamed_op_rollback",
1169
+ chatRequestId,
1170
+ appliedCount: streamApplyState.appliedCount,
1171
+ processedCount: streamApplyState.processedCount,
1172
+ failedAtIndex: streamApplyState.failedAtIndex,
1173
+ rolledBackSlugs
1174
+ }, `Rolled back ${rolledBackSlugs.length} page(s) — ${reason}`);
1175
+ streamApplyState.appliedCount = 0;
1176
+ streamApplyState.processedCount = 0;
1177
+ streamApplyState.rollbackSnapshots.clear();
1178
+ // The client already rendered every streamed op. Bump so it refetches the
1179
+ // draft it is actually holding rather than the one it was shown.
1180
+ bumpVersion(body.session);
1181
+ return rolledBackSlugs;
1182
+ };
1101
1183
  const respondFromPlan = async (plan, source, applyMode = "apply_now", optionsOverride, plannerTier) => {
1102
1184
  if (plannerTier)
1103
1185
  activePlannerTier = plannerTier;
@@ -1472,7 +1554,12 @@ export async function runChatPipeline(ctx, body, options) {
1472
1554
  const forcedInfo = infoResponse({ body, current, plannerSource: source, modelUsed, modelKey });
1473
1555
  return { done: true, response: forcedInfo };
1474
1556
  }
1475
- if (translationScope === "page") {
1557
+ // The gate gets one shot. On the repair pass it is skipped entirely: by then
1558
+ // the model has already been handed the explicit checklist plus the list of
1559
+ // fields it missed, and a still-imperfect plan is worth applying. Failing it
1560
+ // twice would return 400 and discard every translation that did land —
1561
+ // strictly worse for the user than a page that is 90% translated.
1562
+ if (translationScope === "page" && !optionsOverride?.skipTranslationCoverageGate) {
1476
1563
  const translationCoverageGap = findFullPageTranslationCoverageGap({
1477
1564
  plan: resolvedPlan,
1478
1565
  message: plannerMessage,
@@ -1480,7 +1567,7 @@ export async function runChatPipeline(ctx, body, options) {
1480
1567
  slug: effectiveSlug
1481
1568
  });
1482
1569
  if (translationCoverageGap)
1483
- return { done: false, reason: translationCoverageGap };
1570
+ return { done: false, reason: translationCoverageGap, reasonCategory: undefined };
1484
1571
  }
1485
1572
  const explicitCtaCoverageGap = findExplicitCtaTargetCoverageGap({
1486
1573
  plan: resolvedPlan,
@@ -1489,7 +1576,7 @@ export async function runChatPipeline(ctx, body, options) {
1489
1576
  slug: effectiveSlug
1490
1577
  });
1491
1578
  if (explicitCtaCoverageGap)
1492
- return { done: false, reason: explicitCtaCoverageGap };
1579
+ return { done: false, reason: explicitCtaCoverageGap, reasonCategory: undefined };
1493
1580
  // Tier-1 destructive-action gate. Hold any edit_plan containing destructive
1494
1581
  // ops (remove_page on a page with content, multi-page scope, bulk deletes)
1495
1582
  // for explicit approval — undo protects recovery but not accidental intent.
@@ -1642,6 +1729,20 @@ export async function runChatPipeline(ctx, body, options) {
1642
1729
  };
1643
1730
  }
1644
1731
  if (effectiveApplyMode === "plan_only") {
1732
+ /*
1733
+ * A held plan has applied nothing. See `rollbackStreamedApply` for why
1734
+ * this cannot be decided op-by-op while the plan is still streaming.
1735
+ *
1736
+ * This has to run before the dry-run below, not only before the response:
1737
+ * the preview is computed against the live draft, so on a stream that
1738
+ * already landed its `add_block`s the dry run would report every one of
1739
+ * them as a duplicate-id failure and render an approval card claiming the
1740
+ * plan cannot be applied.
1741
+ */
1742
+ const rolledBack = rollbackStreamedApply("plan held for approval");
1743
+ if (rolledBack.length > 0) {
1744
+ options?.onRollbackStarted?.({ appliedCount: rolledBack.length, reason: "plan_held_for_approval" });
1745
+ }
1645
1746
  // Dry-run the held plan up front to build the structured before→after
1646
1747
  // preview the approval card renders AND to learn whether it changes anything.
1647
1748
  // Best-effort: a preview failure must never block showing the plan.
@@ -1666,7 +1767,7 @@ export async function runChatPipeline(ctx, body, options) {
1666
1767
  // with a "Will change X" summary the draft never honours (#23). Return an
1667
1768
  // honest no-effective-change response instead.
1668
1769
  if (planPreview && planPreview.appliedCount === 0 && planPreview.failedCount === 0 && planPreview.skippedCount > 0) {
1669
- pendingApprovalPlanBySession.delete(body.session);
1770
+ await clearPendingPlan(body.session, "discarded");
1670
1771
  const onlyUnchanged = planPreview.opResults.every((r) => r.status !== "skipped" || r.reason === "unchanged_value");
1671
1772
  const noopSummary = onlyUnchanged
1672
1773
  ? "No changes needed — that content is already set."
@@ -1716,7 +1817,7 @@ export async function runChatPipeline(ctx, body, options) {
1716
1817
  // so the copy matches the "Approve plan" UX.
1717
1818
  const approvalSummary = pastToFutureTense(resolvedPlan.summary_for_user);
1718
1819
  const approvalChangeLog = resolvedPlan.change_log.map(pastToFutureTense);
1719
- pendingApprovalPlanBySession.set(body.session, {
1820
+ await savePendingPlan(body.session, {
1720
1821
  id: pendingPlanId,
1721
1822
  createdAt: new Date().toISOString(),
1722
1823
  promptHash,
@@ -1795,7 +1896,7 @@ export async function runChatPipeline(ctx, body, options) {
1795
1896
  }
1796
1897
  if (resolvedPlan.ops.length === 0) {
1797
1898
  pendingClarificationBySession.delete(body.session);
1798
- pendingApprovalPlanBySession.delete(body.session);
1899
+ await clearPendingPlan(body.session, "discarded");
1799
1900
  /*
1800
1901
  * Zero ops is not the same fact as "already correct".
1801
1902
  *
@@ -2436,7 +2537,20 @@ export async function runChatPipeline(ctx, body, options) {
2436
2537
  pushUndo(body.session, effectiveSlug, undoSnapshot);
2437
2538
  }
2438
2539
  pendingClarificationBySession.delete(body.session);
2439
- pendingApprovalPlanBySession.delete(body.session);
2540
+ /*
2541
+ * Only the plan we actually applied is `approved`.
2542
+ *
2543
+ * This path is shared: a plain `auto` turn that succeeds runs it too. A
2544
+ * held `plan_ready` proposal the user never looked at — they typed
2545
+ * something else instead — was superseded, not approved, and recording
2546
+ * otherwise poisons the single number this outcome exists to produce.
2547
+ */
2548
+ if (optionsOverride?.approvedPlanId) {
2549
+ await clearPendingPlan(body.session, "approved", optionsOverride.approvedPlanId);
2550
+ }
2551
+ else {
2552
+ await clearPendingPlan(body.session, "discarded");
2553
+ }
2440
2554
  const planUpdatedSlug = pickUpdatedSlug(body.session, effectiveSlug, resolvedPlan.ops);
2441
2555
  const updatedSlug = planUpdatedSlug ?? (effectiveSlug !== requestedSlug ? effectiveSlug : undefined);
2442
2556
  pushRecentEdit(body.session, { slug: updatedSlug ?? effectiveSlug, summary: futureToPastTense(resolvedPlan.summary_for_user), ops: resolvedPlan.ops });
@@ -2531,8 +2645,25 @@ export async function runChatPipeline(ctx, body, options) {
2531
2645
  applyDurationMs = Date.now() - applyStartedAtMs;
2532
2646
  }
2533
2647
  const reason = toErrorDetail(error);
2648
+ /*
2649
+ * The category the throw already carried, in preference to one guessed
2650
+ * back out of its message.
2651
+ *
2652
+ * The ops engine labels `Block id … already exists` a `schema_violation`
2653
+ * and is right to. By the time it reached here it was a bare string, and
2654
+ * `classifyGuardrailError` matches none of its schema_violation keywords
2655
+ * against those words — so the failure arrived as `internal_error`, which
2656
+ * `isDeterministicRepairEligible` refuses. The one plan a repair pass
2657
+ * could have trivially fixed (rename the block, apply the other five ops)
2658
+ * was the one plan that never reached it, and the user got "I could not
2659
+ * apply that change safely" instead.
2660
+ *
2661
+ * Keyword matching stays as the fallback for reasons that are only ever
2662
+ * strings — planner output failures, coverage gates.
2663
+ */
2664
+ const reasonCategory = error instanceof OrchestrationError ? error.category : undefined;
2534
2665
  if (isNoEffectiveChangeError(reason)) {
2535
- pendingApprovalPlanBySession.delete(body.session);
2666
+ await clearPendingPlan(body.session, "discarded");
2536
2667
  ctx.chatTelemetry.push({
2537
2668
  id: chatRequestId,
2538
2669
  at: new Date().toISOString(),
@@ -2618,15 +2749,15 @@ export async function runChatPipeline(ctx, body, options) {
2618
2749
  promptLength: plannerMessage.length,
2619
2750
  outcome: "apply_failed",
2620
2751
  reason: reason.slice(0, 300),
2621
- reasonCategory: classifyGuardrailError(reason),
2752
+ reasonCategory: reasonCategory ?? classifyGuardrailError(reason),
2622
2753
  plannerTier,
2623
2754
  ...timingFields()
2624
2755
  });
2625
- return { done: false, reason };
2756
+ return { done: false, reason, reasonCategory };
2626
2757
  }
2627
2758
  };
2628
2759
  if (executionMode === "apply_pending_plan") {
2629
- const pending = pendingApprovalPlanBySession.get(body.session);
2760
+ const pending = await loadPendingPlan(body.session);
2630
2761
  if (!pending) {
2631
2762
  const fallbackMessage = typeof body.message === "string" ? body.message.trim() : "";
2632
2763
  if (fallbackMessage.length > 0) {
@@ -2724,7 +2855,7 @@ export async function runChatPipeline(ctx, body, options) {
2724
2855
  });
2725
2856
  imageResolutionDurationMs += Date.now() - imageResolutionStartMs;
2726
2857
  }
2727
- const approvedOutcome = await respondFromPlan(approvalPlan, pending.source, "apply_now", { preResolvedPlan: true });
2858
+ const approvedOutcome = await respondFromPlan(approvalPlan, pending.source, "apply_now", { preResolvedPlan: true, approvedPlanId: pending.id });
2728
2859
  if (approvedOutcome.done)
2729
2860
  return approvedOutcome.response;
2730
2861
  return guardrailFailureResponse({ reason: approvedOutcome.reason, source: pending.source });
@@ -2732,7 +2863,7 @@ export async function runChatPipeline(ctx, body, options) {
2732
2863
  catch (error) {
2733
2864
  const reason = toErrorDetail(error);
2734
2865
  ctx.log.error({ event: "apply_pending_plan_error", chatRequestId, error: reason }, "Pending plan execution failed");
2735
- pendingApprovalPlanBySession.delete(body.session);
2866
+ await clearPendingPlan(body.session, "discarded");
2736
2867
  ctx.chatTelemetry.push({
2737
2868
  id: chatRequestId,
2738
2869
  at: new Date().toISOString(),
@@ -3093,7 +3224,67 @@ export async function runChatPipeline(ctx, body, options) {
3093
3224
  // `plannerSource === "demo"` is handled earlier; here it's always a real planner.
3094
3225
  const planner = plannerRegistry.get(plannerSource);
3095
3226
  const supportsNativeTools = planner.supportsNativeTools;
3096
- const generatePlanImpl = planner.generatePlan;
3227
+ // A full-page translation is a fan-out, not one long request: its output length
3228
+ // scales with the page, and one request's output is strictly serial. Splitting
3229
+ // it into independent per-block-group requests turns wall clock from the sum of
3230
+ // the blocks into the slowest chunk, and keeps every chunk far from the token
3231
+ // ceiling that made a single request an all-or-nothing bet. Substituting it
3232
+ // here — rather than at each call site — keeps the planner's contract identical
3233
+ // for the pipeline: same args in, one merged plan out.
3234
+ const translationChunks = translationScope === "page" ? planTranslationChunks({ page: current }) : [];
3235
+ const chunkTranslation = translationScope === "page" && shouldChunkTranslation(current, translationChunks);
3236
+ if (chunkTranslation) {
3237
+ ctx.log.info({
3238
+ event: "translation_chunking_enabled",
3239
+ slug: effectiveSlug,
3240
+ chunkCount: translationChunks.length,
3241
+ blocksPerChunk: translationChunks.map((chunk) => chunk.blockIds.length),
3242
+ bytesPerChunk: translationChunks.map((chunk) => chunk.bytes)
3243
+ }, "Full-page translation will be planned in parallel chunks");
3244
+ }
3245
+ const generatePlanImpl = chunkTranslation
3246
+ ? (plannerArgs) =>
3247
+ // A repair pass is already scoped to the fields the first pass missed —
3248
+ // it is small, targeted, and must see the whole page to fix cross-block
3249
+ // gaps, so it runs unchunked.
3250
+ plannerArgs.feedback
3251
+ ? planner.generatePlan(plannerArgs)
3252
+ : generateChunkedTranslationPlan({
3253
+ plannerArgs,
3254
+ chunks: translationChunks,
3255
+ generate: planner.generatePlan,
3256
+ log: ctx.log,
3257
+ onChunksSettled: (outcomes) => {
3258
+ const failed = outcomes.filter((outcome) => !outcome.ok);
3259
+ plannerContextTelemetryFields.translationChunkCount = outcomes.length;
3260
+ plannerContextTelemetryFields.translationChunksFailed = failed.length;
3261
+ // The slowest chunk is the request's latency. Logging every chunk's
3262
+ // wall clock next to its weight is what makes the split tunable —
3263
+ // a balanced split whose slowest chunk is no faster means the
3264
+ // bottleneck is somewhere other than chunk size.
3265
+ ctx.log.info({
3266
+ event: "translation_chunks_settled",
3267
+ slug: effectiveSlug,
3268
+ slowestMs: Math.max(...outcomes.map((outcome) => outcome.durationMs)),
3269
+ chunks: outcomes.map((outcome) => ({
3270
+ index: outcome.index,
3271
+ ms: outcome.durationMs,
3272
+ bytes: outcome.bytes,
3273
+ outputTokens: outcome.outputTokens,
3274
+ ops: outcome.opCount,
3275
+ attempts: outcome.attempts
3276
+ }))
3277
+ }, "Translation chunks settled");
3278
+ if (failed.length > 0) {
3279
+ ctx.log.warn({
3280
+ event: "translation_chunks_failed",
3281
+ slug: effectiveSlug,
3282
+ failedChunks: failed.map((outcome) => ({ index: outcome.index, blockIds: outcome.blockIds, reason: outcome.reason?.slice(0, 200) }))
3283
+ }, "Some translation chunks failed — the coverage gate will repair the gap");
3284
+ }
3285
+ }
3286
+ })
3287
+ : planner.generatePlan;
3097
3288
  const maxPlanningAttempts = 3;
3098
3289
  let initialPlan = null;
3099
3290
  let routerDetectedInfo = false;
@@ -3588,13 +3779,10 @@ export async function runChatPipeline(ctx, body, options) {
3588
3779
  incrementalPlanStreamEnabled &&
3589
3780
  !/^(0|false|no|off)$/i.test((process.env.CHAT_STREAMED_OP_APPLY ?? "1").trim()) &&
3590
3781
  Boolean(options?.onOpApplied);
3591
- const streamApplyState = {
3592
- appliedCount: 0,
3593
- failedAtIndex: null,
3594
- hasStructuralOps: false,
3595
- /** Snapshots per slug — `null` means the page didn't exist before streaming (needs removal on rollback). */
3596
- rollbackSnapshots: new Map()
3597
- };
3782
+ // Raised when an attempt dies on `max_tokens`. Retrying a truncation with the
3783
+ // same budget reproduces it exactly — three identical failures cost ~250s and
3784
+ // told us nothing new — so each such retry gets more room to finish.
3785
+ let outputTokenScale = 1;
3598
3786
  // Skip the planning loop if the parallel race already produced an initialPlan
3599
3787
  for (let attempt = initialPlan ? maxPlanningAttempts + 1 : 1; attempt <= maxPlanningAttempts; attempt += 1) {
3600
3788
  throwIfCanceled(options?.signal);
@@ -3610,6 +3798,7 @@ export async function runChatPipeline(ctx, body, options) {
3610
3798
  currentPage: current,
3611
3799
  contextPack: plannerContext,
3612
3800
  model: modelUsed,
3801
+ outputTokenScale,
3613
3802
  locale: body.locale,
3614
3803
  imageSourceChoiceOpen,
3615
3804
  history: isLightweightEdit ? [] : plannerChatHistory,
@@ -3708,6 +3897,7 @@ export async function runChatPipeline(ctx, body, options) {
3708
3897
  // would set failedAtIndex and force a full rollback + re-apply of the
3709
3898
  // whole plan — discarding the blocks that genuinely changed.
3710
3899
  const stepResult = await applyOpsAtomically(body.session, [op], { componentsManifest, allowNoEffectiveChange: true });
3900
+ streamApplyState.processedCount += 1;
3711
3901
  if (stepResult.appliedCount === 0 || stepResult.skippedOps.length > 0) {
3712
3902
  options?.onOpSkipped?.({
3713
3903
  index,
@@ -3789,6 +3979,11 @@ export async function runChatPipeline(ctx, body, options) {
3789
3979
  };
3790
3980
  }
3791
3981
  const reasonCategory = isPlannerOutputError(error) ? error.reasonCategory : classifyGuardrailError(reason);
3982
+ // Truncation is a budget failure, not a model failure — an identical retry
3983
+ // hits the same ceiling. Double the output budget for the next attempt
3984
+ // instead (the planner clamps it to the per-path ceiling).
3985
+ if (/truncated \(max_tokens/i.test(errorMsg))
3986
+ outputTokenScale *= 2;
3792
3987
  ctx.log.warn({ event: "plan_attempt_failed", attempt, model: modelUsed, reason: reason.slice(0, 300) }, `Planning attempt ${attempt} failed`);
3793
3988
  ctx.chatTelemetry.push({
3794
3989
  id: chatRequestId,
@@ -3942,33 +4137,46 @@ export async function runChatPipeline(ctx, body, options) {
3942
4137
  }, { outcome: "planning_missing", plannerTier: "full_llm" })
3943
4138
  };
3944
4139
  }
3945
- // If streamed per-op apply already applied all ops successfully and no structural
3946
- // ops are pending, skip the normal apply step in respondFromPlan.
4140
+ // If streamed per-op apply already handled every op successfully and no
4141
+ // structural ops are pending, skip the normal apply step in respondFromPlan.
4142
+ //
4143
+ // The count has to be checked. `appliedCount > 0` read "the stream applied
4144
+ // something" as "the stream applied everything", which is only true when the
4145
+ // planner streams every op it returns. A chunked full-page translation does
4146
+ // not: its chunks run as separate planner calls, and a chunk that produced no
4147
+ // streamed ops still contributes its ops to the merged plan. Marking that plan
4148
+ // pre-applied dropped those ops on the floor — the page kept whole sections in
4149
+ // the source language while the response listed every op as applied.
4150
+ const streamedPlanFullyStreamed = streamApplyState.processedCount >= initialPlan.ops.length;
3947
4151
  const streamedApplyComplete = streamedPerOpApplyEnabled &&
3948
4152
  streamApplyState.appliedCount > 0 &&
3949
4153
  streamApplyState.failedAtIndex === null &&
3950
- !streamApplyState.hasStructuralOps;
3951
- // If streamed apply partially failed, roll back and let respondFromPlan re-apply everything
3952
- if (streamedPerOpApplyEnabled && streamApplyState.failedAtIndex !== null && streamApplyState.rollbackSnapshots.size > 0) {
3953
- const rolledBackSlugs = [];
3954
- for (const [slug, snapshot] of streamApplyState.rollbackSnapshots) {
3955
- if (snapshot) {
3956
- setPage(body.session, { ...snapshot, slug });
3957
- }
3958
- else {
3959
- removePage(body.session, slug);
3960
- }
3961
- rolledBackSlugs.push(slug);
3962
- }
3963
- ctx.log.warn({ event: "streamed_op_rollback", chatRequestId, appliedCount: streamApplyState.appliedCount, failedAtIndex: streamApplyState.failedAtIndex, rolledBackSlugs }, `Rolled back ${rolledBackSlugs.length} page(s) after streamed op failure at index ${streamApplyState.failedAtIndex}`);
4154
+ !streamApplyState.hasStructuralOps &&
4155
+ streamedPlanFullyStreamed;
4156
+ // Anything the stream already landed has to be undone before respondFromPlan
4157
+ // replays the plan, or a non-idempotent op (an `add_block` that streamed
4158
+ // before the stream fell short) would apply twice. This covers both an outright
4159
+ // streamed failure and a stream that only carried part of the plan.
4160
+ if (streamedPerOpApplyEnabled &&
4161
+ !streamedApplyComplete &&
4162
+ (streamApplyState.failedAtIndex !== null || streamApplyState.appliedCount > 0) &&
4163
+ streamApplyState.rollbackSnapshots.size > 0) {
4164
+ rollbackStreamedApply(streamApplyState.failedAtIndex !== null
4165
+ ? `streamed op failure at index ${streamApplyState.failedAtIndex}`
4166
+ : `streamed apply covered ${streamApplyState.processedCount} of ${initialPlan.ops.length} planned ops`);
3964
4167
  }
3965
4168
  const initialOutcome = await respondFromPlan(initialPlan, plannerSource, applyMode, streamedApplyComplete
3966
4169
  ? { preApplied: true, undoSnapshot: streamApplyState.rollbackSnapshots.get(effectiveSlug) ?? undefined }
3967
4170
  : undefined, "full_llm");
3968
4171
  if (initialOutcome.done)
3969
4172
  return initialOutcome.response;
3970
- if (!isDeterministicRepairEligible(initialOutcome.reason)) {
3971
- return guardrailFailureResponse({ reason: initialOutcome.reason, source: plannerSource });
4173
+ const initialReasonCategory = initialOutcome.reasonCategory ?? classifyGuardrailError(initialOutcome.reason);
4174
+ if (!isRepairEligibleCategory(initialReasonCategory)) {
4175
+ return guardrailFailureResponse({
4176
+ reason: initialOutcome.reason,
4177
+ source: plannerSource,
4178
+ category: initialReasonCategory
4179
+ });
3972
4180
  }
3973
4181
  let repairedPlan;
3974
4182
  try {
@@ -3989,13 +4197,13 @@ export async function runChatPipeline(ctx, body, options) {
3989
4197
  promptLength: plannerMessage.length,
3990
4198
  outcome: "repair_started",
3991
4199
  reason: initialOutcome.reason.slice(0, 300),
3992
- reasonCategory: classifyGuardrailError(initialOutcome.reason),
4200
+ reasonCategory: initialReasonCategory,
3993
4201
  ...timingFields()
3994
4202
  });
3995
4203
  planningAttempts += 1;
3996
4204
  plannerContextTelemetryFields.schemaRetryUsed = true;
3997
4205
  const repairFeedback = /full-page translation coverage/i.test(initialOutcome.reason)
3998
- ? `${initialOutcome.reason}. Repair for translation completeness: include missing translated text fields for list children across all affected blocks. Preserve links/hrefs unchanged.`
4206
+ ? `${initialOutcome.reason}. Repair for translation completeness: emit an update_props op for every blockId named above — some of them are blocks your previous plan did not touch at all, so adding fields to existing ops is not enough. Include the missing translated text fields for list children too. Preserve links/hrefs unchanged.`
3999
4207
  : /explicit cta target coverage/i.test(initialOutcome.reason)
4000
4208
  ? `${initialOutcome.reason}. Repair for multi-target CTA completeness: update both hero and footer CTA text targets requested by the user, keep all CTA links/hrefs unchanged, and preserve user constraints such as punctuation bans.`
4001
4209
  : buildDeterministicRepairFeedback(initialOutcome.reason);
@@ -4025,7 +4233,57 @@ export async function runChatPipeline(ctx, body, options) {
4025
4233
  : undefined,
4026
4234
  signal: options?.signal
4027
4235
  }), options?.signal);
4028
- repairedPlan = repairResult.plan;
4236
+ // The repair supplements the first plan; it does not stand in for it.
4237
+ //
4238
+ // The feedback handed to a repair names only what the first pass missed, so
4239
+ // the plan that comes back answers a narrower question than the one it used
4240
+ // to replace — and replacing meant discarding every op the first pass got
4241
+ // right. A chunked page-wide translation produced eight correct ops, tripped
4242
+ // the coverage gate on blocks that were already in the target language (the
4243
+ // gate walks every block and has no notion of "already translated"), and
4244
+ // then applied the four-op repair on its own. Three of those four were
4245
+ // no-ops. A page with six untranslated sections gained one and reported
4246
+ // success.
4247
+ //
4248
+ // Where both plans address the same target the repair wins — it is the pass
4249
+ // that saw the failure — and it keeps its position in the first plan's
4250
+ // ordering so structural ops stay where they were planned.
4251
+ const opTarget = (op) => "blockId" in op && typeof op.blockId === "string" ? `${op.op}:${op.blockId}` : op.op;
4252
+ const repairByTarget = new Map(repairResult.plan.ops.map((op) => [opTarget(op), op]));
4253
+ const takenFromRepair = new Set();
4254
+ const mergedRepairOps = [];
4255
+ for (const op of initialPlan.ops) {
4256
+ const target = opTarget(op);
4257
+ if (!repairByTarget.has(target)) {
4258
+ mergedRepairOps.push(op);
4259
+ continue;
4260
+ }
4261
+ // Superseded: the repair rewrote this target, so the original is dropped
4262
+ // rather than re-applied on top of the correction.
4263
+ if (takenFromRepair.has(target))
4264
+ continue;
4265
+ takenFromRepair.add(target);
4266
+ mergedRepairOps.push(repairByTarget.get(target));
4267
+ }
4268
+ for (const op of repairResult.plan.ops) {
4269
+ const target = opTarget(op);
4270
+ if (takenFromRepair.has(target))
4271
+ continue;
4272
+ takenFromRepair.add(target);
4273
+ mergedRepairOps.push(op);
4274
+ }
4275
+ if (mergedRepairOps.length > repairResult.plan.ops.length) {
4276
+ ctx.log.info({
4277
+ event: "repair_plan_merged",
4278
+ chatRequestId,
4279
+ repairOpCount: repairResult.plan.ops.length,
4280
+ initialOpCount: initialPlan.ops.length,
4281
+ mergedOpCount: mergedRepairOps.length
4282
+ }, "Repair plan merged with the ops the first plan already had right");
4283
+ }
4284
+ // change_log is left as the repair wrote it; the incomplete-changelog
4285
+ // synthesis downstream fills in entries for the carried-over ops.
4286
+ repairedPlan = { ...repairResult.plan, ops: mergedRepairOps };
4029
4287
  planUsage = repairResult.usage;
4030
4288
  if (repairResult.schemaContext) {
4031
4289
  plannerContextTelemetryFields.contractMode = repairResult.schemaContext.contractMode;
@@ -4092,7 +4350,7 @@ export async function runChatPipeline(ctx, body, options) {
4092
4350
  }, { outcome: "repair_failed", reasonCategory: classifyGuardrailError(reason), reason: reason.slice(0, 300), plannerTier: "full_llm" })
4093
4351
  };
4094
4352
  }
4095
- const repairedOutcome = await respondFromPlan(repairedPlan, plannerSource, applyMode);
4353
+ const repairedOutcome = await respondFromPlan(repairedPlan, plannerSource, applyMode, { skipTranslationCoverageGate: true });
4096
4354
  if (repairedOutcome.done)
4097
4355
  return repairedOutcome.response;
4098
4356
  const repairedReason = repairedOutcome.reason;
@@ -4137,7 +4395,7 @@ export async function runChatPipeline(ctx, body, options) {
4137
4395
  status: "validation_error",
4138
4396
  summary: "I could not apply that change safely.",
4139
4397
  changes: [],
4140
- validationErrors: [formatValidationError(repairedReason)],
4398
+ validationErrors: [formatValidationError(repairedReason, repairedOutcome.reasonCategory)],
4141
4399
  previewVersion: versions.get(body.session) ?? 0,
4142
4400
  plannerSource,
4143
4401
  modelUsed,
@@ -57,6 +57,8 @@ export declare function generatePlanWithGemini(args: {
57
57
  warn: (obj: Record<string, unknown>, msg: string) => void;
58
58
  };
59
59
  forceFullSchemaContracts?: boolean;
60
+ /** Restrict block-schema contracts to these types. See CommonGeneratePlanArgs. */
61
+ contractBlockTypeAllowlist?: string[];
60
62
  componentsManifest?: BlockManifest;
61
63
  lightweight?: boolean;
62
64
  signal?: AbortSignal;
@@ -193,7 +193,8 @@ export async function generatePlanWithGemini(args) {
193
193
  pageWideTranslation,
194
194
  legacyIncludeContracts: includeContracts,
195
195
  forceFullContracts: args.forceFullSchemaContracts,
196
- componentsManifest: args.componentsManifest
196
+ componentsManifest: args.componentsManifest,
197
+ contractBlockTypeAllowlist: args.contractBlockTypeAllowlist
197
198
  });
198
199
  const userPayload = {
199
200
  request: args.message,