@avocadostudio-ai/orchestrator-core 0.3.1 → 0.3.3

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 (73) 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 +297 -53
  7. package/dist/chat/gemini-planner.d.ts +2 -0
  8. package/dist/chat/gemini-planner.js +2 -1
  9. package/dist/chat/hallucination-validator.d.ts +6 -0
  10. package/dist/chat/hallucination-validator.js +49 -8
  11. package/dist/chat/planner-types.d.ts +15 -0
  12. package/dist/chat/planner-types.js +2 -2
  13. package/dist/chat/planner.d.ts +12 -0
  14. package/dist/chat/planner.js +16 -2
  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 +25 -0
  18. package/dist/checks/field-walk.js +152 -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 +375 -0
  25. package/dist/checks/run-checks.d.ts +32 -0
  26. package/dist/checks/run-checks.js +152 -0
  27. package/dist/checks/session-runner.d.ts +19 -0
  28. package/dist/checks/session-runner.js +95 -0
  29. package/dist/checks/types.d.ts +65 -0
  30. package/dist/checks/types.js +1 -0
  31. package/dist/cms/adapter.d.ts +1 -0
  32. package/dist/durable/durable-store-singleton.d.ts +37 -0
  33. package/dist/durable/durable-store-singleton.js +179 -0
  34. package/dist/durable/finding-impact.d.ts +30 -0
  35. package/dist/durable/finding-impact.js +53 -0
  36. package/dist/durable/in-memory-durable-store.d.ts +203 -0
  37. package/dist/durable/in-memory-durable-store.js +363 -0
  38. package/dist/durable/index.d.ts +5 -0
  39. package/dist/durable/index.js +4 -0
  40. package/dist/durable/pending-plan-store.d.ts +28 -0
  41. package/dist/durable/pending-plan-store.js +156 -0
  42. package/dist/durable/sqlite-durable-store.d.ts +71 -0
  43. package/dist/durable/sqlite-durable-store.js +631 -0
  44. package/dist/durable/types.d.ts +265 -0
  45. package/dist/durable/types.js +1 -0
  46. package/dist/handler/create-orchestrator.d.ts +4 -0
  47. package/dist/handler/create-orchestrator.js +85 -9
  48. package/dist/http/audio-actions.d.ts +1 -1
  49. package/dist/http/checks-actions.d.ts +39 -0
  50. package/dist/http/checks-actions.js +122 -0
  51. package/dist/http/history-actions.d.ts +1 -1
  52. package/dist/http/image-generate-actions.d.ts +2 -2
  53. package/dist/http/ops-actions.d.ts +2 -2
  54. package/dist/http/publish-actions.d.ts +4 -4
  55. package/dist/http/restore-actions.d.ts +3 -3
  56. package/dist/http/screenshot-actions.d.ts +2 -2
  57. package/dist/http/session-actions.d.ts +1 -1
  58. package/dist/http/telemetry-feedback-actions.d.ts +2 -2
  59. package/dist/http/unsplash-actions.d.ts +2 -2
  60. package/dist/http/variations-actions.d.ts +2 -2
  61. package/dist/index.d.ts +7 -0
  62. package/dist/index.js +27 -0
  63. package/dist/nlp/deterministic-planner-context.d.ts +16 -0
  64. package/dist/nlp/deterministic-planner-context.js +33 -7
  65. package/dist/nlp/deterministic-planner-suggestions.js +1 -1
  66. package/dist/nlp/plan-normalizer.js +193 -56
  67. package/dist/ops/destructive-action-gate.js +7 -2
  68. package/dist/ops/ops-engine.d.ts +12 -1
  69. package/dist/ops/ops-engine.js +41 -14
  70. package/dist/publish/publish-target-registry.js +1 -1
  71. package/dist/publish/publish-target.d.ts +1 -1
  72. package/dist/state/session-state.js +8 -1
  73. package/package.json +3 -3
@@ -3,13 +3,14 @@ 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 { loadPendingPlan, savePendingPlan, clearPendingPlan } from "../durable/pending-plan-store.js";
8
+ import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isRepairEligibleCategory, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
8
9
  import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
