@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
@@ -77,6 +77,8 @@ export declare function generatePlanWithAnthropic(args: {
77
77
  warn: (obj: Record<string, unknown>, msg: string) => void;
78
78
  };
79
79
  forceFullSchemaContracts?: boolean;
80
+ /** Restrict block-schema contracts to these types. See CommonGeneratePlanArgs. */
81
+ contractBlockTypeAllowlist?: string[];
80
82
  componentsManifest?: BlockManifest;
81
83
  lightweight?: boolean;
82
84
  signal?: AbortSignal;
@@ -90,6 +92,12 @@ export declare function generatePlanWithAnthropic(args: {
90
92
  thinking?: {
91
93
  effort: PlannerEffort;
92
94
  };
95
+ /**
96
+ * Multiplier on the computed output-token budget. The pipeline raises it on a
97
+ * retry that follows a `max_tokens` truncation so the next attempt has room the
98
+ * previous one lacked. Clamped to the per-path ceilings above.
99
+ */
100
+ outputTokenScale?: number;
93
101
  }): Promise<{
94
102
  plan: EditPlan;
95
103
  usage: TokenUsage;
@@ -11,6 +11,7 @@ import { extractUsage, ZERO_USAGE } from "../telemetry/usage.js";
11
11
  import { anthropicSystemPromptWithCache, anthropicSegmentedSystemPromptWithCache, anthropicToolWithCache, ANTHROPIC_FINE_GRAINED_STREAM_HEADERS } from "./anthropic-cache.js";
12
12
  import { executeToolCall } from "../tools/runtime.js";
13
13
  import { DEFERRABLE_IMAGE_TOOLS } from "./chat-pipeline-shared.js";
14
+ import { buildFullPageTranslationChecklist } from "./chat-pipeline-translation.js";
14
15
  // Minimum text length to treat a text-only model response as meaningful
15
16
  // content (rather than discarding it in favor of the hardcoded fallback).
16
17
  const MIN_MEANINGFUL_RESPONSE_LENGTH = 20;
@@ -30,6 +31,14 @@ function isApiLevelError(err) {
30
31
  msg.includes("billing") ||
31
32
  /\b40[0-13]\b/.test(err.message));
32
33
  }
34
+ /**
35
+ * Output-token ceilings for the planner. Current Claude models (Sonnet 5, Opus 5)
36
+ * accept up to 128K output tokens, but the SDK only supports budgets that large
37
+ * on a stream — a non-streaming `messages.create` with a 64K budget can sit past
38
+ * the HTTP timeout. The two paths therefore get separate ceilings.
39
+ */
40
+ const PLANNER_MAX_TOKENS_NON_STREAMING = 32000;
41
+ const PLANNER_MAX_TOKENS_STREAMING = 64000;
33
42
  /**
34
43
  * Try JSON.parse, then repairAndParseJson. Returns parsed object or null.
35
44
  */
@@ -53,6 +62,49 @@ function tryParseOrRepair(buf, log, model) {
53
62
  }
54
63
  }
55
64
  }
