@avocadostudio-ai/orchestrator-core 0.3.2 → 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 (69) 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 +296 -53
  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/translation-chunking.d.ts +124 -0
  14. package/dist/chat/translation-chunking.js +371 -0
  15. package/dist/checks/field-walk.d.ts +25 -0
  16. package/dist/checks/field-walk.js +152 -0
  17. package/dist/checks/index.d.ts +5 -0
  18. package/dist/checks/index.js +4 -0
  19. package/dist/checks/page-weight.d.ts +22 -0
  20. package/dist/checks/page-weight.js +200 -0
  21. package/dist/checks/rules-draft.d.ts +2 -0
  22. package/dist/checks/rules-draft.js +375 -0
  23. package/dist/checks/run-checks.d.ts +32 -0
  24. package/dist/checks/run-checks.js +152 -0
  25. package/dist/checks/session-runner.d.ts +19 -0
  26. package/dist/checks/session-runner.js +95 -0
  27. package/dist/checks/types.d.ts +65 -0
  28. package/dist/checks/types.js +1 -0
  29. package/dist/durable/durable-store-singleton.d.ts +37 -0
  30. package/dist/durable/durable-store-singleton.js +179 -0
  31. package/dist/durable/finding-impact.d.ts +30 -0
  32. package/dist/durable/finding-impact.js +53 -0
  33. package/dist/durable/in-memory-durable-store.d.ts +203 -0
  34. package/dist/durable/in-memory-durable-store.js +363 -0
  35. package/dist/durable/index.d.ts +5 -0
  36. package/dist/durable/index.js +4 -0
  37. package/dist/durable/pending-plan-store.d.ts +28 -0
  38. package/dist/durable/pending-plan-store.js +156 -0
  39. package/dist/durable/sqlite-durable-store.d.ts +71 -0
  40. package/dist/durable/sqlite-durable-store.js +631 -0
  41. package/dist/durable/types.d.ts +265 -0
  42. package/dist/durable/types.js +1 -0
  43. package/dist/handler/create-orchestrator.d.ts +4 -0
  44. package/dist/handler/create-orchestrator.js +67 -4
  45. package/dist/http/audio-actions.d.ts +1 -1
  46. package/dist/http/checks-actions.d.ts +39 -0
  47. package/dist/http/checks-actions.js +122 -0
  48. package/dist/http/history-actions.d.ts +1 -1
  49. package/dist/http/image-generate-actions.d.ts +2 -2
  50. package/dist/http/ops-actions.d.ts +2 -2
  51. package/dist/http/publish-actions.d.ts +4 -4
  52. package/dist/http/restore-actions.d.ts +3 -3
  53. package/dist/http/screenshot-actions.d.ts +2 -2
  54. package/dist/http/session-actions.d.ts +1 -1
  55. package/dist/http/telemetry-feedback-actions.d.ts +2 -2
  56. package/dist/http/unsplash-actions.d.ts +2 -2
  57. package/dist/http/variations-actions.d.ts +2 -2
  58. package/dist/index.d.ts +7 -0
  59. package/dist/index.js +27 -0
  60. package/dist/nlp/deterministic-planner-context.d.ts +16 -0
  61. package/dist/nlp/deterministic-planner-context.js +33 -7
  62. package/dist/nlp/plan-normalizer.js +54 -6
  63. package/dist/ops/destructive-action-gate.js +7 -2
  64. package/dist/ops/ops-engine.d.ts +12 -1
  65. package/dist/ops/ops-engine.js +41 -14
  66. package/dist/publish/publish-target-registry.js +1 -1
  67. package/dist/publish/publish-target.d.ts +1 -1
  68. package/dist/state/session-state.js +8 -1
  69. package/package.json +3 -3
@@ -29,7 +29,7 @@ const openAIPlanner = {
29
29
  },
30
30
  async generatePlan(args) {
31
31
  // OpenAI doesn't yet support Anthropic-style thinking events — drop them.
32
- const { onStatusUpdate: _s, onImageProgress: _i, log: _l, onThinking: _t, thinking: _th, ...rest } = args;
32
+ const { onStatusUpdate: _s, onImageProgress: _i, log: _l, onThinking: _t, thinking: _th, outputTokenScale: _o, ...rest } = args;
33
33
  return generatePlanWithOpenAI(rest);
34
34
  },
35
35
  };
@@ -44,7 +44,7 @@ const geminiPlanner = {
44
44
  supportsNativeTools: true,
45
45
  parseIntent: parseIntentWithGemini,
46
46
  async generatePlan(args) {
47
- const { onThinking: _t, thinking: _th, ...rest } = args;
47
+ const { onThinking: _t, thinking: _th, outputTokenScale: _o, ...rest } = args;
48
48
  return generatePlanWithGemini(rest);
49
49
  },
50
50
  };