9
10
  import { clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
10
11
  import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
11
12
  import { isDemoModeEnabled, splitDemoOps, getDemoAllowedBlockTypes } from "../demo-mode.js";
12
- import { isCancelError as _isCancelError, OperationError } from "../errors.js";
13
+ import { isCancelError as _isCancelError, OperationError, OrchestrationError } from "../errors.js";
13
14
  import { acquireSessionLock } from "../state/session-lock.js";
14
15
  const ERROR_CATEGORIES = [
15
16
  "schema_violation",
@@ -38,7 +39,7 @@ import { executeToolCall } from "../tools/runtime.js";
38
39
  // Re-exports from extracted modules (for backwards compat with external importers)
39
40
  // ---------------------------------------------------------------------------
40
41
  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";
42
+ export { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, isNonEmptyString, findFullPageTranslationCoverageGap, buildFullPageTranslationChecklist, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest } from "./chat-pipeline-translation.js";
42
43
  export { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, shouldEnableReasoningForMessage } from "./chat-pipeline-context.js";
43
44
  export { isRewriteLikeMessage, isPerformanceAwareMessage, isLikelyTextField, collectChangedTextFields, buildMetaChangeLogEntries, buildAiInsightChanges, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, deterministicSelectedTextRewritePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.js";
44
45
  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 +47,7 @@ export { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGenerating
46
47
  // Internal imports from extracted modules (used by this file)
47
48
  import { collectMentionedSlugsFromPlan, normalizePlanCopyForUi, futureToPastTense, pastToFutureTense } from "./chat-pipeline-ui.js";
48
49
  import { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, findFullPageTranslationCoverageGap, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest } from "./chat-pipeline-translation.js";
50
+ import { planTranslationChunks, shouldChunkTranslation, generateChunkedTranslationPlan } from "./translation-chunking.js";
49
51
  import { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, classifyMessageComplexity, isRouterPlanTooShallow, shouldEnableReasoningForMessage } from "./chat-pipeline-context.js";
50
52
  import { buildAiInsightChanges, buildMetaChangeLogEntries, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.js";
51
53
  import { getValueAtPath, setValueAtPath, deleteValueAtPath, blockSupportsImageAtPath, detectImageOps, rewriteAddBlockToChildImageUpdate, withUnsplashHeroImage, resolveHeroImageForCreatePage } from "./chat-pipeline-image.js";
@@ -260,7 +262,7 @@ export async function runChatPipeline(ctx, body, options) {
260
262
  }
261
263
  const siteCapabilities = parsedCapabilities.data;
262
264
  if (executionMode === "discard_pending_plan") {
263
- const existing = pendingApprovalPlanBySession.get(body.session);
265
+ const existing = await loadPendingPlan(body.session);
264
266
  if (!existing) {
265
267
  const defaultProvider = ctx.availableProviders[0] ?? "openai";
266
268
  const defaultModelKey = process.env.OPENAI_MODEL_KEY ?? "balanced";
@@ -280,7 +282,7 @@ export async function runChatPipeline(ctx, body, options) {
280
282
  if (body.pendingPlanId && body.pendingPlanId !== existing.id) {
281
283
  return { code: 409, payload: { error: "pending plan mismatch" } };
282
284
  }
283
- pendingApprovalPlanBySession.delete(body.session);
285
+ await clearPendingPlan(body.session, "discarded");
284
286
  return {
285
287
  code: 200,
286
288
  payload: {
@@ -731,7 +733,7 @@ export async function runChatPipeline(ctx, body, options) {
731
733
  totalDurationMs: 0
732
734
  });
733
735
  if (executionMode === "auto") {
734
- const existingPendingPlan = pendingApprovalPlanBySession.get(body.session);
736
+ const existingPendingPlan = await loadPendingPlan(body.session);
735
737
  const normalizedIncomingMessage = typeof body.message === "string" ? body.message.trim() : "";
736
738
  const normalizedPendingMessage = typeof existingPendingPlan?.originalMessage === "string"
737
739
  ? existingPendingPlan.originalMessage.trim()
@@ -1041,7 +1043,7 @@ export async function runChatPipeline(ctx, body, options) {
1041
1043
  strictJsonEnabled: isStrictJsonResponseEnabled()
1042
1044
  };
1043
1045
  const guardrailFailureResponse = (args) => {
1044
- const category = classifyGuardrailError(args.reason);
1046
+ const category = args.category ?? classifyGuardrailError(args.reason);
1045
1047
  ctx.chatTelemetry.push({
1046
1048
  id: chatRequestId,
1047
1049
  at: new Date().toISOString(),
@@ -1086,7 +1088,7 @@ export async function runChatPipeline(ctx, body, options) {
1086
1088
  status: "validation_error",
1087
1089
  summary: "I could not apply that change safely.",
1088
1090
  changes: [],
1089
- validationErrors: [formatValidationError(args.reason)],
1091
+ validationErrors: [formatValidationError(args.reason, category)],
1090
1092
  previewVersion: versions.get(body.session) ?? 0,
1091
1093
  plannerSource: args.source,
1092
1094
  modelUsed,
@@ -1098,6 +1100,71 @@ export async function runChatPipeline(ctx, body, options) {
1098
1100
  let usedNativeUnsplashTool = false;
1099
1101
  let usedNativeImageTool = false;
1100
1102
  let deferredNativeImageCalls = [];
1103
+ const streamApplyState = {
1104
+ appliedCount: 0,
1105
+ /**
1106
+ * Ops the stream actually took responsibility for — applied *or* skipped as
1107
+ * a benign no-op. `appliedCount` alone cannot answer "did the stream carry
1108
+ * the whole plan", because a legitimately unchanged op is not a gap.
1109
+ */
1110
+ processedCount: 0,
1111
+ failedAtIndex: null,
1112
+ hasStructuralOps: false,
1113
+ /** Snapshots per slug — `null` means the page didn't exist before streaming (needs removal on rollback). */
1114
+ rollbackSnapshots: new Map()
1115
+ };
1116
+ /*
1117
+ * Undo everything the streamed per-op apply landed on the draft.
1118
+ *
1119
+ * Declared up here, above `respondFromPlan`, because the invariant it exists
1120
+ * to hold is one only `respondFromPlan` can see: **a plan held for approval
1121
+ * has applied nothing.**
1122
+ *
1123
+ * The stream decides what to defer one op at a time, from the op alone, and
1124
+ * three of the four things that hold a plan are properties of the whole plan
1125
+ * — `multi_page_plan` fires on touched-slug count with no destructive op
1126
+ * required, and the compound-image hold fires on a plan that is not
1127
+ * image-only. An additive six-op plan trips both defer lists' blind spot: it
1128
+ * streams clean onto the page, and *then* the gate holds it. The user is
1129
+ * shown an "Approve" card for content the draft already contains, and the
1130
+ * `undoSnapshot` threaded into the applied path never reaches the held path,
1131
+ * so they cannot even undo it. Re-running the same prompt then collides with
1132
+ * the leftovers — `Block id b_featuregrid_wellness already exists` — which is
1133
+ * the visible symptom and not the bug.
1134
+ *
1135
+ * Rolling back where the decision is made, rather than teaching the per-op
1136
+ * defer list to predict it, is what keeps the invariant true for the next
1137
+ * hold condition somebody adds.
1138
+ */
1139
+ const rollbackStreamedApply = (reason) => {
1140
+ if (streamApplyState.rollbackSnapshots.size === 0)
1141
+ return [];
1142
+ const rolledBackSlugs = [];
1143
+ for (const [slug, snapshot] of streamApplyState.rollbackSnapshots) {
1144
+ if (snapshot) {
1145
+ setPage(body.session, { ...snapshot, slug });
1146
+ }
1147
+ else {
1148
+ removePage(body.session, slug);
1149
+ }
1150
+ rolledBackSlugs.push(slug);
1151
+ }
1152
+ ctx.log.warn({
1153
+ event: "streamed_op_rollback",
1154
+ chatRequestId,
1155
+ appliedCount: streamApplyState.appliedCount,
1156
+ processedCount: streamApplyState.processedCount,
1157
+ failedAtIndex: streamApplyState.failedAtIndex,
1158
+ rolledBackSlugs
1159
+ }, `Rolled back ${rolledBackSlugs.length} page(s) — ${reason}`);
1160
+ streamApplyState.appliedCount = 0;
1161
+ streamApplyState.processedCount = 0;
1162
+ streamApplyState.rollbackSnapshots.clear();
1163
+ // The client already rendered every streamed op. Bump so it refetches the
1164
+ // draft it is actually holding rather than the one it was shown.
1165
+ bumpVersion(body.session);
1166
+ return rolledBackSlugs;
1167
+ };
1101
1168
  const respondFromPlan = async (plan, source, applyMode = "apply_now", optionsOverride, plannerTier) => {
1102
1169
  if (plannerTier)
1103
1170
  activePlannerTier = plannerTier;
@@ -1171,6 +1238,7 @@ export async function runChatPipeline(ctx, body, options) {
1171
1238
  blockId: entry.blockId,
1172
1239
  blockType: entry.blockType,
1173
1240
  propName: entry.propName,
1241
+ allowedProps: entry.allowedProps,
1174
1242
  plannerSource: source,
1175
1243
  modelKey,
1176
1244
  modelUsed
@@ -1471,7 +1539,12 @@ export async function runChatPipeline(ctx, body, options) {
1471
1539
  const forcedInfo = infoResponse({ body, current, plannerSource: source, modelUsed, modelKey });
1472
1540
  return { done: true, response: forcedInfo };
1473
1541
  }
1474
- if (translationScope === "page") {
1542
+ // The gate gets one shot. On the repair pass it is skipped entirely: by then
1543
+ // the model has already been handed the explicit checklist plus the list of
1544
+ // fields it missed, and a still-imperfect plan is worth applying. Failing it
1545
+ // twice would return 400 and discard every translation that did land —
1546
+ // strictly worse for the user than a page that is 90% translated.
1547
+ if (translationScope === "page" && !optionsOverride?.skipTranslationCoverageGate) {
1475
1548
  const translationCoverageGap = findFullPageTranslationCoverageGap({
1476
1549
  plan: resolvedPlan,
1477
1550
  message: plannerMessage,
@@ -1479,7 +1552,7 @@ export async function runChatPipeline(ctx, body, options) {
1479
1552
  slug: effectiveSlug
1480
1553
  });
1481
1554
  if (translationCoverageGap)
1482
- return { done: false, reason: translationCoverageGap };
1555
+ return { done: false, reason: translationCoverageGap, reasonCategory: undefined };
1483
1556
  }
1484
1557
  const explicitCtaCoverageGap = findExplicitCtaTargetCoverageGap({
1485
1558
  plan: resolvedPlan,
@@ -1488,7 +1561,7 @@ export async function runChatPipeline(ctx, body, options) {
1488
1561
  slug: effectiveSlug
1489
1562
  });
1490
1563
  if (explicitCtaCoverageGap)
1491
- return { done: false, reason: explicitCtaCoverageGap };
1564
+ return { done: false, reason: explicitCtaCoverageGap, reasonCategory: undefined };
1492
1565
  // Tier-1 destructive-action gate. Hold any edit_plan containing destructive
1493
1566
  // ops (remove_page on a page with content, multi-page scope, bulk deletes)
1494
1567
  // for explicit approval — undo protects recovery but not accidental intent.
@@ -1641,6 +1714,20 @@ export async function runChatPipeline(ctx, body, options) {
1641
1714
  };
1642
1715
  }
1643
1716
  if (effectiveApplyMode === "plan_only") {
1717
+ /*
1718
+ * A held plan has applied nothing. See `rollbackStreamedApply` for why
1719
+ * this cannot be decided op-by-op while the plan is still streaming.
1720
+ *
1721
+ * This has to run before the dry-run below, not only before the response:
1722
+ * the preview is computed against the live draft, so on a stream that
1723
+ * already landed its `add_block`s the dry run would report every one of
1724
+ * them as a duplicate-id failure and render an approval card claiming the
1725
+ * plan cannot be applied.
1726
+ */
1727
+ const rolledBack = rollbackStreamedApply("plan held for approval");
1728
+ if (rolledBack.length > 0) {
1729
+ options?.onRollbackStarted?.({ appliedCount: rolledBack.length, reason: "plan_held_for_approval" });
1730
+ }
1644
1731
  // Dry-run the held plan up front to build the structured before→after
1645
1732
  // preview the approval card renders AND to learn whether it changes anything.
1646
1733
  // Best-effort: a preview failure must never block showing the plan.
@@ -1665,7 +1752,7 @@ export async function runChatPipeline(ctx, body, options) {
1665
1752
  // with a "Will change X" summary the draft never honours (#23). Return an
1666
1753
  // honest no-effective-change response instead.
1667
1754
  if (planPreview && planPreview.appliedCount === 0 && planPreview.failedCount === 0 && planPreview.skippedCount > 0) {
1668
- pendingApprovalPlanBySession.delete(body.session);
1755
+ await clearPendingPlan(body.session, "discarded");
1669
1756
  const onlyUnchanged = planPreview.opResults.every((r) => r.status !== "skipped" || r.reason === "unchanged_value");
1670
1757
  const noopSummary = onlyUnchanged
1671
1758
  ? "No changes needed — that content is already set."
@@ -1715,7 +1802,7 @@ export async function runChatPipeline(ctx, body, options) {
1715
1802
  // so the copy matches the "Approve plan" UX.
1716
1803
  const approvalSummary = pastToFutureTense(resolvedPlan.summary_for_user);
1717
1804
  const approvalChangeLog = resolvedPlan.change_log.map(pastToFutureTense);
1718
- pendingApprovalPlanBySession.set(body.session, {
1805
+ await savePendingPlan(body.session, {
1719
1806
  id: pendingPlanId,
1720
1807
  createdAt: new Date().toISOString(),
1721
1808
  promptHash,
@@ -1794,7 +1881,7 @@ export async function runChatPipeline(ctx, body, options) {
1794
1881
  }
1795
1882
  if (resolvedPlan.ops.length === 0) {
1796
1883
  pendingClarificationBySession.delete(body.session);
1797
- pendingApprovalPlanBySession.delete(body.session);
1884
+ await clearPendingPlan(body.session, "discarded");
1798
1885
  /*
1799
1886
  * Zero ops is not the same fact as "already correct".
1800
1887
  *
@@ -2435,7 +2522,20 @@ export async function runChatPipeline(ctx, body, options) {
2435
2522
  pushUndo(body.session, effectiveSlug, undoSnapshot);
2436
2523
  }
2437
2524
  pendingClarificationBySession.delete(body.session);
2438
- pendingApprovalPlanBySession.delete(body.session);
2525
+ /*
2526
+ * Only the plan we actually applied is `approved`.
2527
+ *
2528
+ * This path is shared: a plain `auto` turn that succeeds runs it too. A
2529
+ * held `plan_ready` proposal the user never looked at — they typed
2530
+ * something else instead — was superseded, not approved, and recording
2531
+ * otherwise poisons the single number this outcome exists to produce.
2532
+ */
2533
+ if (optionsOverride?.approvedPlanId) {
2534
+ await clearPendingPlan(body.session, "approved", optionsOverride.approvedPlanId);
2535
+ }
2536
+ else {
2537
+ await clearPendingPlan(body.session, "discarded");
2538
+ }
2439
2539
  const planUpdatedSlug = pickUpdatedSlug(body.session, effectiveSlug, resolvedPlan.ops);
2440
2540
  const updatedSlug = planUpdatedSlug ?? (effectiveSlug !== requestedSlug ? effectiveSlug : undefined);
2441
2541
  pushRecentEdit(body.session, { slug: updatedSlug ?? effectiveSlug, summary: futureToPastTense(resolvedPlan.summary_for_user), ops: resolvedPlan.ops });
@@ -2530,8 +2630,25 @@ export async function runChatPipeline(ctx, body, options) {
2530
2630
  applyDurationMs = Date.now() - applyStartedAtMs;
2531
2631
  }
2532
2632
  const reason = toErrorDetail(error);
2633
+ /*
2634
+ * The category the throw already carried, in preference to one guessed
2635
+ * back out of its message.
2636
+ *
2637
+ * The ops engine labels `Block id … already exists` a `schema_violation`
2638
+ * and is right to. By the time it reached here it was a bare string, and
2639
+ * `classifyGuardrailError` matches none of its schema_violation keywords
2640
+ * against those words — so the failure arrived as `internal_error`, which
2641
+ * `isDeterministicRepairEligible` refuses. The one plan a repair pass
2642
+ * could have trivially fixed (rename the block, apply the other five ops)
2643
+ * was the one plan that never reached it, and the user got "I could not
2644
+ * apply that change safely" instead.
2645
+ *
2646
+ * Keyword matching stays as the fallback for reasons that are only ever
2647
+ * strings — planner output failures, coverage gates.
2648
+ */
2649
+ const reasonCategory = error instanceof OrchestrationError ? error.category : undefined;
2533
2650
  if (isNoEffectiveChangeError(reason)) {
2534
- pendingApprovalPlanBySession.delete(body.session);
2651
+ await clearPendingPlan(body.session, "discarded");
2535
2652
  ctx.chatTelemetry.push({
2536
2653
  id: chatRequestId,
2537
2654
  at: new Date().toISOString(),
@@ -2617,15 +2734,15 @@ export async function runChatPipeline(ctx, body, options) {
2617
2734
  promptLength: plannerMessage.length,
2618
2735
  outcome: "apply_failed",
2619
2736
  reason: reason.slice(0, 300),
2620
- reasonCategory: classifyGuardrailError(reason),
2737
+ reasonCategory: reasonCategory ?? classifyGuardrailError(reason),
2621
2738
  plannerTier,
2622
2739
  ...timingFields()
2623
2740
  });
2624
- return { done: false, reason };
2741
+ return { done: false, reason, reasonCategory };
2625
2742
  }
2626
2743
  };
2627
2744
  if (executionMode === "apply_pending_plan") {
2628
- const pending = pendingApprovalPlanBySession.get(body.session);
2745
+ const pending = await loadPendingPlan(body.session);
2629
2746
  if (!pending) {
2630
2747
  const fallbackMessage = typeof body.message === "string" ? body.message.trim() : "";
2631
2748
  if (fallbackMessage.length > 0) {
@@ -2723,7 +2840,7 @@ export async function runChatPipeline(ctx, body, options) {
2723
2840
  });
2724
2841
  imageResolutionDurationMs += Date.now() - imageResolutionStartMs;
2725
2842
  }
2726
- const approvedOutcome = await respondFromPlan(approvalPlan, pending.source, "apply_now", { preResolvedPlan: true });
2843
+ const approvedOutcome = await respondFromPlan(approvalPlan, pending.source, "apply_now", { preResolvedPlan: true, approvedPlanId: pending.id });
2727
2844
  if (approvedOutcome.done)
2728
2845
  return approvedOutcome.response;
2729
2846
  return guardrailFailureResponse({ reason: approvedOutcome.reason, source: pending.source });
@@ -2731,7 +2848,7 @@ export async function runChatPipeline(ctx, body, options) {
2731
2848
  catch (error) {
2732
2849
  const reason = toErrorDetail(error);
2733
2850
  ctx.log.error({ event: "apply_pending_plan_error", chatRequestId, error: reason }, "Pending plan execution failed");
2734
- pendingApprovalPlanBySession.delete(body.session);
2851
+ await clearPendingPlan(body.session, "discarded");
2735
2852
  ctx.chatTelemetry.push({
2736
2853
  id: chatRequestId,
2737
2854
  at: new Date().toISOString(),
@@ -3092,7 +3209,67 @@ export async function runChatPipeline(ctx, body, options) {
3092
3209
  // `plannerSource === "demo"` is handled earlier; here it's always a real planner.
3093
3210
  const planner = plannerRegistry.get(plannerSource);
3094
3211
  const supportsNativeTools = planner.supportsNativeTools;
3095
- const generatePlanImpl = planner.generatePlan;
3212
+ // A full-page translation is a fan-out, not one long request: its output length
3213
+ // scales with the page, and one request's output is strictly serial. Splitting
3214
+ // it into independent per-block-group requests turns wall clock from the sum of
3215
+ // the blocks into the slowest chunk, and keeps every chunk far from the token
3216
+ // ceiling that made a single request an all-or-nothing bet. Substituting it
3217
+ // here — rather than at each call site — keeps the planner's contract identical
3218
+ // for the pipeline: same args in, one merged plan out.
3219
+ const translationChunks = translationScope === "page" ? planTranslationChunks({ page: current }) : [];
3220
+ const chunkTranslation = translationScope === "page" && shouldChunkTranslation(current, translationChunks);
3221
+ if (chunkTranslation) {
3222
+ ctx.log.info({
3223
+ event: "translation_chunking_enabled",
3224
+ slug: effectiveSlug,
3225
+ chunkCount: translationChunks.length,
3226
+ blocksPerChunk: translationChunks.map((chunk) => chunk.blockIds.length),
3227
+ bytesPerChunk: translationChunks.map((chunk) => chunk.bytes)
3228
+ }, "Full-page translation will be planned in parallel chunks");
3229
+ }
3230
+ const generatePlanImpl = chunkTranslation
3231
+ ? (plannerArgs) =>
3232
+ // A repair pass is already scoped to the fields the first pass missed —
3233
+ // it is small, targeted, and must see the whole page to fix cross-block
3234
+ // gaps, so it runs unchunked.
3235
+ plannerArgs.feedback
3236
+ ? planner.generatePlan(plannerArgs)
3237
+ : generateChunkedTranslationPlan({
3238
+ plannerArgs,
3239
+ chunks: translationChunks,
3240
+ generate: planner.generatePlan,
3241
+ log: ctx.log,
3242
+ onChunksSettled: (outcomes) => {
3243
+ const failed = outcomes.filter((outcome) => !outcome.ok);
3244
+ plannerContextTelemetryFields.translationChunkCount = outcomes.length;
3245
+ plannerContextTelemetryFields.translationChunksFailed = failed.length;
3246
+ // The slowest chunk is the request's latency. Logging every chunk's
3247
+ // wall clock next to its weight is what makes the split tunable —
3248
+ // a balanced split whose slowest chunk is no faster means the
3249
+ // bottleneck is somewhere other than chunk size.
3250
+ ctx.log.info({
3251
+ event: "translation_chunks_settled",
3252
+ slug: effectiveSlug,
3253
+ slowestMs: Math.max(...outcomes.map((outcome) => outcome.durationMs)),
3254
+ chunks: outcomes.map((outcome) => ({
3255
+ index: outcome.index,
3256
+ ms: outcome.durationMs,
3257
+ bytes: outcome.bytes,
3258
+ outputTokens: outcome.outputTokens,
3259
+ ops: outcome.opCount,
3260
+ attempts: outcome.attempts
3261
+ }))
3262
+ }, "Translation chunks settled");
3263
+ if (failed.length > 0) {
3264
+ ctx.log.warn({
3265
+ event: "translation_chunks_failed",
3266
+ slug: effectiveSlug,
3267
+ failedChunks: failed.map((outcome) => ({ index: outcome.index, blockIds: outcome.blockIds, reason: outcome.reason?.slice(0, 200) }))
3268
+ }, "Some translation chunks failed — the coverage gate will repair the gap");
3269
+ }
3270
+ }
3271
+ })
3272
+ : planner.generatePlan;
3096
3273
  const maxPlanningAttempts = 3;
3097
3274
  let initialPlan = null;
3098
3275
  let routerDetectedInfo = false;
@@ -3587,13 +3764,10 @@ export async function runChatPipeline(ctx, body, options) {
3587
3764
  incrementalPlanStreamEnabled &&
3588
3765
  !/^(0|false|no|off)$/i.test((process.env.CHAT_STREAMED_OP_APPLY ?? "1").trim()) &&
3589
3766
  Boolean(options?.onOpApplied);
3590
- const streamApplyState = {
3591
- appliedCount: 0,
3592
- failedAtIndex: null,
3593
- hasStructuralOps: false,
3594
- /** Snapshots per slug — `null` means the page didn't exist before streaming (needs removal on rollback). */
3595
- rollbackSnapshots: new Map()
3596
- };
3767
+ // Raised when an attempt dies on `max_tokens`. Retrying a truncation with the
3768
+ // same budget reproduces it exactly — three identical failures cost ~250s and
3769
+ // told us nothing new — so each such retry gets more room to finish.
3770
+ let outputTokenScale = 1;
3597
3771
  // Skip the planning loop if the parallel race already produced an initialPlan
3598
3772
  for (let attempt = initialPlan ? maxPlanningAttempts + 1 : 1; attempt <= maxPlanningAttempts; attempt += 1) {
3599
3773
  throwIfCanceled(options?.signal);
@@ -3609,6 +3783,7 @@ export async function runChatPipeline(ctx, body, options) {
3609
3783
  currentPage: current,
3610
3784
  contextPack: plannerContext,
3611
3785
  model: modelUsed,
3786
+ outputTokenScale,
3612
3787
  locale: body.locale,
3613
3788
  imageSourceChoiceOpen,
3614
3789
  history: isLightweightEdit ? [] : plannerChatHistory,
@@ -3707,6 +3882,7 @@ export async function runChatPipeline(ctx, body, options) {
3707
3882
  // would set failedAtIndex and force a full rollback + re-apply of the
3708
3883
  // whole plan — discarding the blocks that genuinely changed.
3709
3884
  const stepResult = await applyOpsAtomically(body.session, [op], { componentsManifest, allowNoEffectiveChange: true });
3885
+ streamApplyState.processedCount += 1;
3710
3886
  if (stepResult.appliedCount === 0 || stepResult.skippedOps.length > 0) {
3711
3887
  options?.onOpSkipped?.({
3712
3888
  index,
@@ -3788,6 +3964,11 @@ export async function runChatPipeline(ctx, body, options) {
3788
3964
  };
3789
3965
  }
3790
3966
  const reasonCategory = isPlannerOutputError(error) ? error.reasonCategory : classifyGuardrailError(reason);
3967
+ // Truncation is a budget failure, not a model failure — an identical retry
3968
+ // hits the same ceiling. Double the output budget for the next attempt
3969
+ // instead (the planner clamps it to the per-path ceiling).
3970
+ if (/truncated \(max_tokens/i.test(errorMsg))
3971
+ outputTokenScale *= 2;
3791
3972
  ctx.log.warn({ event: "plan_attempt_failed", attempt, model: modelUsed, reason: reason.slice(0, 300) }, `Planning attempt ${attempt} failed`);
3792
3973
  ctx.chatTelemetry.push({
3793
3974
  id: chatRequestId,
@@ -3941,33 +4122,46 @@ export async function runChatPipeline(ctx, body, options) {
3941
4122
  }, { outcome: "planning_missing", plannerTier: "full_llm" })
3942
4123
  };
3943
4124
  }
3944
- // If streamed per-op apply already applied all ops successfully and no structural
3945
- // ops are pending, skip the normal apply step in respondFromPlan.
4125
+ // If streamed per-op apply already handled every op successfully and no
4126
+ // structural ops are pending, skip the normal apply step in respondFromPlan.
4127
+ //
4128
+ // The count has to be checked. `appliedCount > 0` read "the stream applied
4129
+ // something" as "the stream applied everything", which is only true when the
4130
+ // planner streams every op it returns. A chunked full-page translation does
4131
+ // not: its chunks run as separate planner calls, and a chunk that produced no
4132
+ // streamed ops still contributes its ops to the merged plan. Marking that plan
4133
+ // pre-applied dropped those ops on the floor — the page kept whole sections in
4134
+ // the source language while the response listed every op as applied.
4135
+ const streamedPlanFullyStreamed = streamApplyState.processedCount >= initialPlan.ops.length;
3946
4136
  const streamedApplyComplete = streamedPerOpApplyEnabled &&
3947
4137
  streamApplyState.appliedCount > 0 &&
3948
4138
  streamApplyState.failedAtIndex === null &&
3949
- !streamApplyState.hasStructuralOps;
3950
- // If streamed apply partially failed, roll back and let respondFromPlan re-apply everything
3951
- if (streamedPerOpApplyEnabled && streamApplyState.failedAtIndex !== null && streamApplyState.rollbackSnapshots.size > 0) {
3952
- const rolledBackSlugs = [];
3953
- for (const [slug, snapshot] of streamApplyState.rollbackSnapshots) {
3954
- if (snapshot) {
3955
- setPage(body.session, { ...snapshot, slug });
3956
- }
3957
- else {
3958
- removePage(body.session, slug);
3959
- }
3960
- rolledBackSlugs.push(slug);
3961
- }
3962
- 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}`);
4139
+ !streamApplyState.hasStructuralOps &&
4140
+ streamedPlanFullyStreamed;
4141
+ // Anything the stream already landed has to be undone before respondFromPlan
4142
+ // replays the plan, or a non-idempotent op (an `add_block` that streamed
4143
+ // before the stream fell short) would apply twice. This covers both an outright
4144
+ // streamed failure and a stream that only carried part of the plan.
4145
+ if (streamedPerOpApplyEnabled &&
4146
+ !streamedApplyComplete &&
4147
+ (streamApplyState.failedAtIndex !== null || streamApplyState.appliedCount > 0) &&
4148
+ streamApplyState.rollbackSnapshots.size > 0) {
4149
+ rollbackStreamedApply(streamApplyState.failedAtIndex !== null
4150
+ ? `streamed op failure at index ${streamApplyState.failedAtIndex}`
4151
+ : `streamed apply covered ${streamApplyState.processedCount} of ${initialPlan.ops.length} planned ops`);
3963
4152
  }
3964
4153
  const initialOutcome = await respondFromPlan(initialPlan, plannerSource, applyMode, streamedApplyComplete
3965
4154
  ? { preApplied: true, undoSnapshot: streamApplyState.rollbackSnapshots.get(effectiveSlug) ?? undefined }
3966
4155
  : undefined, "full_llm");
3967
4156
  if (initialOutcome.done)
3968
4157
  return initialOutcome.response;
3969
- if (!isDeterministicRepairEligible(initialOutcome.reason)) {
3970
- return guardrailFailureResponse({ reason: initialOutcome.reason, source: plannerSource });
4158
+ const initialReasonCategory = initialOutcome.reasonCategory ?? classifyGuardrailError(initialOutcome.reason);
4159
+ if (!isRepairEligibleCategory(initialReasonCategory)) {
4160
+ return guardrailFailureResponse({
4161
+ reason: initialOutcome.reason,
4162
+ source: plannerSource,
4163
+ category: initialReasonCategory
4164
+ });
3971
4165
  }
3972
4166
  let repairedPlan;
3973
4167
  try {
@@ -3988,13 +4182,13 @@ export async function runChatPipeline(ctx, body, options) {
3988
4182
  promptLength: plannerMessage.length,
3989
4183
  outcome: "repair_started",
3990
4184
  reason: initialOutcome.reason.slice(0, 300),
3991
- reasonCategory: classifyGuardrailError(initialOutcome.reason),
4185
+ reasonCategory: initialReasonCategory,
3992
4186
  ...timingFields()
3993
4187
  });
3994
4188
  planningAttempts += 1;
3995
4189
  plannerContextTelemetryFields.schemaRetryUsed = true;
3996
4190
  const repairFeedback = /full-page translation coverage/i.test(initialOutcome.reason)
3997
- ? `${initialOutcome.reason}. Repair for translation completeness: include missing translated text fields for list children across all affected blocks. Preserve links/hrefs unchanged.`
4191
+ ? `${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.`
3998
4192
  : /explicit cta target coverage/i.test(initialOutcome.reason)
3999
4193
  ? `${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.`
4000
4194
  : buildDeterministicRepairFeedback(initialOutcome.reason);
@@ -4024,7 +4218,57 @@ export async function runChatPipeline(ctx, body, options) {
4024
4218
  : undefined,
4025
4219
  signal: options?.signal
4026
4220
  }), options?.signal);
4027
- repairedPlan = repairResult.plan;
4221
+ // The repair supplements the first plan; it does not stand in for it.
4222
+ //
4223
+ // The feedback handed to a repair names only what the first pass missed, so
4224
+ // the plan that comes back answers a narrower question than the one it used
4225
+ // to replace — and replacing meant discarding every op the first pass got
4226
+ // right. A chunked page-wide translation produced eight correct ops, tripped
4227
+ // the coverage gate on blocks that were already in the target language (the
4228
+ // gate walks every block and has no notion of "already translated"), and
4229
+ // then applied the four-op repair on its own. Three of those four were
4230
+ // no-ops. A page with six untranslated sections gained one and reported
4231
+ // success.
4232
+ //
4233
+ // Where both plans address the same target the repair wins — it is the pass
4234
+ // that saw the failure — and it keeps its position in the first plan's
4235
+ // ordering so structural ops stay where they were planned.
4236
+ const opTarget = (op) => "blockId" in op && typeof op.blockId === "string" ? `${op.op}:${op.blockId}` : op.op;
4237
+ const repairByTarget = new Map(repairResult.plan.ops.map((op) => [opTarget(op), op]));
4238
+ const takenFromRepair = new Set();
4239
+ const mergedRepairOps = [];
4240
+ for (const op of initialPlan.ops) {
4241
+ const target = opTarget(op);
4242
+ if (!repairByTarget.has(target)) {
4243
+ mergedRepairOps.push(op);
4244
+ continue;
4245
+ }
4246
+ // Superseded: the repair rewrote this target, so the original is dropped
4247
+ // rather than re-applied on top of the correction.
4248
+ if (takenFromRepair.has(target))
4249
+ continue;
4250
+ takenFromRepair.add(target);
4251
+ mergedRepairOps.push(repairByTarget.get(target));
4252
+ }
4253
+ for (const op of repairResult.plan.ops) {
4254
+ const target = opTarget(op);
4255
+ if (takenFromRepair.has(target))
4256
+ continue;
4257
+ takenFromRepair.add(target);
4258
+ mergedRepairOps.push(op);
4259
+ }
4260
+ if (mergedRepairOps.length > repairResult.plan.ops.length) {
4261
+ ctx.log.info({
4262
+ event: "repair_plan_merged",
4263
+ chatRequestId,
4264
+ repairOpCount: repairResult.plan.ops.length,
4265
+ initialOpCount: initialPlan.ops.length,
4266
+ mergedOpCount: mergedRepairOps.length
4267
+ }, "Repair plan merged with the ops the first plan already had right");
4268
+ }
4269
+ // change_log is left as the repair wrote it; the incomplete-changelog
4270
+ // synthesis downstream fills in entries for the carried-over ops.
4271
+ repairedPlan = { ...repairResult.plan, ops: mergedRepairOps };
4028
4272
  planUsage = repairResult.usage;
4029
4273
  if (repairResult.schemaContext) {
4030
4274
  plannerContextTelemetryFields.contractMode = repairResult.schemaContext.contractMode;
@@ -4091,7 +4335,7 @@ export async function runChatPipeline(ctx, body, options) {
4091
4335
  }, { outcome: "repair_failed", reasonCategory: classifyGuardrailError(reason), reason: reason.slice(0, 300), plannerTier: "full_llm" })
4092
4336
  };
4093
4337
  }
4094
- const repairedOutcome = await respondFromPlan(repairedPlan, plannerSource, applyMode);
4338
+ const repairedOutcome = await respondFromPlan(repairedPlan, plannerSource, applyMode, { skipTranslationCoverageGate: true });
4095
4339
  if (repairedOutcome.done)
4096
4340
  return repairedOutcome.response;
4097
4341
  const repairedReason = repairedOutcome.reason;
@@ -4136,7 +4380,7 @@ export async function runChatPipeline(ctx, body, options) {
4136
4380
  status: "validation_error",
4137
4381
  summary: "I could not apply that change safely.",
4138
4382
  changes: [],
4139
- validationErrors: [formatValidationError(repairedReason)],
4383
+ validationErrors: [formatValidationError(repairedReason, repairedOutcome.reasonCategory)],
4140
4384
  previewVersion: versions.get(body.session) ?? 0,
4141
4385
  plannerSource,
4142
4386
  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,
@@ -15,6 +15,12 @@ export type HallucinatedProp = {
15
15
  blockId: string;
16
16
  blockType: string;
17
17
  propName: string;
18
+ /**
19
+ * What the block *does* accept. Present so the log line that records a strip
20
+ * carries the answer next to the question — the integrator who hit this spent
21
+ * nine minutes proving the block was fine, and this is the line they read.
22
+ */
23
+ allowedProps: string[];
18
24
  };
19
25
  export type HallucinationValidationResult = {
20
26
  plan: EditPlan;