65
+ /**
66
+ * Recover a usable plan from a turn that stopped on `max_tokens`.
67
+ *
68
+ * A truncated turn still buffered every token the model emitted before it hit
69
+ * the ceiling, and the repair strategies close the open brackets — so a
70
+ * page-wide translation that ran out of room comes back as most blocks
71
+ * translated rather than none. The coverage check then backfills or repairs the
72
+ * remainder, which costs a fraction of re-running the whole generation.
73
+ *
74
+ * Returns null when nothing usable survived; the caller should then throw so the
75
+ * pipeline retries with a larger budget.
76
+ */
77
+ function salvageTruncatedPlan(args) {
78
+ const parsed = args.parsed ?? (args.buffer.length > 0 ? tryParseOrRepair(args.buffer, args.log, args.model) : null);
79
+ if (!parsed)
80
+ return null;
81
+ // Closing open brackets can leave the final op holding a half-written string —
82
+ // "Под" where the model was writing "Подзаголовок" — and that would be written
83
+ // into the page as if it were the translation. The buffer parses cleanly only
84
+ // when nothing was cut mid-value, so otherwise discard the trailing op.
85
+ let bufferComplete = false;
86
+ try {
87
+ JSON.parse(args.buffer);
88
+ bufferComplete = true;
89
+ }
90
+ catch { /* truncated mid-value — trailing op is suspect */ }
91
+ const ops = parsed.ops;
92
+ if (!bufferComplete && Array.isArray(ops))
93
+ ops.pop();
94
+ const salvagedOpCount = Array.isArray(parsed.ops)
95
+ ? (parsed.ops).length
96
+ : 0;
97
+ if (salvagedOpCount === 0)
98
+ return null;
99
+ args.log?.warn({
100
+ event: "anthropic_planner_truncation_salvaged",
101
+ model: args.model,
102
+ toolJsonBufLength: args.buffer.length,
103
+ droppedTrailingOp: !bufferComplete,
104
+ opCount: salvagedOpCount
105
+ }, "Anthropic planner: salvaged a partial plan from the truncated stream");
106
+ return parsed;
107
+ }
56
108
  // ---------------------------------------------------------------------------
57
109
  // Singleton Anthropic client — reuses HTTP/2 connection pool across requests.
58
110
  // Lazily initialized on first use so module-level import doesn't throw if
@@ -228,14 +280,50 @@ export async function generatePlanWithAnthropic(args) {
228
280
  pageWideTranslation,
229
281
  legacyIncludeContracts: includeContracts,
230
282
  forceFullContracts: args.forceFullSchemaContracts,
231
- componentsManifest: args.componentsManifest
283
+ componentsManifest: args.componentsManifest,
284
+ contractBlockTypeAllowlist: args.contractBlockTypeAllowlist
232
285
  });
286
+ // A full-page translation is a coverage problem, not a writing problem: the
287
+ // model reliably translates what it looks at, and the failures are sections it
288
+ // never looked at. "Translate all blocks" leaves it to infer the scope from the
289
+ // outline; an enumerated checklist with a count makes a forgotten section
290
+ // visible to the model itself, which is cheaper than catching it afterwards.
291
+ // Per-request, so it rides in the user turn and leaves the cached system
292
+ // prefix untouched.
293
+ const translationChecklist = pageWideTranslation
294
+ ? buildFullPageTranslationChecklist(args.currentPage)
295
+ : null;
233
296
  const user = {
234
297
  request: args.message,
235
298
  audienceHint: audienceHint ?? null,
236
299
  slug: args.slug,
237
300
  contextPack: args.contextPack,
238
301
  ...schemaContext.payload,
302
+ ...(translationChecklist && translationChecklist.length > 0
303
+ ? {
304
+ translationChecklist: {
305
+ instruction: `This page has ${translationChecklist.length} blocks carrying text that must be translated. ` +
306
+ `Emit an update_props op for EVERY blockId listed below and translate EVERY field path listed under it. ` +
307
+ `Before you finish, count your ops: fewer than ${translationChecklist.length} means you skipped a section — go back and add it. ` +
308
+ // The engine deep-merges list rows by index, so a row may carry only
309
+ // the keys being translated — but it merges positionally, so a short
310
+ // array truncates the list. Echoing whole rows is the expensive
311
+ // failure (it doubled one CardGrid chunk's output); dropping rows is
312
+ // the destructive one.
313
+ `For list fields (paths like items[0].q), send EVERY row of the array in its original order, ` +
314
+ `but inside each row include ONLY the fields you translated — omitted keys keep their current values, ` +
315
+ `while omitted rows would delete list entries. ` +
316
+ // "Leave URL-like fields alone" read as "leave ctaText alone" too, and
317
+ // four card buttons stayed in English while their titles translated.
318
+ // Destination vs. label is the distinction that matters, so name both
319
+ // sides rather than describing one of them.
320
+ `Leave DESTINATIONS out of the patch entirely — ctaHref, href, url, imageUrl, and any id. ` +
321
+ `But LABELS are visible text and must be translated like any other string, including ctaText and secondaryCtaText: ` +
322
+ `ctaText is the words on the button, not the link it points at.`,
323
+ blocks: translationChecklist
324
+ }
325
+ }
326
+ : {}),
239
327
  feedback: args.feedback ?? null
240
328
  };