@@ -45,6 +45,16 @@ export declare function buildPlannerSchemaContext(args: {
45
45
  forceFullContracts?: boolean;
46
46
  componentsManifest?: BlockManifest;
47
47
  effectiveBlockTypes?: string[];
48
+ /**
49
+ * Hard ceiling on which block contracts may be sent, whatever mode is chosen.
50
+ *
51
+ * A page-wide translation asks for `full` contracts because it edits every
52
+ * block on the page — but a *chunk* of one edits only the blocks it holds, and
53
+ * shipping all eighteen contracts to each of six chunks repeats ~9 KB six times
54
+ * for no gain. Set only where the caller can guarantee the model cannot
55
+ * legitimately reference a type outside the list.
56
+ */
57
+ contractBlockTypeAllowlist?: string[];
48
58
  }): {
49
59
  payload: PlannerSchemaContextPayload;
50
60
  meta: PlannerSchemaContextMeta;
@@ -136,6 +146,8 @@ export declare function generatePlanWithOpenAI(args: {
136
146
  client?: PlannerOpenAIClient;
137
147
  siteContextBlock?: string | null;
138
148
  forceFullSchemaContracts?: boolean;
149
+ /** Restrict block-schema contracts to these types. See CommonGeneratePlanArgs. */
150
+ contractBlockTypeAllowlist?: string[];
139
151
  componentsManifest?: BlockManifest;
140
152
  lightweight?: boolean;
141
153
  signal?: AbortSignal;
@@ -191,13 +191,26 @@ function buildContractsForMode(args) {
191
191
  payload.pageMetaContract = pageMetaContractSummary();
192
192
  return payload;
193
193
  }
194
+ /** Narrow the contract set to an allowlist, when the caller supplied a non-empty one. */
195
+ function restrictContracts(allContracts, allowlist) {
196
+ if (!allowlist || allowlist.length === 0)
197
+ return allContracts;
198
+ const restricted = {};
199
+ for (const type of allowlist) {
200
+ if (type in allContracts)
201
+ restricted[type] = allContracts[type];
202
+ }
203
+ // An allowlist that matches nothing is a caller bug, not an instruction to
204
+ // send the model a page it has no schema for — fall back to everything.
205
+ return Object.keys(restricted).length > 0 ? restricted : allContracts;
206
+ }
194
207
  export function buildPlannerSchemaContext(args) {
195
208
  const strictJsonEnabled = isStrictJsonResponseEnabled();
196
209
  const targetBlockTypes = pickTargetBlockTypes({ message: args.message, contextPack: args.contextPack });
197
210
  const includePageMetaContract = /\b(seo|meta|metadata|og\s*image|open\s*graph|description|structured\s*data|schema\.org)\b/i.test(args.message) || /\d{2,3}\s*char/i.test(args.message);
198
211
  const knownTypes = args.effectiveBlockTypes
199
212
  ?? (args.componentsManifest ? args.componentsManifest.blocks.map(c => c.type) : Object.keys(blockSchemas));
200
- const allContracts = blockContractsSummary(args.componentsManifest);
213
+ const allContracts = restrictContracts(blockContractsSummary(args.componentsManifest), args.contractBlockTypeAllowlist);
201
214
  if (!isAdaptiveSchemaContextEnabled()) {
202
215
  const payload = args.legacyIncludeContracts
203
216
  ? {
@@ -1162,7 +1175,8 @@ export async function generatePlanWithOpenAI(args) {
1162
1175
  pageWideTranslation,
1163
1176
  legacyIncludeContracts: includeContracts,
1164
1177
  forceFullContracts: args.forceFullSchemaContracts,
1165
- componentsManifest: args.componentsManifest
1178
+ componentsManifest: args.componentsManifest,
1179
+ contractBlockTypeAllowlist: args.contractBlockTypeAllowlist
1166
1180
  });
1167
1181
  const user = {
1168
1182
  request: args.message,
@@ -0,0 +1,124 @@
1
+ import type { EditPlan, PageDoc } from "@avocadostudio-ai/shared";
2
+ import type { plannerContextPack } from "../nlp/deterministic-planner.ts";
3
+ import type { CommonGeneratePlanArgs, GeneratePlanResult } from "./planner-types.ts";
4
+ export declare function translationChunkingConfig(): {
5
+ enabled: boolean;
6
+ /**
7
+ * Translatable bytes to aim for per chunk.
8
+ *
9
+ * Chunk wall clock is roughly a fixed time-to-first-token plus streaming time
10
+ * proportional to this. Measured on Sonnet 5: ~5s to first token, then ~130
11
+ * output tokens/second, and a chunk's output runs a little over one token per
12
+ * source byte. ~900 bytes puts streaming (~8s) in the same range as the fixed
13
+ * cost, which is where splitting further stops buying much and starts paying
14
+ * another chunk's input tokens for nothing.
15
+ */
16
+ targetBytes: number;
17
+ /** Upper bound on parallel planner calls — one wave, not a stampede. */
18
+ maxChunks: number;
19
+ /** Below these, a single request is already fast and chunking only adds input cost. */
20
+ minBlocks: number;
21
+ minBytes: number;
22
+ };
23
+ export type TranslationChunk = {
24
+ blockIds: string[];
25
+ /** Translatable bytes carried by this chunk — the driver of its output length. */
26
+ bytes: number;
27
+ };
28
+ /**
29
+ * Partition the page's translatable blocks into balanced chunks.
30
+ *
31
+ * Wall clock is the *heaviest* chunk, not the average one, so balance is the
32
+ * whole game: a first live run split a page into 1604 / 1010 / 172 / 769 bytes
33
+ * and spent 17 of its 23 seconds waiting on the first bin while a 172-byte bin
34
+ * sat finished. Packing blocks in page order is what produces that — it has to
35
+ * close a chunk before it can see what comes next.
36
+ *
37
+ * So blocks are packed heaviest-first into whichever chunk is currently lightest
38
+ * (longest-processing-time-first), which bounds the heaviest chunk far better,
39
+ * and page order is restored afterwards so the change log still reads top to
40
+ * bottom. Blocks are never split: an `update_props` op carries a whole block.
41
+ *
42
+ * Returns a single chunk when the page is too small to be worth splitting; the
43
+ * caller treats that as "don't chunk".
44
+ */
45
+ export declare function planTranslationChunks(args: {
46
+ page: PageDoc;
47
+ targetBytes?: number;
48
+ maxChunks?: number;
49
+ }): TranslationChunk[];
50
+ /** Whether this page is worth fanning out, per the configured thresholds. */
51
+ export declare function shouldChunkTranslation(page: PageDoc, chunks: TranslationChunk[]): boolean;
52
+ /**
53
+ * The page as this chunk should see it: same id/slug/meta, only its own blocks.
54
+ *
55
+ * Everything downstream in the planner derives from `currentPage` — the output
56
+ * token budget, and the enumerated translation checklist handed to the model —
57
+ * so narrowing the page is what makes a chunk a chunk. No extra plumbing.
58
+ */
59
+ export declare function subsetPageForChunk(page: PageDoc, blockIds: Set<string>): PageDoc;
60
+ /** The context pack narrowed to the chunk's blocks, so input cost doesn't repeat the whole page N times. */
61
+ export declare function subsetContextPackForChunk(pack: ReturnType<typeof plannerContextPack>, blockIds: Set<string>): ReturnType<typeof plannerContextPack>;
62
+ /**
63
+ * Keep only what this chunk was asked for.
64
+ *
65
+ * A chunk sees a slice of the page, but nothing stops a model from volunteering
66
+ * an op for a block it half-remembers from the site context, or from restructuring
67
+ * a page it was asked to translate. Ops outside the chunk's blocks are dropped
68
+ * rather than merged: a translation that also deletes a section is not a
69
+ * translation, and two chunks editing the same block would silently race.
70
+ */
71
+ export declare function filterChunkOps(args: {
72
+ ops: EditPlan["ops"];
73
+ blockIds: Set<string>;
74
+ isFirstChunk: boolean;
75
+ }): {
76
+ kept: EditPlan["ops"];
77
+ droppedCount: number;
78
+ };
79
+ export type ChunkOutcome = {
80
+ index: number;
81
+ blockIds: string[];
82
+ ok: boolean;
83
+ opCount: number;
84
+ droppedOpCount: number;
85
+ attempts: number;
86
+ /** Wall clock for this chunk. The slowest one is the request's latency — the number to tune against. */
87
+ durationMs: number;
88
+ bytes: number;
89
+ outputTokens: number;
90
+ reason?: string;
91
+ };
92
+ /**
93
+ * Run every chunk in parallel and merge the results into one plan.
94
+ *
95
+ * A chunk that dies on a token ceiling is retried once on its own, with double
96
+ * the budget — retrying one chunk is cheap where retrying the page is not. A
97
+ * chunk that still fails is left out: the merged plan is short those blocks, and
98
+ * the pipeline's translation coverage gate is what notices and repairs that. The
99
+ * whole call only fails when every chunk failed, so the caller's own retry loop
100
+ * sees a normal planner failure.
101
+ */
102
+ export declare function generateChunkedTranslationPlan(args: {
103
+ plannerArgs: CommonGeneratePlanArgs;
104
+ chunks: TranslationChunk[];
105
+ generate: (plannerArgs: CommonGeneratePlanArgs) => Promise<GeneratePlanResult>;
106
+ log?: {
107
+ warn: (obj: Record<string, unknown>, msg: string) => void;
108
+ info?: (obj: Record<string, unknown>, msg: string) => void;
109
+ };
110
+ onChunksSettled?: (outcomes: ChunkOutcome[]) => void;
111
+ }): Promise<GeneratePlanResult>;
112
+ /**
113
+ * Fold the chunk plans back into one.
114
+ *
115
+ * Chunks are balanced by weight rather than page position, so their ops come
116
+ * back shuffled; `blockOrder` puts them back the way the page reads, which is
117
+ * the order the change log and the plan preview are reviewed in.
118
+ *
119
+ * The user-facing summary is not concatenated: every chunk was given the user's
120
+ * original message, so every chunk wrote a summary of the same request
121
+ * ("Translated the page into Russian"), and stacking six of those reads like a
122
+ * stutter. The first one stands for all.
123
+ */
124
+ export declare function mergeChunkResults(results: GeneratePlanResult[], blockOrder?: string[]): GeneratePlanResult;
@@ -0,0 +1,371 @@
1
+ import { buildFullPageTranslationChecklist } from "./chat-pipeline-translation.js";
2
+ import { ZERO_USAGE } from "../telemetry/usage.js";
3
+ /**
4
+ * Full-page translation, split into independent per-block-group requests.
5
+ *
6
+ * One request for the whole page is the wrong shape for this job. A translation
7
+ * re-emits every string on the page, so its *output* length scales with the page
8
+ * while a single request's output is strictly serial: an 8 KB page cost 9,139
9
+ * output tokens and 75 seconds of streaming, and a page twice that size costs
10
+ * twice as long. Worse, the whole thing is one all-or-nothing unit — overshoot
11
+ * the token ceiling on the last block and the other six are discarded too.
12
+ *
13
+ * The blocks are independent, though. Nothing in translating the FAQ depends on
14
+ * how the hero was translated, so the work fans out: N smaller requests in
15
+ * parallel, each carrying only its own blocks, merged back into one plan. Wall
16
+ * clock becomes the slowest chunk rather than the sum, each chunk is far from
17
+ * any token ceiling, and a chunk that does fail costs only its own blocks.
18
+ *
19
+ * The tradeoff is input tokens — the schema contract and site context repeat per
20
+ * chunk — so each chunk carries only its own slice of the page outline, and the
21
+ * whole path is gated on the page being big enough to be worth it.
22
+ */
23
+ // ---------------------------------------------------------------------------
24
+ // Configuration
25
+ // ---------------------------------------------------------------------------
26
+ function envFlagOff(raw) {
27
+ return /^(0|false|no|off)$/i.test((raw ?? "").trim());
28
+ }
29
+ function envInt(raw, fallback) {
30
+ const parsed = Number((raw ?? "").trim());
31
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
32
+ }
33
+ export function translationChunkingConfig() {
34
+ return {
35
+ enabled: !envFlagOff(process.env.CHAT_TRANSLATION_CHUNKING),
36
+ /**
37
+ * Translatable bytes to aim for per chunk.
38
+ *
39
+ * Chunk wall clock is roughly a fixed time-to-first-token plus streaming time
40
+ * proportional to this. Measured on Sonnet 5: ~5s to first token, then ~130
41
+ * output tokens/second, and a chunk's output runs a little over one token per
42
+ * source byte. ~900 bytes puts streaming (~8s) in the same range as the fixed
43
+ * cost, which is where splitting further stops buying much and starts paying
44
+ * another chunk's input tokens for nothing.
45
+ */
46
+ targetBytes: envInt(process.env.CHAT_TRANSLATION_CHUNK_BYTES, 900),
47
+ /** Upper bound on parallel planner calls — one wave, not a stampede. */
48
+ maxChunks: envInt(process.env.CHAT_TRANSLATION_MAX_CHUNKS, 6),
49
+ /** Below these, a single request is already fast and chunking only adds input cost. */
50
+ minBlocks: envInt(process.env.CHAT_TRANSLATION_CHUNK_MIN_BLOCKS, 3),
51
+ minBytes: envInt(process.env.CHAT_TRANSLATION_CHUNK_MIN_BYTES, 1800)
52
+ };
53
+ }
54
+ /**
55
+ * Weigh each block by the `update_props` patch a translation has to emit for it.
56
+ *
57
+ * Two wrong answers were tried first. Block JSON size over-weights a Gallery of
58
+ * twenty image URLs that has nothing to translate. Translatable string length —
59
+ * the obvious fix — under-weights list blocks badly: a live run gave one 769-byte
60
+ * CardGrid the same weight as a 1030-byte prose block and then watched it take
61
+ * twice as long, because a list patch re-sends *every row* (a shorter array
62
+ * truncates the list) and pays JSON structure for each one.
63
+ *
64
+ * So the weight is the thing being predicted: build the minimal patch we ask the
65
+ * model for and measure it.
66
+ */
67
+ function weighBlocks(page) {
68
+ const checklist = buildFullPageTranslationChecklist(page);
69
+ return checklist.map((entry) => {
70
+ const block = page.blocks.find((b) => b.id === entry.blockId);
71
+ const props = (block?.props ?? {});
72
+ const patch = {};
73
+ for (const field of entry.fields) {
74
+ const listMatch = field.match(/^([^[]+)\[(\d+)\]\.(.+)$/);
75
+ if (!listMatch) {
76
+ if (typeof props[field] === "string")
77
+ patch[field] = props[field];
78
+ continue;
79
+ }
80
+ const [, listKey, rawIndex, itemField] = listMatch;
81
+ const source = props[listKey];
82
+ if (!Array.isArray(source))
83
+ continue;
84
+ const rows = patch[listKey]
85
+ ?? source.map(() => ({}));
86
+ patch[listKey] = rows;
87
+ const value = source[Number(rawIndex)]?.[itemField];
88
+ if (typeof value === "string")
89
+ rows[Number(rawIndex)][itemField] = value;
90
+ }
91
+ return { blockId: entry.blockId, bytes: JSON.stringify(patch).length };
92
+ });
93
+ }
94
+ /**
95
+ * Partition the page's translatable blocks into balanced chunks.
96
+ *
97
+ * Wall clock is the *heaviest* chunk, not the average one, so balance is the
98
+ * whole game: a first live run split a page into 1604 / 1010 / 172 / 769 bytes
99
+ * and spent 17 of its 23 seconds waiting on the first bin while a 172-byte bin
100
+ * sat finished. Packing blocks in page order is what produces that — it has to
101
+ * close a chunk before it can see what comes next.
102
+ *
103
+ * So blocks are packed heaviest-first into whichever chunk is currently lightest
104
+ * (longest-processing-time-first), which bounds the heaviest chunk far better,
105
+ * and page order is restored afterwards so the change log still reads top to
106
+ * bottom. Blocks are never split: an `update_props` op carries a whole block.
107
+ *
108
+ * Returns a single chunk when the page is too small to be worth splitting; the
109
+ * caller treats that as "don't chunk".
110
+ */
111
+ export function planTranslationChunks(args) {
112
+ const config = translationChunkingConfig();
113
+ const targetBytes = args.targetBytes ?? config.targetBytes;
114
+ const maxChunks = args.maxChunks ?? config.maxChunks;
115
+ const weighted = weighBlocks(args.page);
116
+ if (weighted.length === 0)
117
+ return [];
118
+ const totalBytes = weighted.reduce((sum, entry) => sum + entry.bytes, 0);
119
+ const chunkCount = Math.min(weighted.length, maxChunks, Math.max(1, Math.ceil(totalBytes / targetBytes)));
120
+ if (chunkCount <= 1) {
121
+ return [{ blockIds: weighted.map((entry) => entry.blockId), bytes: totalBytes }];
122
+ }
123
+ const pageOrder = new Map(weighted.map((entry, index) => [entry.blockId, index]));
124
+ const bins = Array.from({ length: chunkCount }, () => ({ blockIds: [], bytes: 0 }));
125
+ // Heaviest first into the lightest bin. The first `chunkCount` blocks land in
126
+ // distinct empty bins, so no bin is ever left empty.
127
+ for (const entry of [...weighted].sort((a, b) => b.bytes - a.bytes)) {
128
+ const lightest = bins.reduce((min, bin) => (bin.bytes < min.bytes ? bin : min));
129
+ lightest.blockIds.push(entry.blockId);
130
+ lightest.bytes += entry.bytes;
131
+ }
132
+ for (const bin of bins)
133
+ bin.blockIds.sort((a, b) => pageOrder.get(a) - pageOrder.get(b));
134
+ bins.sort((a, b) => pageOrder.get(a.blockIds[0]) - pageOrder.get(b.blockIds[0]));
135
+ return bins;
136
+ }
137
+ /** Whether this page is worth fanning out, per the configured thresholds. */
138
+ export function shouldChunkTranslation(page, chunks) {
139
+ const config = translationChunkingConfig();
140
+ if (!config.enabled)
141
+ return false;
142
+ if (chunks.length < 2)
143
+ return false;
144
+ const translatableBlocks = chunks.reduce((sum, chunk) => sum + chunk.blockIds.length, 0);
145
+ const translatableBytes = chunks.reduce((sum, chunk) => sum + chunk.bytes, 0);
146
+ return translatableBlocks >= config.minBlocks && translatableBytes >= config.minBytes;
147
+ }
148
+ // ---------------------------------------------------------------------------
149
+ // Per-chunk request shaping
150
+ // ---------------------------------------------------------------------------
151
+ /**
152
+ * The page as this chunk should see it: same id/slug/meta, only its own blocks.
153
+ *
154
+ * Everything downstream in the planner derives from `currentPage` — the output
155
+ * token budget, and the enumerated translation checklist handed to the model —
156
+ * so narrowing the page is what makes a chunk a chunk. No extra plumbing.
157
+ */
158
+ export function subsetPageForChunk(page, blockIds) {
159
+ return { ...page, blocks: page.blocks.filter((block) => blockIds.has(block.id)) };
160
+ }
161
+ /** The context pack narrowed to the chunk's blocks, so input cost doesn't repeat the whole page N times. */
162
+ export function subsetContextPackForChunk(pack, blockIds) {
163
+ const outline = pack.pageOutline.filter((entry) => blockIds.has(entry.id));
164
+ return {
165
+ ...pack,
166
+ blockCount: outline.length,
167
+ pageOutline: outline,
168
+ // The selection and its neighbours belong to the whole-page view; a chunk is
169
+ // scoped by its checklist, and a stale "selected block" would pull the model
170
+ // toward a block this chunk may not even carry.
171
+ selected: { ...pack.selected, blockId: null, blockType: null, editablePath: null, block: null, imageUrlForVision: null },
172
+ neighbors: { previous: null, next: null }
173
+ };
174
+ }
175
+ /**
176
+ * Page-level ops a translation may legitimately emit, but only once. Handing the
177
+ * same site config to six chunks invites six conflicting `update_site_config`
178
+ * ops, so only the first chunk is allowed to produce them.
179
+ */
180
+ const PAGE_LEVEL_OPS = new Set(["update_page_meta", "update_site_config"]);
181
+ /** Ops that address a block, and so must address one of *this* chunk's blocks. */
182
+ const BLOCK_SCOPED_OPS = new Set(["update_props", "update_item", "add_item", "remove_item", "move_item", "reorder_items"]);
183
+ /**
184
+ * Keep only what this chunk was asked for.
185
+ *
186
+ * A chunk sees a slice of the page, but nothing stops a model from volunteering
187
+ * an op for a block it half-remembers from the site context, or from restructuring
188
+ * a page it was asked to translate. Ops outside the chunk's blocks are dropped
189
+ * rather than merged: a translation that also deletes a section is not a
190
+ * translation, and two chunks editing the same block would silently race.
191
+ */
192
+ export function filterChunkOps(args) {
193
+ const kept = args.ops.filter((op) => {
194
+ if (BLOCK_SCOPED_OPS.has(op.op)) {
195
+ return "blockId" in op && typeof op.blockId === "string" && args.blockIds.has(op.blockId);
196
+ }
197
+ if (PAGE_LEVEL_OPS.has(op.op))
198
+ return args.isFirstChunk;
199
+ return false;
200
+ });
201
+ return { kept, droppedCount: args.ops.length - kept.length };
202
+ }
203
+ /**
204
+ * Run every chunk in parallel and merge the results into one plan.
205
+ *
206
+ * A chunk that dies on a token ceiling is retried once on its own, with double
207
+ * the budget — retrying one chunk is cheap where retrying the page is not. A
208
+ * chunk that still fails is left out: the merged plan is short those blocks, and
209
+ * the pipeline's translation coverage gate is what notices and repairs that. The
210
+ * whole call only fails when every chunk failed, so the caller's own retry loop
211
+ * sees a normal planner failure.
212
+ */
213
+ export async function generateChunkedTranslationPlan(args) {
214
+ const { plannerArgs, chunks, generate } = args;
215
+ // Ops stream to the UI (and to the streamed-per-op apply) as they arrive from
216
+ // whichever chunk produced them. Order across chunks is not meaningful — each
217
+ // op patches a different block — but the index has to stay unique and rising,
218
+ // so it is handed out centrally rather than per chunk.
219
+ let streamedOpIndex = 0;
220
+ const runChunk = async (chunk, index) => {
221
+ const blockIds = new Set(chunk.blockIds);
222
+ const isFirstChunk = index === 0;
223
+ const startedAt = Date.now();
224
+ let attempts = 0;
225
+ let lastError;
226
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
227
+ attempts = attempt;
228
+ try {
229
+ const chunkPage = subsetPageForChunk(plannerArgs.currentPage, blockIds);
230
+ const result = await generate({
231
+ ...plannerArgs,
232
+ currentPage: chunkPage,
233
+ contextPack: subsetContextPackForChunk(plannerArgs.contextPack, blockIds),
234
+ outputTokenScale: (plannerArgs.outputTokenScale ?? 1) * attempt,
235
+ // A page-wide translation normally ships every block's schema contract,
236
+ // because it edits every block. A chunk edits two — and repeating ~9 KB
237
+ // of contracts per chunk would hand back in input tokens most of what
238
+ // the fan-out buys.
239
+ contractBlockTypeAllowlist: [...new Set(chunkPage.blocks.map((block) => String(block.type)))],
240
+ // Chunks are self-contained: no tools to call, no prior turns to
241
+ // resolve against. Dropping both keeps the repeated input small.
242
+ history: [],
243
+ toolRuntime: undefined,
244
+ toolCallContext: undefined,
245
+ onToolExecution: undefined,
246
+ // Prose callbacks are single-sourced. Six chunks streaming their own
247
+ // summary into one message box would interleave into nonsense; ops and
248
+ // change-log lines are independent and stream from every chunk.
249
+ onToken: isFirstChunk ? plannerArgs.onToken : undefined,
250
+ onSummaryChunk: isFirstChunk ? plannerArgs.onSummaryChunk : undefined,
251
+ onThinking: isFirstChunk ? plannerArgs.onThinking : undefined,
252
+ onStatusUpdate: isFirstChunk ? plannerArgs.onStatusUpdate : undefined,
253
+ onPlannedOp: plannerArgs.onPlannedOp
254
+ ? (op) => {
255
+ streamedOpIndex += 1;
256
+ plannerArgs.onPlannedOp(op, streamedOpIndex);
257
+ }
258
+ : undefined
259
+ });
260
+ const { kept, droppedCount } = filterChunkOps({ ops: result.plan.ops, blockIds, isFirstChunk });
261
+ if (droppedCount > 0) {
262
+ args.log?.warn({ event: "translation_chunk_ops_dropped", chunkIndex: index, droppedCount, blockIds: chunk.blockIds }, "Translation chunk returned ops outside its own blocks — dropped");
263
+ }
264
+ return {
265
+ outcome: {
266
+ index,
267
+ blockIds: chunk.blockIds,
268
+ ok: true,
269
+ opCount: kept.length,
270
+ droppedOpCount: droppedCount,
271
+ attempts,
272
+ durationMs: Date.now() - startedAt,
273
+ bytes: chunk.bytes,
274
+ outputTokens: result.usage?.outputTokens ?? 0
275
+ },
276
+ result: { ...result, plan: { ...result.plan, ops: kept } }
277
+ };
278
+ }
279
+ catch (error) {
280
+ lastError = error;
281
+ const message = error instanceof Error ? error.message : String(error);
282
+ // Only a budget failure is worth an immediate identical retry; anything
283
+ // else (cancellation, an API error) should surface to the caller now.
284
+ if (attempt === 2 || !/truncated \(max_tokens/i.test(message))
285
+ break;
286
+ args.log?.warn({ event: "translation_chunk_retry", chunkIndex: index, blockIds: chunk.blockIds, reason: message.slice(0, 200) }, "Translation chunk truncated — retrying with a larger budget");
287
+ }
288
+ }
289
+ return {
290
+ outcome: {
291
+ index,
292
+ blockIds: chunk.blockIds,
293
+ ok: false,
294
+ opCount: 0,
295
+ droppedOpCount: 0,
296
+ attempts,
297
+ durationMs: Date.now() - startedAt,
298
+ bytes: chunk.bytes,
299
+ outputTokens: 0,
300
+ reason: lastError instanceof Error ? lastError.message : String(lastError)
301
+ }
302
+ };
303
+ };
304
+ const settled = await Promise.all(chunks.map((chunk, index) => runChunk(chunk, index)));
305
+ const outcomes = settled.map((entry) => entry.outcome);
306
+ args.onChunksSettled?.(outcomes);
307
+ const succeeded = settled.filter((entry) => entry.result !== undefined);
308
+ if (succeeded.length === 0) {
309
+ const firstReason = outcomes.find((outcome) => outcome.reason)?.reason ?? "translation chunks produced no plan";
310
+ throw new Error(firstReason);
311
+ }
312
+ return mergeChunkResults(succeeded.map((entry) => entry.result), plannerArgs.currentPage.blocks.map((block) => block.id));
313
+ }
314
+ /** Chunk usage is real spend on the same request — the totals have to include all of it. */
315
+ function addUsage(a, b) {
316
+ const cacheCreation = (a.cacheCreationInputTokens ?? 0) + (b.cacheCreationInputTokens ?? 0);
317
+ const cacheRead = (a.cacheReadInputTokens ?? 0) + (b.cacheReadInputTokens ?? 0);
318
+ return {
319
+ inputTokens: a.inputTokens + b.inputTokens,
320
+ outputTokens: a.outputTokens + b.outputTokens,
321
+ totalTokens: a.totalTokens + b.totalTokens,
322
+ ...(cacheCreation > 0 ? { cacheCreationInputTokens: cacheCreation } : {}),
323
+ ...(cacheRead > 0 ? { cacheReadInputTokens: cacheRead } : {})
324
+ };
325
+ }
326
+ /**
327
+ * Fold the chunk plans back into one.
328
+ *
329
+ * Chunks are balanced by weight rather than page position, so their ops come
330
+ * back shuffled; `blockOrder` puts them back the way the page reads, which is
331
+ * the order the change log and the plan preview are reviewed in.
332
+ *
333
+ * The user-facing summary is not concatenated: every chunk was given the user's
334
+ * original message, so every chunk wrote a summary of the same request
335
+ * ("Translated the page into Russian"), and stacking six of those reads like a
336
+ * stutter. The first one stands for all.
337
+ */
338
+ export function mergeChunkResults(results, blockOrder) {
339
+ const ops = [];
340
+ const changeLog = [];
341
+ const seenBlockOps = new Set();
342
+ for (const result of results) {
343
+ for (const op of result.plan.ops) {
344
+ // Chunks are disjoint by construction, but a duplicate here would mean two
345
+ // ops racing to patch one block, and the loser's translation vanishing.
346
+ const key = "blockId" in op && typeof op.blockId === "string" ? `${op.op}:${op.blockId}` : null;
347
+ if (key) {
348
+ if (seenBlockOps.has(key))
349
+ continue;
350
+ seenBlockOps.add(key);
351
+ }
352
+ ops.push(op);
353
+ }
354
+ for (const entry of result.plan.change_log ?? [])
355
+ changeLog.push(entry);
356
+ }
357
+ if (blockOrder) {
358
+ const rank = new Map(blockOrder.map((id, index) => [id, index]));
359
+ // Page-level ops carry no block, and belong at the top where the model put them.
360
+ const rankOf = (op) => "blockId" in op && typeof op.blockId === "string" ? rank.get(op.blockId) ?? Number.MAX_SAFE_INTEGER : -1;
361
+ ops.sort((a, b) => rankOf(a) - rankOf(b));
362
+ }
363
+ const summary = results.map((result) => result.plan.summary_for_user).find((text) => typeof text === "string" && text.trim().length > 0)
364
+ ?? "Translated the page.";
365
+ return {
366
+ plan: { intent: "edit_plan", summary_for_user: summary, change_log: changeLog, ops },
367
+ usage: results.reduce((total, result) => addUsage(total, result.usage), { ...ZERO_USAGE }),
368
+ schemaContext: results[0].schemaContext,
369
+ deferredNativeImageCalls: results.flatMap((result) => result.deferredNativeImageCalls ?? [])
370
+ };
371
+ }
@@ -0,0 +1,25 @@
1
+ import type { BlockManifest, PageDoc } from "@avocadostudio-ai/shared";
2
+ import type { FieldEntry } from "./types.ts";
3
+ export declare function walkPageFields(page: PageDoc, manifest: BlockManifest): FieldEntry[];
4
+ /**
5
+ * Readable text inside a field value.
6
+ *
7
+ * A `text` field is a string. A `richtext` field is a ProseMirror document, or
8
+ * markdown, or — for a custom block registered with a loose schema — something
9
+ * else entirely. Collecting every `text` string in the tree handles all three
10
+ * without the caller having to know which it got, and returns "" for a shape
11
+ * this does not recognise rather than guessing.
12
+ */
13
+ export declare function fieldText(value: unknown): string;
14
+ /**
15
+ * The page's fields, split per block, in block order.
16
+ *
17
+ * Paths and containers are *block-relative* — every block with an image has a
18
+ * field at `imageUrl`, every block with a list has a `cards[0]` — so any rule
19
+ * that indexes fields by path or container has to do it one block at a time. A
20
+ * page-wide index of either silently pairs one block's image with another
21
+ * block's alt text, which is a false negative on the page that has two heroes
22
+ * and a false positive with the wrong block in its evidence on the page that
23
+ * has two of anything else.
24
+ */
25
+ export declare function groupByBlock(fields: FieldEntry[]): Map<string, FieldEntry[]>;