241
329
  const imageUrlForVision = typeof args.contextPack.selected?.imageUrlForVision === "string"
@@ -320,7 +408,31 @@ export async function generatePlanWithAnthropic(args) {
320
408
  const effortParam = thinkingParam ? args.thinking.effort : undefined;
321
409
  // Adaptive thinking counts thinking tokens against max_tokens; give headroom so
322
410
  // the actual response isn't truncated. Effort (not max_tokens) governs spend.
323
- const plannerMaxTokens = thinkingParam ? 16000 : 8192;
411
+ const baseMaxTokens = thinkingParam ? 16000 : 8192;
412
+ // Whole-page translations and rewrites re-emit every translatable string on the
413
+ // page inside an op envelope, so their output length scales with the page — a
414
+ // flat budget truncates them.
415
+ //
416
+ // Size these from the page, and size them generously: `max_tokens` is a ceiling,
417
+ // not a reservation, so over-asking costs nothing while under-asking costs a
418
+ // whole extra generation. "translate this page to russian" on an 8.3 KB page
419
+ // needed 9139 output tokens and was budgeted 8772 — a 4% miss that cost 78
420
+ // seconds of retry. The estimate has to hold for the worst case, not the median:
421
+ // - ~3x the page's own JSON, because the op envelope repeats
422
+ // op/pageSlug/blockId/patch per block on top of the text itself, and target
423
+ // languages run longer than the English source.
424
+ // - ~2.5 bytes/token, not the ~4 that English averages: Cyrillic, Greek, and
425
+ // CJK tokenize far denser, which is exactly the case that truncates.
426
+ const wholePageEdit = pageWideTranslation || pageWideRewrite;
427
+ const wholePageBudget = wholePageEdit
428
+ ? Math.ceil((JSON.stringify(args.currentPage).length * 3) / 2.5) + (thinkingParam ? 24000 : 8000)
429
+ : 0;
430
+ // The pipeline raises this on a retry that follows a truncation, so attempt N+1
431
+ // doesn't reproduce attempt N's failure with an identical budget.
432
+ const outputTokenScale = Math.max(1, args.outputTokenScale ?? 1);
433
+ const requestedMaxTokens = Math.ceil(Math.max(baseMaxTokens, wholePageBudget) * outputTokenScale);
434
+ const plannerMaxTokens = Math.min(requestedMaxTokens, PLANNER_MAX_TOKENS_NON_STREAMING);
435
+ const plannerStreamMaxTokens = Math.min(requestedMaxTokens, PLANNER_MAX_TOKENS_STREAMING);
324
436
  // Collapsed request params spread into every tool-loop create/stream call.
325
437
  // effort rides inside output_config alongside thinking; only the constrained-
326
438
  // decoding fallback (thinking OFF) sets output_config.format instead.
@@ -395,10 +507,13 @@ export async function generatePlanWithAnthropic(args) {
395
507
  for (let turn = 0; turn < maxToolTurns; turn += 1) {
396
508
  let response;
397
509
  let emittedTextDeltas = false;
510
+ // Mirrors the submit_edit_plan tool JSON streamed this turn, hoisted out of
511
+ // the stream branch so the max_tokens handler below can salvage from it.
512
+ let submitToolJsonBuf = "";
398
513
  if (client.messages.stream) {
399
514
  const stream = client.messages.stream({
400
515
  model: args.model,
401
- max_tokens: plannerMaxTokens,
516
+ max_tokens: plannerStreamMaxTokens,
402
517
  system: cachedSystem,
403
518
  tools: toolDefs,
404
519
  tool_choice: submitToolChoice,
@@ -460,6 +575,7 @@ export async function generatePlanWithAnthropic(args) {
460
575
  continue;
461
576
  const nextBuf = (submitToolJsonByIndex.get(idx) ?? "") + (event.delta.partial_json ?? "");
462
577
  submitToolJsonByIndex.set(idx, nextBuf);
578
+ submitToolJsonBuf = nextBuf;
463
579
  emitProgressFromToolJson(nextBuf);
464
580
  }
465
581
  }
@@ -530,9 +646,25 @@ export async function generatePlanWithAnthropic(args) {
530
646
  model: args.model,
531
647
  stopReason: "max_tokens",
532
648
  turn,
649
+ toolJsonBufLength: submitToolJsonBuf.length,
533
650
  contentBlockTypes: response.content.map((b) => b.type)
534
- }, "Anthropic planner: response truncated (max_tokens) during tool loop");
535
- throw new Error("Model response was truncated (max_tokens reached)");
651
+ }, "Anthropic planner: response truncated (max_tokens) during tool loop — attempting salvage");
652
+ const truncatedToolBlock = response.content
653
+ .map((block) => asToolUseBlock(block))
654
+ .find((block) => block?.name === "submit_edit_plan");
655
+ const salvaged = salvageTruncatedPlan({
656
+ parsed: truncatedToolBlock?.input && typeof truncatedToolBlock.input === "object"
657
+ ? truncatedToolBlock.input
658
+ : undefined,
659
+ buffer: submitToolJsonBuf,
660
+ log: args.log,
661
+ model: args.model
662
+ });
663
+ // Nothing parseable survived — let the caller retry with a bigger budget.
664
+ if (!salvaged)
665
+ throw new Error("Model response was truncated (max_tokens reached)");
666
+ parsed = salvaged;
667
+ break;
536
668
  }
537
669
  if (args.onToken && !emittedTextDeltas) {
538
670
  for (const block of response.content) {
@@ -679,8 +811,22 @@ export async function generatePlanWithAnthropic(args) {
679
811
  content: toolResults
680
812
  });
681
813
  }
814
+ // Path 2: streaming. Chosen whenever *any* incremental callback is listening,
815
+ // not just `onToken`.
816
+ //
817
+ // Testing `onToken` alone conflated two different questions: "is a human
818
+ // watching prose arrive" and "does someone need the ops as they are parsed".
819
+ // A chunked full-page translation separates them — prose callbacks go to the
820
+ // first chunk only, because six chunks streaming six summaries into one
821
+ // message box reads as a stutter — so every other chunk fell through to the
822
+ // non-streaming `messages.create` below and never fired `onPlannedOp`. Those
823
+ // chunks' ops reached the merged plan but never reached the pipeline's
824
+ // streamed per-op apply, which then reported the partial stream as a finished
825
+ // apply and skipped the rest of the plan. A page-wide translation applied the
826
+ // first chunk's blocks, left the other five in the source language, and said
827
+ // it had translated the page.
682
828
  }
683
- else if (args.onToken) {
829
+ else if (args.onToken || args.onPlannedOp || args.onFieldDraft || args.onSummaryChunk || args.onChangeLogEntry) {
684
830
  let toolJsonBuf = "";
685
831
  let textBuf = "";
686
832
  if (client.messages.stream) {
@@ -689,7 +835,7 @@ export async function generatePlanWithAnthropic(args) {
689
835
  // before emitting the tool call.
690
836
  const stream = client.messages.stream({
691
837
  model: args.model,
692
- max_tokens: plannerMaxTokens,
838
+ max_tokens: plannerStreamMaxTokens,
693
839
  system: cachedSystem,
694
840
  tools: [anthropicToolWithCache(submitPlanToolDef)],
695
841
  tool_choice: thinkingParam
@@ -738,7 +884,7 @@ export async function generatePlanWithAnthropic(args) {
738
884
  }
739
885
  else if (event.delta?.type === "text_delta") {
740
886
  textBuf += event.delta.text ?? "";
741
- args.onToken(event.delta.text ?? "");
887
+ args.onToken?.(event.delta.text ?? "");
742
888
  }
743
889
  }
744
890
  }
@@ -757,6 +903,7 @@ export async function generatePlanWithAnthropic(args) {
757
903
  }
758
904
  // If stream completed normally, try finalMessage() for validated tool input.
759
905
  // Skip if stream threw — finalMessage() would also fail.
906
+ let streamTruncated = false;
760
907
  if (!streamLoopError) {
761
908
  let finalMessage;
762
909
  try {
@@ -764,14 +911,16 @@ export async function generatePlanWithAnthropic(args) {
764
911
  usage = extractUsage(finalMessage);
765
912
  const streamStopReason = finalMessage?.stop_reason;
766
913
  if (streamStopReason === "max_tokens") {
914
+ // Don't throw yet — the buffer below usually holds a nearly-complete
915
+ // plan. Record it and let the repair fallback try to salvage one.
916
+ streamTruncated = true;
767
917
  args.log?.warn({
768
918
  event: "anthropic_planner_truncated",
769
919
  model: args.model,
770
920
  stopReason: streamStopReason,
771
921
  toolJsonBufLength: toolJsonBuf.length,
772
922
  textBufLength: textBuf.length
773
- }, "Anthropic planner: response truncated (max_tokens)");
774
- throw new Error("Model response was truncated (max_tokens reached)");
923
+ }, "Anthropic planner: response truncated (max_tokens) — attempting salvage from streamed buffer");
775
924
  }
776
925
  const finalToolBlock = finalMessage.content
777
926
  ?.map((block) => asToolUseBlock(block))
@@ -781,8 +930,6 @@ export async function generatePlanWithAnthropic(args) {
781
930
  }
782
931
  }
783
932
  catch (finalMsgErr) {
784
- if (finalMsgErr instanceof Error && finalMsgErr.message.includes("max_tokens"))
785
- throw finalMsgErr;
786
933
  args.log?.warn({
787
934
  event: "anthropic_planner_final_message_failed",
788
935
  model: args.model,
@@ -811,6 +958,13 @@ export async function generatePlanWithAnthropic(args) {
811
958
  }
812
959
  }
813
960
  }
961
+ if (streamTruncated) {
962
+ const salvaged = salvageTruncatedPlan({ parsed, buffer: toolJsonBuf, log: args.log, model: args.model });
963
+ // Nothing parseable survived — let the caller retry with a bigger budget.
964
+ if (!salvaged)
965
+ throw new Error("Model response was truncated (max_tokens reached)");
966
+ parsed = salvaged;
967
+ }
814
968
  }
815
969
  else {
816
970
  const response = await client.messages.create({
@@ -18,6 +18,19 @@ export declare function findExplicitCtaTargetCoverageGap(args: {
18
18
  currentPage: PageDoc;
19
19
  slug: string;
20
20
  }): string | null;
21
+ /**
22
+ * The checklist a full-page translation has to satisfy, block by block.
23
+ *
24
+ * Telling the planner "translate all blocks" is not the same as telling it which
25
+ * blocks exist — prose scope is easy to under-read, and a forgotten section
26
+ * looks identical to a section that was never in scope. This enumerates the work
27
+ * so the model can be held to a count it can see.
28
+ */
29
+ export declare function buildFullPageTranslationChecklist(page: PageDoc): {
30
+ blockId: string;
31
+ blockType: string;
32
+ fields: string[];
33
+ }[];
21
34
  export declare function findFullPageTranslationCoverageGap(args: {
22
35
  plan: EditPlan;
23
36
  message: string;
@@ -228,6 +228,64 @@ export function findExplicitCtaTargetCoverageGap(args) {
228
228
  }
229
229
  return null;
230
230
  }
231
+ /** Field kinds whose values are prose and must be translated. */
232
+ function isTranslatableKind(kind) {
233
+ return kind === "text" || kind === "richtext" || kind === "imageAlt";
234
+ }
235
+ /**
236
+ * The translatable surface of one block: which top-level props and which
237
+ * list-child fields carry prose. One definition, used both to tell the planner
238
+ * what it must cover and to check afterwards whether it did.
239
+ */
240
+ function translatableFieldKeys(meta) {
241
+ const topLevel = Object.entries(meta?.fields ?? {})
242
+ .filter(([, fm]) => isTranslatableKind(fm.kind))
243
+ .map(([key]) => key);
244
+ const lists = Object.entries(meta?.listFields ?? {})
245
+ .map(([listKey, listMeta]) => [
246
+ listKey,
247
+ Object.entries(listMeta.itemFields ?? {})
248
+ .filter(([, fm]) => isTranslatableKind(fm.kind))
249
+ .map(([key]) => key)
250
+ ])
251
+ .filter(([, itemFields]) => itemFields.length > 0);
252
+ return { topLevel, lists };
253
+ }
254
+ /**
255
+ * The checklist a full-page translation has to satisfy, block by block.
256
+ *
257
+ * Telling the planner "translate all blocks" is not the same as telling it which
258
+ * blocks exist — prose scope is easy to under-read, and a forgotten section
259
+ * looks identical to a section that was never in scope. This enumerates the work
260
+ * so the model can be held to a count it can see.
261
+ */
262
+ export function buildFullPageTranslationChecklist(page) {
263
+ const blockMeta = getAllBlockMeta();
264
+ const entries = [];
265
+ for (const block of page.blocks) {
266
+ const { topLevel, lists } = translatableFieldKeys(blockMeta[block.type]);
267
+ const props = block.props;
268
+ const fields = topLevel.filter((key) => isNonEmptyString(props[key]));
269
+ for (const [listKey, itemFields] of lists) {
270
+ const listValue = props[listKey];
271
+ if (!Array.isArray(listValue))
272
+ continue;
273
+ for (let idx = 0; idx < listValue.length; idx += 1) {
274
+ const item = listValue[idx];
275
+ if (!item || typeof item !== "object" || Array.isArray(item))
276
+ continue;
277
+ const row = item;
278
+ for (const field of itemFields) {
279
+ if (isNonEmptyString(row[field]))
280
+ fields.push(`${listKey}[${idx}].${field}`);
281
+ }
282
+ }
283
+ }
284
+ if (fields.length > 0)
285
+ entries.push({ blockId: block.id, blockType: String(block.type), fields });
286
+ }
287
+ return entries;
288
+ }
231
289
  export function findFullPageTranslationCoverageGap(args) {
232
290
  if (inferTranslationScopeFromMessage(args.message) !== "page")
233
291
  return null;
@@ -244,47 +302,38 @@ export function findFullPageTranslationCoverageGap(args) {
244
302
  .filter((id) => typeof id === "string" && id.length > 0));
245
303
  if (touchedBlockIds.size === 0)
246
304
  return null;
305
+ // Every block is checked, not just the ones the plan touched. Scoping this to
306
+ // touchedBlockIds meant a block the model skipped *entirely* was invisible to
307
+ // the coverage check — a page-wide translation could leave a whole FAQ section
308
+ // in English and still report full coverage, because the section it forgot was
309
+ // also the section it was never asked about.
247
310
  const missingPaths = [];
248
311
  for (const block of args.currentPage.blocks) {
249
- if (!touchedBlockIds.has(block.id))
250
- continue;
251
- const meta = blockMeta[block.type];
312
+ const { topLevel: translatableTopLevel, lists: translatableLists } = translatableFieldKeys(blockMeta[block.type]);
252
313
  // --- Top-level scalar prop coverage ---
253
- if (meta?.fields) {
254
- const translatableTopLevel = Object.entries(meta.fields)
255
- .filter(([, fm]) => fm.kind === "text" || fm.kind === "richtext" || fm.kind === "imageAlt")
256
- .map(([key]) => key);
257
- if (translatableTopLevel.length > 0) {
258
- const coveredTopLevel = new Set();
259
- for (const op of args.plan.ops) {
260
- if (!("pageSlug" in op) || op.pageSlug !== args.slug)
261
- continue;
262
- if (op.op === "update_props" && op.blockId === block.id) {
263
- const patch = normalizeUpdatePropsPatch(op);
264
- for (const key of translatableTopLevel) {
265
- if (isNonEmptyString(patch[key]))
266
- coveredTopLevel.add(key);
267
- }
314
+ if (translatableTopLevel.length > 0) {
315
+ const coveredTopLevel = new Set();
316
+ for (const op of args.plan.ops) {
317
+ if (!("pageSlug" in op) || op.pageSlug !== args.slug)
318
+ continue;
319
+ if (op.op === "update_props" && op.blockId === block.id) {
320
+ const patch = normalizeUpdatePropsPatch(op);
321
+ for (const key of translatableTopLevel) {
322
+ if (isNonEmptyString(patch[key]))
323
+ coveredTopLevel.add(key);
268
324
  }
269
325
  }
270
- const blockProps = block.props;
271
- for (const key of translatableTopLevel) {
272
- if (!isNonEmptyString(blockProps[key]))
273
- continue;
274
- if (!coveredTopLevel.has(key))
275
- missingPaths.push(`${block.id}.${key}`);
276
- }
326
+ }
327
+ const blockProps = block.props;
328
+ for (const key of translatableTopLevel) {
329
+ if (!isNonEmptyString(blockProps[key]))
330
+ continue;
331
+ if (!coveredTopLevel.has(key))
332
+ missingPaths.push(`${block.id}.${key}`);
277
333
  }
278
334
  }
279
335
  // --- List field child coverage ---
280
- const listFields = meta?.listFields ?? {};
281
- const listEntries = Object.entries(listFields);
282
- for (const [listKey, listMeta] of listEntries) {
283
- const translatableItemFields = Object.entries(listMeta.itemFields ?? {})
284
- .filter(([, fieldMeta]) => fieldMeta.kind === "text" || fieldMeta.kind === "richtext" || fieldMeta.kind === "imageAlt")
285
- .map(([key]) => key);
286
- if (translatableItemFields.length === 0)
287
- continue;
336
+ for (const [listKey, translatableItemFields] of translatableLists) {
288
337
  const listValue = block.props[listKey];
289
338
  if (!Array.isArray(listValue) || listValue.length === 0)
290
339
  continue;
@@ -350,9 +399,17 @@ export function findFullPageTranslationCoverageGap(args) {
350
399
  }
351
400
  if (missingPaths.length === 0)
352
401
  return null;
353
- // Auto-patch: fill missing fields with original (untranslated) values rather than failing.
354
- // This ensures partial LLM translations still apply untranslated text is better than no translation.
402
+ // Auto-patch: where the plan already edits a block but left one of its fields
403
+ // out, fill that field with the original (untranslated) value rather than
404
+ // failing — a partially translated block still applies, and untranslated text
405
+ // beats losing the field.
406
+ //
407
+ // A block the plan never touched is a different case: there is no op to patch
408
+ // into, and writing the English back would be a no-op that papers over the
409
+ // miss. Those paths stay unresolved and are reported, so the caller can ask
410
+ // the model for the translation it skipped.
355
411
  let patched = 0;
412
+ const unresolved = [];
356
413
  for (const path of missingPaths) {
357
414
  // Parse path: "b_hero_1.heading" or "b_cardgrid_7.cards[0].imageAlt"
358
415
  const topMatch = path.match(/^([^.]+)\.([^[]+)$/);
@@ -374,6 +431,9 @@ export function findFullPageTranslationCoverageGap(args) {
374
431
  patched++;
375
432
  }
376
433
  }
434
+ else {
435
+ unresolved.push(path);
436
+ }
377
437
  }
378
438
  else if (listMatch) {
379
439
  const [, blockId, listKey, indexStr, field] = listMatch;
@@ -390,23 +450,27 @@ export function findFullPageTranslationCoverageGap(args) {
390
450
  continue;
391
451
  // Find the existing update_props op and patch the list item
392
452
  const existingOp = args.plan.ops.find(op => op.op === "update_props" && "blockId" in op && op.blockId === blockId && "pageSlug" in op && op.pageSlug === args.slug);
393
- if (existingOp && "patch" in existingOp) {
394
- const patch = existingOp.patch;
395
- if (Array.isArray(patch[listKey]) && patch[listKey][idx] && typeof patch[listKey][idx] === "object") {
396
- const itemPatch = patch[listKey][idx];
397
- if (!isNonEmptyString(itemPatch[field])) {
398
- itemPatch[field] = originalValue;
399
- patched++;
400
- }
453
+ const patch = existingOp && "patch" in existingOp ? existingOp.patch : null;
454
+ const itemPatch = patch && Array.isArray(patch[listKey]) && patch[listKey][idx] && typeof patch[listKey][idx] === "object"
455
+ ? patch[listKey][idx]
456
+ : null;
457
+ if (itemPatch) {
458
+ if (!isNonEmptyString(itemPatch[field])) {
459
+ itemPatch[field] = originalValue;
460
+ patched++;
401
461
  }
402
462
  }
463
+ else {
464
+ unresolved.push(path);
465
+ }
403
466
  }
404
467
  }
405
468
  if (patched > 0) {
406
469
  console.log(`[translation-coverage] Auto-patched ${patched} missing fields with original values`);
407
- return null; // Patched — no gap anymore
408
470
  }
409
- return `Invalid full-page translation coverage. Missing translated fields: ${missingPaths.join(", ")}`;
471
+ if (unresolved.length === 0)
472
+ return null;
473
+ return `Invalid full-page translation coverage. Missing translated fields: ${unresolved.join(", ")}`;
410
474
  }
411
475
  export function normalizeVariationTypos(text) {
412
476
  return text
@@ -9,7 +9,7 @@ import { generatePlanWithAnthropic, parseIntentWithAnthropic } from "./anthropic
9
9
  import { generatePlanWithGemini, parseIntentWithGemini } from "./gemini-planner.ts";
10
10
  import { type ThinkingEvent } from "./planner-types.ts";
11
11
  export { sentenceCase, firstUrlFromText, preferredImageAltText, collectMentionedSlugsFromPlan, collectMentionedSlugsFromOps, normalizePlanCopyForUi, futureToPastTense } from "./chat-pipeline-ui.ts";
12
- export { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, isNonEmptyString, findFullPageTranslationCoverageGap, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest, type CloneAndTranslateRequest, type TranslationScope } from "./chat-pipeline-translation.ts";
12
+ export { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, isNonEmptyString, findFullPageTranslationCoverageGap, buildFullPageTranslationChecklist, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest, type CloneAndTranslateRequest, type TranslationScope } from "./chat-pipeline-translation.ts";
13
13
  export { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, shouldEnableReasoningForMessage } from "./chat-pipeline-context.ts";
14
14
  export { isRewriteLikeMessage, isPerformanceAwareMessage, isLikelyTextField, collectChangedTextFields, buildMetaChangeLogEntries, buildAiInsightChanges, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, deterministicSelectedTextRewritePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.ts";
15
15
  export { blockHasImageUrlProp, parsePath, getValueAtPath, setValueAtPath, deleteValueAtPath, extractIndexedQueries, extractReferencedItemIndices, blockSupportsImageAtPath, detectImagePaths, imageQueryFromItem, shouldPopulateAllChildImages, findImageTargets, rewriteAddBlockToChildImageUpdate, withUnsplashHeroImage, shouldResolveCreatePageHeroImage, resolveHeroImageForCreatePage, detectImageOps } from "./chat-pipeline-image.ts";