sygnal 6.1.1 → 6.3.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 (92) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +3 -1
  3. package/dist/ai.cjs.js +36 -0
  4. package/dist/ai.cjs.js.map +1 -1
  5. package/dist/ai.esm.js +1 -1
  6. package/dist/astro/server.cjs.js +3 -3
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs +3 -3
  9. package/dist/astro/server.mjs.map +1 -1
  10. package/dist/diagnostics.cjs.js +174 -6
  11. package/dist/diagnostics.cjs.js.map +1 -1
  12. package/dist/diagnostics.esm.js +174 -6
  13. package/dist/diagnostics.esm.js.map +1 -1
  14. package/dist/guide/adapters.md +2 -1
  15. package/dist/guide/agent.md +119 -7
  16. package/dist/guide/ai-cards.md +319 -0
  17. package/dist/guide/ai-chat.md +64 -16
  18. package/dist/guide/ai-compose.md +881 -0
  19. package/dist/guide/ai-decisions.md +17 -1
  20. package/dist/guide/behaviors.md +1 -1
  21. package/dist/guide/error-boundaries.md +2 -1
  22. package/dist/guide/forms.md +1 -1
  23. package/dist/guide/http.md +5 -4
  24. package/dist/guide/mcp-apps.md +4 -2
  25. package/dist/guide/persistence.md +1 -1
  26. package/dist/guide/recipes/ai-form-fill.md +1 -1
  27. package/dist/guide/recipes/ai-summarize.md +1 -1
  28. package/dist/guide/recipes/ai-support-inbox.md +1 -1
  29. package/dist/guide/resources.md +11 -10
  30. package/dist/guide/ui/combobox.md +1 -1
  31. package/dist/guide/undo.md +1 -1
  32. package/dist/guide/web-components.md +1 -0
  33. package/dist/guide/webmcp.md +1 -2
  34. package/dist/index.cjs.js +5842 -370
  35. package/dist/index.cjs.js.map +1 -1
  36. package/dist/index.d.ts +679 -13
  37. package/dist/index.esm.js +5832 -370
  38. package/dist/index.esm.js.map +1 -1
  39. package/dist/sygnal.min.js +1 -1
  40. package/dist/sygnal.min.js.map +1 -1
  41. package/dist/vike/config/package.json +1 -1
  42. package/dist/vike/onRenderClient.cjs.js +3 -2
  43. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  44. package/dist/vike/onRenderClient.mjs +3 -2
  45. package/dist/vike/onRenderClient.mjs.map +1 -1
  46. package/llms.txt +8 -6
  47. package/package.json +2 -1
  48. package/src/ai.d.ts +683 -12
  49. package/src/ai.ts +6 -0
  50. package/src/collection.ts +2 -1
  51. package/src/core/instance.ts +6 -3
  52. package/src/core/markers/lazy.ts +7 -3
  53. package/src/core/markers/portal.ts +5 -2
  54. package/src/core/markers/suspense.ts +3 -2
  55. package/src/core/markers/transition.ts +6 -3
  56. package/src/core/registry.ts +9 -5
  57. package/src/cycle/dom/IsolateModule.ts +25 -22
  58. package/src/extra/ai/agent/index.ts +111 -26
  59. package/src/extra/ai/chat/behavior.ts +368 -87
  60. package/src/extra/ai/chat/driver.ts +7 -4
  61. package/src/extra/ai/compose/agent.ts +55 -0
  62. package/src/extra/ai/compose/ask.ts +147 -0
  63. package/src/extra/ai/compose/behavior.ts +183 -0
  64. package/src/extra/ai/compose/catalog.ts +211 -0
  65. package/src/extra/ai/compose/composed.ts +61 -0
  66. package/src/extra/ai/compose/decisions.ts +1649 -0
  67. package/src/extra/ai/compose/diag.ts +33 -0
  68. package/src/extra/ai/compose/index.ts +23 -0
  69. package/src/extra/ai/compose/layout.ts +441 -0
  70. package/src/extra/ai/compose/llm.ts +1120 -0
  71. package/src/extra/ai/compose/render.ts +159 -0
  72. package/src/extra/ai/compose/testing.ts +71 -0
  73. package/src/extra/ai/compose/types.ts +159 -0
  74. package/src/extra/ai/decide.ts +97 -1
  75. package/src/extra/ai/index.ts +6 -1
  76. package/src/extra/ai/webmcp.ts +5 -3
  77. package/src/extra/diagnostics/checks/next.ts +86 -2
  78. package/src/extra/diagnostics/codes.ts +64 -3
  79. package/src/extra/fetchDriver.ts +58 -27
  80. package/src/extra/lens.ts +15 -0
  81. package/src/extra/ssr.ts +3 -3
  82. package/src/extra/testing.ts +2 -0
  83. package/src/extra/widget.ts +3 -3
  84. package/src/index.d.ts +42 -1
  85. package/src/index.ts +8 -0
  86. package/src/lazy.ts +3 -2
  87. package/src/portal.ts +5 -2
  88. package/src/pragma/index.ts +2 -1
  89. package/src/suspense.ts +5 -2
  90. package/src/switchable.ts +2 -1
  91. package/src/transition.ts +5 -2
  92. package/src/vike/onRenderClient.ts +3 -2
@@ -0,0 +1,1120 @@
1
+ /*
2
+ * PLAN-9 B-3: compose.llm(), the GenAI LLM backend of the `compose` behavior (phase-1 contract,
3
+ * dev-plans/research/ai-compose/phase1-contract.md). A port of the research's recommended path
4
+ * (dev-plans/research/ai-compose/llm/llm-composer.mjs, REPORT.md §1–§6, §9) with its exact
5
+ * prompt and schema, onto the normalized catalog:
6
+ *
7
+ * - start(): ONE chat request with structured output: the `tree+plan` schema (an `asked_for`
8
+ * list, then a nested tree: per place and depth an `anyOf` of one closed object per part, enum
9
+ * params, record enums from state, `in`, rows (leaves only, at most 4) and `maxDepth` (types.ts:
10
+ * a container at depth d, the page's children at 1, only when d <= maxDepth) in the
11
+ * grammar) and the system prompt (the catalog as text, the rules, the catalog's `guidance`,
12
+ * its `examples` as few-shot turns). `delta` when streaming.
13
+ * - delta(): a preview of the complete nodes so far (a leaf shows once its object closes, so its
14
+ * params are final; containers show what they hold). Keys are identities (fromTree), so a part
15
+ * keeps its key as the reply grows.
16
+ * - reply(): read (lenient: `type` / `component` / `name` for `part`, flat params, the list under
17
+ * another key), repair (unknown parts dropped, duplicates beyond `maxUses`, one per `group`,
18
+ * bad params → default, text cut to `maxLength`, unknown slots → default, containers too deep
19
+ * or in a row lifted, a row of one unwrapped, empty containers dropped), then a schema check
20
+ * of the repaired tree, fromTree, finish. An empty tree is `unavailable`; a reply that still
21
+ * violates the schema is `{ error }` (the behavior's status 'error') and SYG683, never a layout.
22
+ * - fake(): the JSON text of a target tree in this schema (composeWith).
23
+ * - Requests carry `parse: false` (the chat driver sends the schema, reply() reads the text), so
24
+ * prose or a code fence around the JSON is repaired and a reply with no JSON at all is
25
+ * `{ error }` + SYG683 rather than the driver's FAILED.
26
+ * - B-4: with a `previous` layout that has nodes, start() sends an edit instead (ops2, the section
27
+ * "B-4: edits" below); fake() then returns the operations that make the target.
28
+ * Pure apart from SYG683's report.
29
+ */
30
+ import {jsonSchema, check} from '../schema/jsonSchema'
31
+ import {fromTree, finish, identityOf, toTree} from './layout'
32
+ import {report} from '../../diagnostics/index'
33
+
34
+ export interface LlmOptions {
35
+ /** the model id (the transport's default otherwise), or a function of the host's state that returns it (per request) */
36
+ model?: string | ((state: any) => string | undefined)
37
+ /** the chat driver's sink (default 'LLM') */
38
+ sink?: string
39
+ /** the request key (default: the behavior's key) */
40
+ key?: string
41
+ /** stream a preview through `delta` (default true) */
42
+ stream?: boolean
43
+ /** the `asked_for` plan field (default true; false when there are examples) */
44
+ plan?: boolean
45
+ /** more instructions after the rules, or a function of the system prompt that returns the one to send */
46
+ instructions?: string | ((prompt: string) => string)
47
+ /** few-shot examples: [prompt, tree, asked_for?] or { prompt, tree, asked_for? }; default: the catalog's */
48
+ examples?: any[]
49
+ /** anything else goes on the request as is (transport options: `body`, `coalesce`, ...) */
50
+ [option: string]: unknown
51
+ }
52
+
53
+ type Issue = {kind: string; detail: string}
54
+ type TNode = {part: string; params?: Record<string, any>; slots?: Record<string, TNode[]>}
55
+
56
+ // ---------------------------------------------------------------- the catalog, as the model sees it
57
+ const ROOT = 'page'
58
+ const PLACE = ROOT + ':default'
59
+ /** a param's kind (types.ts' normalized `kind`, 'options' read as 'enum'; a raw declaration reads the same) */
60
+ const kindOf = (d: any) => {
61
+ if (!d || typeof d != 'object') return ''
62
+ const k = d.kind ?? (typeof d.from == 'function' ? 'record' : d.options ? 'options' : d.type)
63
+ return k == 'options' ? 'enum' : k == 'record' || k == 'number' || k == 'list' || k == 'text' ? k : ''
64
+ }
65
+ const labelOf = (d: any) => d.label ?? d.description
66
+
67
+ function records(d: any, state: any): any[] {
68
+ let r: any
69
+ try { r = d.from(state) } catch (_) { return [] }
70
+ return Array.isArray(r) ? r.filter(x => x && (typeof x.value == 'string' || typeof x.value == 'number')) : []
71
+ }
72
+
73
+ /** the choosable items (parts, then containers; required parts are added by finish, not asked) */
74
+ function context(catalog: any, state: any) {
75
+ const items: any[] = []
76
+ const byId: Record<string, any> = {}
77
+ const add = (id: string, def: any, container: boolean) => {
78
+ const params: any[] = []
79
+ let rec: string | undefined
80
+ for (const [name, d] of Object.entries<any>(def.params || {})) {
81
+ const kind = kindOf(d)
82
+ if (!kind) continue
83
+ const p: any = {name, d, kind}
84
+ if (kind == 'record') {
85
+ p.records = records(d, state)
86
+ if (!p.records.length) return // nothing to show: not choosable now
87
+ rec = name
88
+ }
89
+ params.push(p)
90
+ }
91
+ const row = container && (def.direction == 'row' || id == 'row')
92
+ const slots = container ? Object.keys(def.slots || {default: null}) : []
93
+ // leaves: holds parts only (a row; or a container declared `accepts: 'leaves'`)
94
+ const it = {id, def, container, row, leaves: row || def.accepts == 'leaves', params, rec, slots,
95
+ min: def.min ?? (row ? 2 : 0), max: def.max ?? (row ? 4 : undefined), uses: def.maxUses ?? 1}
96
+ items.push(it)
97
+ byId[id] = it
98
+ }
99
+ for (const [id, def] of Object.entries<any>(catalog.parts || {})) if (!def.required) add(id, def, false)
100
+ for (const [id, def] of Object.entries<any>(catalog.containers || {})) if (id != ROOT) add(id, def, true)
101
+ const required = new Set(Object.keys(catalog.parts || {}).filter(id => catalog.parts[id].required))
102
+ return {items, byId, required, root: catalog.containers?.[ROOT] || {}, maxDepth: catalog.limits?.maxDepth ?? 4, maxParts: catalog.limits?.maxParts ?? 12}
103
+ }
104
+ type Ctx = ReturnType<typeof context>
105
+
106
+ /**
107
+ * the places `it` may go. The normalized catalog lists 'container:slot' entries (types.ts; the root
108
+ * is 'page:default'); a raw `in` may name a container alone. A row's `in` is empty: it goes where its
109
+ * parts may go (never checked here).
110
+ */
111
+ const placesOf = (it: any): string[] => [].concat(it.def.in ?? [ROOT])
112
+ /** may `it` sit at `place` ('container:slot')? */
113
+ const placeOk = (it: any, place: string) => placesOf(it).some((e: string) => e == place || e == place.split(':')[0])
114
+
115
+ /**
116
+ * an `asked_for` item's length: 60 (the research's), or the longest text param's. A model copies its
117
+ * plan into the text it writes, so a shorter cap cut a 120-character question at 60 under a
118
+ * grammar that enforces maxLength (Ollama)
119
+ */
120
+ const askedMax = (ctx: Ctx) => Math.max(60, ...ctx.items.flatMap((it: any) => it.params.filter((p: any) => p.kind == 'text').map((p: any) => p.d.maxLength ?? 40)))
121
+
122
+ // ---------------------------------------------------------------- prompt (research wording, exact)
123
+ function paramText(p: any) {
124
+ const {name, d, kind} = p
125
+ const label = labelOf(d)
126
+ const def = d.default !== undefined ? ` (default ${d.default})` : ''
127
+ if (kind == 'enum') return `${name}: ${label ? label + '. ' : ''}one of ${Object.entries(d.options).map(([v, m]) => `${v} = ${m}`).join('; ')}${def}`
128
+ if (kind == 'record') {
129
+ const rs = p.records.map((r: any) => `${r.value} = ${[r.name, r.description].filter(Boolean).join(': ') || r.value}${r.aliases?.length ? ` (also ${r.aliases.join(', ')})` : ''}`)
130
+ return `${name}: ${label || 'which record'}. one of ${rs.join('; ')}${def}`
131
+ }
132
+ if (kind == 'number') {
133
+ const range = d.min != null && d.max != null ? ` from ${d.min} to ${d.max}` : d.min != null ? ` of at least ${d.min}` : d.max != null ? ` of at most ${d.max}` : ''
134
+ const bins = d.bins ? `; ${Object.entries(d.bins).map(([v, m]) => `${v} = ${m}`).join('; ')}` : ''
135
+ return `${name}: ${label ? label + '. ' : ''}a number${range}${bins}${def}`
136
+ }
137
+ if (kind == 'list') return `${name}${label ? `: ${label}` : ''}; a list of ${d.min ? `${d.min} to ` : 'at most '}${d.max ?? 6} short texts`
138
+ return `${name}${label ? `: ${label}` : ''}; short text, at most ${d.maxLength ?? 40} characters${d.default !== undefined ? `; "${d.default}" when the request does not say` : d.required ? '; always set it' : ''}`
139
+ }
140
+
141
+ /** ' (only inside tabs or panel slot side)': a container whose every slot is allowed is named alone */
142
+ const onlyIn = (it: any, ctx: Ctx) => {
143
+ const ins = placesOf(it)
144
+ if (!ins.length || ins.some(e => e == ROOT || e == PLACE)) return ''
145
+ const by = new Map<string, string[]>()
146
+ for (const e of ins) {
147
+ const [c, s] = e.split(':')
148
+ if (!by.has(c)) by.set(c, [])
149
+ if (s !== undefined) by.get(c)!.push(s)
150
+ }
151
+ const names = [...by].flatMap(([c, ss]) => {
152
+ const all = ctx.byId[c]?.slots ?? []
153
+ return !ss.length || all.every((x: string) => ss.includes(x)) ? [c] : ss.map(x => `${c} slot ${x}`)
154
+ })
155
+ return ` (only inside ${names.join(' or ')})`
156
+ }
157
+
158
+ /** the catalog as the prompt shows it */
159
+ export function catalogText(ctx: Ctx) {
160
+ const lines = ['Parts (id: what it shows):']
161
+ for (const it of ctx.items.filter(i => !i.container)) {
162
+ lines.push(`- ${it.id}: ${it.def.description ?? it.id}${onlyIn(it, ctx)}`)
163
+ for (const p of it.params) lines.push(` param ${paramText(p)}`)
164
+ }
165
+ const cs = ctx.items.filter(i => i.container)
166
+ if (cs.length) {
167
+ lines.push('', 'Containers (hold other parts):')
168
+ for (const it of cs) {
169
+ const slots = it.slots.map((s: string) => {
170
+ const lim = [it.min && `at least ${it.min}`, it.max && `at most ${it.max}`].filter(Boolean).join(', ')
171
+ const acc = it.leaves ? 'parts only, no containers' : ''
172
+ const desc = it.def.slots?.[s]
173
+ return s == 'default' ? [lim, acc].filter(Boolean).join('; ') : `slot ${s}${desc ? `: ${desc}` : ''}`
174
+ }).filter(Boolean).join('; ')
175
+ const desc = it.def.description ?? (it.row ? 'Row: shows its parts side by side, left to right' : it.id)
176
+ lines.push(`- ${it.id}: ${desc}${slots ? ` (${slots})` : ''}${onlyIn(it, ctx)}`)
177
+ for (const p of it.params) lines.push(` param ${paramText(p)}`)
178
+ }
179
+ }
180
+ return lines.join('\n')
181
+ }
182
+
183
+ const RULES = [
184
+ 'Use only the parts the request asks for, by name or by what they show. Include every part it asks for. Do not add parts it does not ask for, even related ones.',
185
+ 'Use each part at most once.',
186
+ 'Order the parts top to bottom as the request says (first, at the top, above, then, below, under, at the bottom). When it does not say, keep the order in which the request mentions them.',
187
+ 'Use a container only when the request asks for it (a row only when it wants parts side by side, next to each other or in a row).',
188
+ 'Set a param only from what the request says; otherwise use its default.',
189
+ 'If nothing in the catalog fits the request, return an empty list.',
190
+ ]
191
+ const PLAN = 'First write asked_for: each thing the request asks for, in its own words, one item per thing (an empty list when it asks for nothing the parts show). Then use exactly one part for each item, and no other parts.'
192
+ const SHAPE = `"children": [ {"part": "<id>", "params": {...}}, {"part": "<container id>", "children": [ ... ]} ]} (top to bottom; a container's children in order)`
193
+
194
+ /** the system prompt (`tree`, or `tree+plan` with `plan`) */
195
+ export function systemPrompt(catalog: any, state: any, {plan = true, instructions}: {plan?: boolean; instructions?: LlmOptions['instructions']} = {}, ctx: Ctx = context(catalog, state)) {
196
+ const intro = 'You compose a page of an app from parts the app has prepared. Each part already has its own data and wording; you only choose which parts the request asks for and arrange them.'
197
+ const guidance = [].concat(catalog.guidance ?? []).flatMap((g: string) => String(g).split('\n')).map(s => s.trim()).filter(Boolean)
198
+ const rules = [...RULES, ...guidance.map(g => g.replace(/^[-*]\s*/, ''))]
199
+ if (plan) rules.push(PLAN)
200
+ const named = ctx.items.some(i => i.container && i.slots.some((s: string) => s != 'default'))
201
+ const extra = typeof instructions == 'string' && instructions ? `\n\n${instructions}` : ''
202
+ const prompt = `${intro}\n\n${catalogText(ctx)}\n\nRules:\n${rules.map(r => `- ${r}`).join('\n')}${extra}\n\nReply with JSON only, in this shape:\n` +
203
+ `{${plan ? '"asked_for": ["<thing the request asks for>", ...], ' : ''}${SHAPE}` +
204
+ (named ? '\nA container with named slots lists each slot\'s parts under the slot\'s name instead of "children".' : '')
205
+ return typeof instructions == 'function' ? instructions(prompt) : prompt
206
+ }
207
+
208
+ // ---------------------------------------------------------------- schema
209
+ function paramsSchema(it: any) {
210
+ const props: any = {}
211
+ const required: string[] = []
212
+ for (const p of it.params) {
213
+ const d = p.d
214
+ if (p.kind == 'enum') { props[p.name] = {enum: Object.keys(d.options)}; required.push(p.name) }
215
+ else if (p.kind == 'record') { props[p.name] = {enum: p.records.map((r: any) => r.value)}; if (d.default === undefined) required.push(p.name) }
216
+ else if (p.kind == 'number') props[p.name] = {type: 'number', ...(d.min != null && {minimum: d.min}), ...(d.max != null && {maximum: d.max})}
217
+ else if (p.kind == 'list') {
218
+ props[p.name] = {type: 'array', items: {type: 'string', maxLength: d.items?.maxLength ?? 40}, ...(d.min && {minItems: d.min}), maxItems: d.max ?? 6}
219
+ if (d.min) required.push(p.name)
220
+ } else { props[p.name] = {type: 'string', maxLength: d.maxLength ?? 40}; if (d.required && d.default === undefined) required.push(p.name) }
221
+ }
222
+ return {props, required}
223
+ }
224
+
225
+ /**
226
+ * The JSON Schema of the reply. Per place (container slot; a row takes its parent's place) and
227
+ * depth, one `$defs` entry: an `anyOf` of one closed object per part allowed there. `relaxIn`
228
+ * leaves `in` out (the post-repair check: `finish` moves misplaced parts).
229
+ */
230
+ export function layoutSchema(catalog: any, state: any, {plan = true, relaxIn = false}: {plan?: boolean; relaxIn?: boolean} = {}, ctx: Ctx = context(catalog, state)) {
231
+ const defs: any = {}
232
+ const refs = new Map<string, any>()
233
+ let n = 0
234
+ const slot = (place: string, depth: number, inRow: boolean, arranged: boolean): any => {
235
+ const k = `${place}|${depth}|${inRow}|${arranged}`
236
+ if (refs.has(k)) return refs.get(k)
237
+ const name = `d${++n}`
238
+ const ref = {$ref: `#/$defs/${name}`}
239
+ refs.set(k, ref)
240
+ defs[name] = true
241
+ const alts: any[] = []
242
+ for (const it of ctx.items) {
243
+ if (it.container && (inRow || depth > ctx.maxDepth || (it.row && arranged))) continue
244
+ if (!relaxIn && !it.row && !placeOk(it, place)) continue
245
+ const props: any = {part: {const: it.id}}
246
+ const required = ['part']
247
+ const ps = paramsSchema(it)
248
+ if (Object.keys(ps.props).length) {
249
+ props.params = {type: 'object', properties: ps.props, required: ps.required, additionalProperties: false}
250
+ required.push('params')
251
+ }
252
+ if (it.container) {
253
+ let any = false
254
+ for (const s of it.slots) {
255
+ const items = slot(it.row ? place : `${it.id}:${s}`, depth + 1, it.leaves, !!it.def.arranged)
256
+ if (!items) continue
257
+ any = true
258
+ const key = s == 'default' ? 'children' : s
259
+ props[key] = {type: 'array', items, ...(it.max && {maxItems: it.max})}
260
+ required.push(key)
261
+ }
262
+ if (!any) continue
263
+ }
264
+ alts.push({type: 'object', properties: props, required, additionalProperties: false})
265
+ }
266
+ if (!alts.length) { delete defs[name]; refs.set(k, null); return null }
267
+ // places that allow the same parts share one definition
268
+ const body = JSON.stringify(alts)
269
+ const same = Object.keys(defs).find(d => d != name && defs[d] !== true && JSON.stringify(defs[d].anyOf) == body)
270
+ if (same) { delete defs[name]; refs.set(k, {$ref: `#/$defs/${same}`}); return refs.get(k) }
271
+ defs[name] = {anyOf: alts}
272
+ return ref
273
+ }
274
+ const items = slot(PLACE, 1, false, !!ctx.root.arranged)
275
+ const children = items ? {type: 'array', items} : {type: 'array', maxItems: 0}
276
+ const schema = {
277
+ type: 'object',
278
+ properties: {...(plan && {asked_for: {type: 'array', items: {type: 'string', maxLength: askedMax(ctx)}, maxItems: 8}}), children},
279
+ required: [...(plan ? ['asked_for'] : []), 'children'],
280
+ additionalProperties: false,
281
+ $defs: defs,
282
+ }
283
+ // names d1, d2, ... in order of first use (no gaps after sharing)
284
+ const order: Record<string, string> = {}
285
+ let i = 0
286
+ const json = JSON.stringify(schema).replace(/"#\/\$defs\/(d\d+)"/g, (_, d) => `"#/$defs/${order[d] ??= `d${++i}`}"`)
287
+ const out = JSON.parse(json)
288
+ out.$defs = Object.fromEntries(Object.entries<any>(out.$defs).map(([d, v]): [string, any] => [order[d], v]).sort(([a], [b]) => +a.slice(1) - +b.slice(1)))
289
+ return out
290
+ }
291
+
292
+ // ---------------------------------------------------------------- reading the reply
293
+ const PART_ALIASES = ['type', 'component', 'name', 'id']
294
+
295
+ function readNode(ctx: Ctx, n: any, issues: Issue[]): TNode | null {
296
+ if (typeof n == 'string') return {part: n}
297
+ if (!n || typeof n != 'object' || Array.isArray(n)) return null
298
+ let part = n.part
299
+ if (typeof part != 'string') {
300
+ const k = PART_ALIASES.find(a => typeof n[a] == 'string' && ctx.byId[n[a]])
301
+ if (k) { part = n[k]; issues.push({kind: 'alias', detail: `${k}: ${part}`}) }
302
+ }
303
+ const it = ctx.byId[part]
304
+ let params = n.params
305
+ if (!params && it?.params.length) {
306
+ // params written flat on the node
307
+ const flat = Object.fromEntries(Object.entries(n).filter(([k]) => it.params.some((p: any) => p.name == k)))
308
+ if (Object.keys(flat).length) { params = flat; issues.push({kind: 'alias', detail: `flat params on ${part}`}) }
309
+ }
310
+ const t: TNode = {part, params}
311
+ if (it?.container) {
312
+ t.slots = {}
313
+ for (const s of it.slots) t.slots[s] = (Array.isArray(n[s == 'default' ? 'children' : s]) ? n[s == 'default' ? 'children' : s] : []).map((x: any) => readNode(ctx, x, issues))
314
+ // a slot the container does not have (or the list under another key): kept, repaired later
315
+ for (const k of Object.keys(n)) if (!['part', 'params', 'children', ...it.slots].includes(k) && Array.isArray(n[k]) && !it.params.some((p: any) => p.name == k)) t.slots[k] = n[k].map((x: any) => readNode(ctx, x, issues))
316
+ } else {
317
+ const kids = Array.isArray(n.children) ? n.children : Array.isArray(n.parts) ? n.parts : null
318
+ if (kids?.length) t.slots = {default: kids.map((x: any) => readNode(ctx, x, issues))} // children on a leaf: lifted later
319
+ }
320
+ return t
321
+ }
322
+
323
+ /** the parsed reply → the page's children (lenient) and the plan */
324
+ export function readReply(ctx: Ctx, value: any, issues: Issue[] = []) {
325
+ let list: any = undefined
326
+ if (Array.isArray(value)) { list = value; issues.push({kind: 'alias', detail: 'a bare list'}) }
327
+ else if (!value || typeof value != 'object') issues.push({kind: 'invalid', detail: 'not an object'})
328
+ else {
329
+ list = value.children
330
+ if (!Array.isArray(list)) {
331
+ // unconstrained output: the list under another name ('parts', 'items', 'ui', ...)
332
+ const k = Object.keys(value).find(x => Array.isArray(value[x]) && value[x].some((n: any) => n && typeof n == 'object'))
333
+ if (k) { list = value[k]; issues.push({kind: 'alias', detail: `${k} for children`}) }
334
+ else if (value.part || value.type) { list = [value]; issues.push({kind: 'alias', detail: 'a single node'}) }
335
+ else if (!('children' in value) && !Array.isArray(value.asked_for)) issues.push({kind: 'invalid', detail: 'no list of parts'})
336
+ }
337
+ }
338
+ const raw = Array.isArray(list) ? list : []
339
+ const asked = value && Array.isArray(value.asked_for) ? value.asked_for.filter((x: any) => typeof x == 'string').map((x: string) => x.slice(0, askedMax(ctx))).slice(0, 8) : undefined
340
+ return {kids: raw.map((n: any) => readNode(ctx, n, issues)), raw: raw.length, asked, issues}
341
+ }
342
+
343
+ // ---------------------------------------------------------------- repair
344
+ function fixParams(it: any, params: any, issues: Issue[]): {params?: Record<string, any>; drop?: boolean} {
345
+ const out: any = {}
346
+ const given = params && typeof params == 'object' && !Array.isArray(params) ? params : {}
347
+ for (const p of it.params) {
348
+ const {name: n, d, kind} = p
349
+ const v = given[n]
350
+ if (kind == 'enum') {
351
+ const ok = v !== undefined && v !== null && Object.prototype.hasOwnProperty.call(d.options, String(v))
352
+ if (v !== undefined && !ok) issues.push({kind: 'param', detail: `${it.id}.${n}=${v}`})
353
+ const val = ok ? String(v) : d.default
354
+ if (val !== undefined) out[n] = val
355
+ } else if (kind == 'record') {
356
+ const r = p.records.find((r: any) => String(r.value) == String(v))
357
+ if (r) out[n] = r.value
358
+ else {
359
+ if (v !== undefined) issues.push({kind: 'param', detail: `${it.id}.${n}=${v}`})
360
+ if (d.default === undefined) { issues.push({kind: 'param', detail: `${it.id}: no record`}); return {drop: true} }
361
+ out[n] = d.default
362
+ }
363
+ } else if (kind == 'number') {
364
+ const x = typeof v == 'number' ? v : typeof v == 'string' && /^-?\d+(\.\d+)?$/.test(v.trim()) ? Number(v) : NaN
365
+ if (isFinite(x)) out[n] = Math.min(d.max ?? Infinity, Math.max(d.min ?? -Infinity, x))
366
+ else {
367
+ if (v !== undefined) issues.push({kind: 'param', detail: `${it.id}.${n}=${v}`})
368
+ if (d.default !== undefined) out[n] = d.default
369
+ }
370
+ } else if (kind == 'list') {
371
+ const max = d.items?.maxLength ?? 40
372
+ const items = (Array.isArray(v) ? v : typeof v == 'string' ? v.split(/\s*[,;|]\s*/) : []).filter((x: any) => typeof x == 'string' && x.trim()).map((x: string) => x.trim().slice(0, max))
373
+ const uniq = [...new Set(items)].slice(0, d.max ?? 6)
374
+ if (!Array.isArray(v) && v !== undefined) issues.push({kind: 'param', detail: `${it.id}.${n} not a list`})
375
+ if (uniq.length >= (d.min || 1)) out[n] = uniq
376
+ else if (d.default !== undefined) out[n] = d.default
377
+ else if (d.min || d.required) { issues.push({kind: 'param', detail: `${it.id}.${n} missing`}); return {drop: true} }
378
+ } else {
379
+ if (typeof v == 'string' && v.trim()) out[n] = v.trim().slice(0, d.maxLength ?? 40)
380
+ else if (d.default !== undefined && d.default !== '') out[n] = d.default
381
+ else if (d.required && d.default === undefined) { issues.push({kind: 'param', detail: `${it.id}.${n} missing`}); return {drop: true} }
382
+ }
383
+ }
384
+ for (const n of Object.keys(given)) if (!it.params.some((p: any) => p.name == n)) issues.push({kind: 'param', detail: `${it.id}.${n} unknown`})
385
+ return {params: Object.keys(out).length ? out : undefined}
386
+ }
387
+
388
+ /**
389
+ * Validate and repair the page's children (research `repairTree`, on the normalized catalog):
390
+ * see the header. `relaxMin` (previews): a container under its minimum stays (a row of one
391
+ * shows as that part's row until its second part arrives).
392
+ */
393
+ export function repairTree(ctx: Ctx, kids: Array<TNode | null>, issues: Issue[] = [], {relaxMin = false} = {}): TNode[] {
394
+ const uses: Record<string, number> = {}
395
+ const groups = new Set<string>()
396
+ const walk = (n: TNode | null, depth: number, place: string, inRow: boolean, arranged: boolean): TNode[] => {
397
+ if (!n || typeof n.part != 'string') { issues.push({kind: 'invalid', detail: 'node without part'}); return [] }
398
+ const it = ctx.byId[n.part]
399
+ const lift = (d: number) => Object.values(n.slots || {}).flat().flatMap(k => walk(k, d, place, inRow, arranged))
400
+ if (!it) { issues.push({kind: ctx.required.has(n.part) ? 'required' : 'unknownPart', detail: n.part}); return lift(depth) }
401
+ if (!it.container) {
402
+ const lifted = n.slots ? (issues.push({kind: 'structure', detail: `children on leaf ${it.id}`}), lift(depth)) : []
403
+ if (!placeOk(it, place)) issues.push({kind: 'structure', detail: `${it.id} not allowed in ${place}`})
404
+ const fixed = fixParams(it, n.params, issues)
405
+ if (fixed.drop) return lifted
406
+ const ident = it.rec ? `${it.id}:${fixed.params?.[it.rec]}` : it.id
407
+ if ((uses[ident] || 0) >= it.uses) { issues.push({kind: 'duplicate', detail: ident}); return lifted }
408
+ const g = it.def.group
409
+ if (g && groups.has(g) && !uses[ident]) { issues.push({kind: 'duplicate', detail: `group ${g}: ${it.id}`}); return lifted }
410
+ uses[ident] = (uses[ident] || 0) + 1
411
+ if (g) groups.add(g)
412
+ return [{part: it.id, ...(fixed.params && {params: fixed.params}) }, ...lifted]
413
+ }
414
+ // a container
415
+ if (depth > ctx.maxDepth || inRow || (it.row && arranged)) {
416
+ issues.push({kind: 'structure', detail: `${it.id} too deep or not accepted`})
417
+ return lift(depth)
418
+ }
419
+ const slots: Record<string, TNode[]> = {}
420
+ for (const s of it.slots) slots[s] = []
421
+ for (const [s, ks] of Object.entries(n.slots || {})) {
422
+ const target = s in slots ? s : 'default' in slots ? 'default' : it.slots[0]
423
+ if (target != s) issues.push({kind: 'structure', detail: `unknown slot ${it.id}.${s}`})
424
+ slots[target].push(...(ks || []).flatMap(k => walk(k, depth + 1, it.row ? place : `${it.id}:${target}`, it.leaves, !!it.def.arranged)))
425
+ }
426
+ const all = Object.values(slots).flat()
427
+ if (all.length == 0) { issues.push({kind: 'structure', detail: `empty ${it.id}`}); return [] }
428
+ const fixed = fixParams(it, n.params, issues)
429
+ if (fixed.drop) return all
430
+ const make = (s: Record<string, TNode[]>): TNode => ({part: it.id, ...(fixed.params && {params: fixed.params}), slots: s})
431
+ const filled = Object.values(slots).filter(k => k.length).length
432
+ if (!relaxMin && (all.length < it.min || filled < (it.def.minSlots || 0))) {
433
+ issues.push({kind: 'structure', detail: `${it.id} under min, unwrapped`})
434
+ return all
435
+ }
436
+ if (it.max && it.slots.length == 1 && all.length > it.max) {
437
+ // over the maximum: split into consecutive containers (a trailing one under the minimum is unwrapped)
438
+ issues.push({kind: 'structure', detail: `${it.id} over max, split`})
439
+ const out: TNode[] = []
440
+ for (let i = 0; i < all.length; i += it.max) {
441
+ const chunk = all.slice(i, i + it.max)
442
+ if (!relaxMin && chunk.length < it.min) out.push(...chunk)
443
+ else out.push(make({[it.slots[0]]: chunk}))
444
+ }
445
+ return out
446
+ }
447
+ return [make(slots)]
448
+ }
449
+ return kids.flatMap(k => walk(k, 1, PLACE, false, !!ctx.root.arranged))
450
+ }
451
+
452
+ // ---------------------------------------------------------------- trees: internal ↔ contract ↔ reply
453
+ const stripDefaults = (it: any, params?: Record<string, any>) => {
454
+ if (!params) return undefined
455
+ const out: any = {}
456
+ for (const k in params) if (params[k] !== it.params.find((p: any) => p.name == k)?.d.default) out[k] = params[k]
457
+ return Object.keys(out).length ? out : undefined
458
+ }
459
+
460
+ /** internal tree → the contract's Tree (params without defaults; `children` array for a default-only container) */
461
+ export function toContractTree(ctx: Ctx, kids: TNode[]): any[] {
462
+ return kids.map(n => {
463
+ const it = ctx.byId[n.part]
464
+ const params = stripDefaults(it, n.params)
465
+ const o: any = {part: n.part, ...(params && {params})}
466
+ if (n.slots) {
467
+ const names = Object.keys(n.slots)
468
+ o.children = names.length == 1 && names[0] == 'default' ? toContractTree(ctx, n.slots.default)
469
+ : Object.fromEntries(names.map(s => [s, toContractTree(ctx, n.slots![s])]))
470
+ }
471
+ return o
472
+ })
473
+ }
474
+
475
+ /** a contract Tree → the reply shape (enum params filled with defaults, every slot present) */
476
+ function toReply(ctx: Ctx, tree: any[]): any[] {
477
+ return (tree || []).map((n: any) => {
478
+ let part = n.part, params = {...n.params}
479
+ if (!ctx.byId[part] && typeof part == 'string' && part.includes(':')) {
480
+ // `part:recordValue` as the part
481
+ const [base, ...rest] = part.split(':')
482
+ const it = ctx.byId[base]
483
+ if (it?.rec) { part = base; params[it.rec] ??= rest.join(':') }
484
+ }
485
+ const it = ctx.byId[part]
486
+ const o: any = {part}
487
+ if (it?.params.length) {
488
+ for (const p of it.params) if (params[p.name] === undefined && p.d.default !== undefined && (p.kind == 'enum' || p.kind == 'record')) params[p.name] = p.d.default
489
+ if (it.rec && params[it.rec] !== undefined) {
490
+ const r = it.params.find((p: any) => p.name == it.rec).records.find((r: any) => String(r.value) == String(params[it.rec]))
491
+ if (r) params[it.rec] = r.value
492
+ }
493
+ o.params = params
494
+ }
495
+ if (it?.container) {
496
+ for (const s of it.slots) {
497
+ const kids = Array.isArray(n.children) ? (s == 'default' || it.slots.length == 1 ? n.children : []) : n.children?.[s] ?? []
498
+ o[s == 'default' ? 'children' : s] = toReply(ctx, kids)
499
+ }
500
+ }
501
+ return o
502
+ })
503
+ }
504
+
505
+ // ---------------------------------------------------------------- streaming: partial JSON
506
+ const PARTIAL = Symbol.for('sygnal.partial')
507
+ const markPartial = (x: any) => (Object.defineProperty(x, PARTIAL, {value: true, enumerable: false}), x)
508
+
509
+ /**
510
+ * Parse a JSON prefix: closes open containers, drops an unfinished string, number or key and the
511
+ * key whose value never started; an object or array cut short is marked partial. Returns
512
+ * undefined when nothing is usable yet.
513
+ */
514
+ export function parsePartialJSON(text: string): any {
515
+ let i = 0
516
+ const s = text
517
+ const INCOMPLETE = Symbol('incomplete')
518
+ const sp = () => { while (i < s.length && /\s/.test(s[i])) i++ }
519
+ const value = (): any => {
520
+ sp()
521
+ if (i >= s.length) return INCOMPLETE
522
+ const ch = s[i]
523
+ if (ch == '{') {
524
+ i++
525
+ const o: any = {}
526
+ for (;;) {
527
+ sp()
528
+ if (i >= s.length) return markPartial(o)
529
+ if (s[i] == '}') { i++; return o }
530
+ if (s[i] == ',') { i++; continue }
531
+ const k = value()
532
+ if (k === INCOMPLETE || typeof k != 'string') return markPartial(o)
533
+ sp()
534
+ if (s[i] != ':') return markPartial(o)
535
+ i++
536
+ const v = value()
537
+ if (v === INCOMPLETE) return markPartial(o)
538
+ o[k] = v
539
+ if (v && typeof v == 'object' && v[PARTIAL]) return markPartial(o)
540
+ }
541
+ }
542
+ if (ch == '[') {
543
+ i++
544
+ const a: any[] = []
545
+ for (;;) {
546
+ sp()
547
+ if (i >= s.length) return markPartial(a)
548
+ if (s[i] == ']') { i++; return a }
549
+ if (s[i] == ',') { i++; continue }
550
+ const v = value()
551
+ if (v === INCOMPLETE) return markPartial(a)
552
+ a.push(v)
553
+ if (v && typeof v == 'object' && v[PARTIAL]) return markPartial(a)
554
+ }
555
+ }
556
+ if (ch == '"') {
557
+ let j = i + 1, out = ''
558
+ while (j < s.length && s[j] != '"') { if (s[j] == '\\') { j++; if (j >= s.length) break } out += s[j]; j++ }
559
+ if (j >= s.length) { i = s.length; return INCOMPLETE }
560
+ const raw = s.slice(i, j + 1)
561
+ i = j + 1
562
+ try { return JSON.parse(raw) } catch (_) { return out }
563
+ }
564
+ const m = /^(-?\d+(\.\d+)?([eE][+-]?\d+)?|true|false|null)/.exec(s.slice(i))
565
+ if (!m || i + m[0].length >= s.length) { i = s.length; return INCOMPLETE }
566
+ i += m[0].length
567
+ return JSON.parse(m[0])
568
+ }
569
+ const v = value()
570
+ return v === INCOMPLETE ? undefined : v
571
+ }
572
+
573
+ // settled: a leaf shows once its object closes (its params are final); a container still filling shows what it holds
574
+ const prune = (x: any): any => {
575
+ if (Array.isArray(x)) return x.filter(y => !(y && typeof y == 'object' && !Array.isArray(y) && y[PARTIAL] && !Object.values(y).some(Array.isArray))).map(prune)
576
+ if (x && typeof x == 'object') return Object.fromEntries(Object.entries(x).map(([k, y]) => [k, prune(y)]))
577
+ return x
578
+ }
579
+
580
+ /** the model's text → a value (code fences, think blocks and prose around the JSON tolerated) */
581
+ function parseText(text: any): {value?: any; error?: string} {
582
+ if (typeof text != 'string') return {error: 'no text'}
583
+ const t = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim().replace(/^```(?:json)?\s*([\s\S]*?)\s*```$/, '$1')
584
+ try { return {value: JSON.parse(t)} } catch (_) {}
585
+ const m = /[{[][\s\S]*[}\]]/.exec(t)
586
+ if (m) try { return {value: JSON.parse(m[0])} } catch (_) {}
587
+ return {error: `not JSON: ${t.length > 80 ? t.slice(0, 80) + '…' : t}`}
588
+ }
589
+
590
+ const cap = (kids: TNode[], max: number) => {
591
+ let n = 0
592
+ const go = (ks: TNode[]): TNode[] => ks.flatMap(k => {
593
+ if (!k.slots) return n++ < max ? [k] : []
594
+ const slots = Object.fromEntries(Object.entries(k.slots).map(([s, x]) => [s, go(x)]))
595
+ return Object.values(slots).some(x => x.length) ? [{...k, slots}] : []
596
+ })
597
+ return go(kids)
598
+ }
599
+
600
+ // a Standard Schema that accepts any value: the reply's checks are the repair's (the schema still goes to the model)
601
+ const ANY = {'~standard': {version: 1, vendor: 'sygnal', validate: (value: unknown) => ({value})}}
602
+
603
+ const say = (message: string, data: any) => {
604
+ try { report('SYG683', {component: 'compose.llm', message, data}) } catch (e) { queueMicrotask(() => { throw e }) }
605
+ }
606
+
607
+ /** examples → few-shot turns */
608
+ function shots(ctx: Ctx, examples: any[], plan: boolean) {
609
+ return examples.flatMap((x: any) => {
610
+ const [prompt, tree, asked] = Array.isArray(x) ? x : [x?.prompt, x?.tree ?? x?.layout, x?.asked_for]
611
+ if (typeof prompt != 'string' || !Array.isArray(tree)) return []
612
+ const children = toReply(ctx, tree)
613
+ const leaves = (ks: any[]): string[] => ks.flatMap(k => ctx.byId[k.part]?.container ? Object.keys(k).filter(s => Array.isArray(k[s])).flatMap(s => leaves(k[s])) : [ctx.byId[k.part]?.def.description ?? k.part])
614
+ return [
615
+ {role: 'user', content: prompt},
616
+ {role: 'assistant', content: JSON.stringify({...(plan && {asked_for: Array.isArray(asked) ? asked : leaves(children)}), children})},
617
+ ]
618
+ })
619
+ }
620
+
621
+ /** a reply value → `{ layout, decisions }`, `unavailable`, or `{ error }`: read, repair, check, fromTree (keys from `previous`), finish */
622
+ function readTree(catalog: any, state: any, previous: any, value: any): any {
623
+ const ctx = context(catalog, state)
624
+ const {kids, raw, asked, issues} = readReply(ctx, value)
625
+ const fixed = repairTree(ctx, kids, issues)
626
+ const decisions = {...(asked && {asked_for: asked}), ...(issues.length && {repairs: issues})}
627
+ if (!fixed.length) {
628
+ // nothing fits (an empty list) is `unavailable`; a reply that isn't a list of parts, or
629
+ // whose every part is unknown or malformed, is an error
630
+ const bad = issues.filter(i => i.kind == 'invalid' || i.kind == 'unknownPart')
631
+ const notAList = issues.some(i => i.kind == 'invalid' && i.detail != 'node without part')
632
+ const allBogus = raw > 0 && bad.length > 0 && !issues.some(i => i.kind == 'required' || i.kind == 'duplicate' || i.kind == 'param')
633
+ if (notAList || allBogus) {
634
+ const message = `the reply has no usable part: ${bad.map(i => i.detail).join(', ')}`
635
+ say(message, {issues, value})
636
+ return {error: `The model's reply is not a layout (${bad.map(i => i.detail).join(', ')})`, decisions}
637
+ }
638
+ return {layout: null, unavailable: true, decisions}
639
+ }
640
+ // the repaired tree must fit the schema (`in` left to finish): otherwise it is an error, never a layout
641
+ const wrong = check(layoutSchema(catalog, state, {plan: false, relaxIn: true}, ctx), {children: toReply(ctx, toContractTree(ctx, fixed))})
642
+ if (wrong.length) {
643
+ const message = `the layout is still invalid after repair: ${wrong.slice(0, 3).map(i => `${(i.path || []).join('.')}: ${i.message}`).join('; ')}`
644
+ say(message, {issues: wrong, value})
645
+ return {error: `The model's layout is invalid (${message})`, decisions}
646
+ }
647
+ return {layout: finish(catalog, fromTree(catalog, toContractTree(ctx, fixed), previous), state), decisions}
648
+ }
649
+
650
+ /** the reply's value: `reply.value` when a driver parsed it, else its text, read leniently (SYG683 when it isn't JSON) */
651
+ function valueOf(reply: any): {value?: any; error?: string} {
652
+ if (reply?.value !== undefined) return {value: reply.value}
653
+ const r = parseText(reply?.text)
654
+ if (r.error) say(`the reply is not a layout: ${r.error}`, {text: reply?.text})
655
+ return r
656
+ }
657
+
658
+ /** compose.llm(options): the chat-LLM backend (one structured-output request, streamed preview; ops2 edits) */
659
+ export function llm(options: LlmOptions = {}) {
660
+ const {model, sink = 'LLM', key, stream = true, plan, instructions, examples, ...rest} = options
661
+ const planFor = (catalog: any) => plan ?? !(examples ?? catalog.examples ?? []).length
662
+ const base = (names: any, state: any) => {
663
+ // a function of the host's state: resolved per request (an app's model picker)
664
+ const m = typeof model == 'function' ? model(state) : model
665
+ return {
666
+ ...rest,
667
+ ...(m && {model: m}),
668
+ key: key ?? names?.key ?? 'compose',
669
+ latest: true,
670
+ // the driver sends the schema but leaves the text to reply(): a reply that isn't strict JSON
671
+ // (prose around it, a code fence) still reaches parseText and the repair, or fails as SYG683
672
+ parse: false,
673
+ ...(names?.ok && {ok: names.ok}),
674
+ ...(names?.error && {error: names.error}),
675
+ ...(stream && names?.delta && {delta: names.delta}),
676
+ }
677
+ }
678
+ return {
679
+ sink,
680
+ kind: 'llm',
681
+ start({catalog, state, prompt, previous, names}: any) {
682
+ const ctx = context(catalog, state)
683
+ const text = String(prompt ?? '')
684
+ if (hasNodes(previous)) {
685
+ // B-4: an edit of the current layout (ops2: no plan field, no examples)
686
+ const order = preorder(previous)
687
+ const request: any = {
688
+ ...base(names, state),
689
+ instructions: editPrompt(catalog, state, {instructions}, ctx),
690
+ messages: [{role: 'user', content: `Current page:\n${currentPage(previous, {ctx, order})}\n\nChange: ${text}`}],
691
+ output: jsonSchema(opsSchema(catalog, state, previous, ctx, order), {validate: ANY}),
692
+ }
693
+ return {request, pending: {step: 'edit', prompt: text, previous, order}}
694
+ }
695
+ const p = planFor(catalog)
696
+ const request: any = {
697
+ ...base(names, state),
698
+ instructions: systemPrompt(catalog, state, {plan: p, instructions}, ctx),
699
+ messages: [...shots(ctx, examples ?? catalog.examples ?? [], p), {role: 'user', content: text}],
700
+ output: jsonSchema(layoutSchema(catalog, state, {plan: p}, ctx), {validate: ANY}),
701
+ }
702
+ return {request, pending: {step: 'llm', prompt: text, plan: p, ...(previous && {previous})}}
703
+ },
704
+ delta({catalog, state, pending, delta}: any) {
705
+ const text = typeof delta == 'string' ? delta : delta?.text
706
+ if (typeof text != 'string') return {}
707
+ const v = parsePartialJSON(text.replace(/<think>[\s\S]*?(<\/think>|$)/g, '').replace(/^\s*```(?:json)?\s*/, ''))
708
+ if (!v || typeof v != 'object') return {}
709
+ if (pending?.step == 'edit') {
710
+ // the operations complete so far, applied to the previous layout (its keys kept)
711
+ const done = (opsOf(v) || []).filter((o: any) => !(o && typeof o == 'object' && o[PARTIAL]))
712
+ if (!done.length) return {}
713
+ const r = applyOps(catalog, state, pending.previous, pending.order, done)
714
+ return r.applied ? {preview: finish(catalog, r.layout, state), keyed: true} : {}
715
+ }
716
+ const ctx = context(catalog, state)
717
+ const {kids} = readReply(ctx, prune(v))
718
+ const fixed = cap(repairTree(ctx, kids, [], {relaxMin: true}), ctx.maxParts)
719
+ if (!fixed.length) return {}
720
+ return {preview: fromTree(catalog, toContractTree(ctx, fixed), pending?.previous)}
721
+ },
722
+ reply({catalog, state, pending, reply}: any) {
723
+ const r = valueOf(reply)
724
+ if (r.error) return {error: `The model's reply is not a layout (${r.error})`}
725
+ if (pending?.step != 'edit') return readTree(catalog, state, pending?.previous, r.value)
726
+ const ops = opsOf(r.value)
727
+ // a whole page instead of operations: read as a composition, keys reused from the previous layout
728
+ if (!ops) return readTree(catalog, state, pending.previous, r.value)
729
+ // no operation: nothing in the catalog fits the change (or it changes nothing); the layout stays
730
+ if (!ops.length) return {layout: null, unavailable: true, decisions: {ops: []}}
731
+ const a = applyOps(catalog, state, pending.previous, pending.order, ops)
732
+ const decisions = {ops: a.ops, ...(a.issues.length && {repairs: a.issues})}
733
+ if (!a.applied) {
734
+ const what = a.issues.map(i => i.detail).join(', ')
735
+ say(`no operation of the reply applies: ${what}`, {issues: a.issues, value: r.value})
736
+ return {error: `The model's edit does not apply (${what})`, decisions}
737
+ }
738
+ return {layout: finish(catalog, a.layout, state), keyed: true, decisions}
739
+ },
740
+ fake({catalog, state, pending, target}: any) {
741
+ const ctx = context(catalog, state)
742
+ if (pending?.step == 'edit') {
743
+ // `[]` is "nothing fits" (no operation); otherwise the operations that turn the previous
744
+ // layout into `target`, or the whole page when operations can't reach it (a new container
745
+ // other than a row, a row of new parts only)
746
+ if (!target?.length) return JSON.stringify({ops: []})
747
+ const ops = editOps(catalog, state, pending.previous, pending.order, target)
748
+ return JSON.stringify(ops ? {ops} : {children: toReply(ctx, target)})
749
+ }
750
+ const children = toReply(ctx, target || [])
751
+ const p = pending?.plan ?? planFor(catalog)
752
+ const leaves = (ks: any[]): string[] => ks.flatMap(k => ctx.byId[k.part]?.container ? Object.keys(k).filter(s => Array.isArray(k[s])).flatMap(s => leaves(k[s])) : [k.part])
753
+ return JSON.stringify({...(p && {asked_for: leaves(children)}), children})
754
+ },
755
+ }
756
+ }
757
+
758
+ // ---------------------------------------------------------------- B-4: edits (ops2)
759
+ /*
760
+ * An edit sends the current page and the change, and the model returns operations positioned by
761
+ * named places (research REPORT.md §1 "The edit prompt (ops2)", llm-composer.mjs opsSchemaV2 /
762
+ * opsSystemPromptV2 / applyOpsV2). The model sees the nodes as `n0` (the page), `n1`, … in
763
+ * preorder (the research's keys; `pending.order` maps them back), never the layout's identity
764
+ * keys. The operations are applied to the previous layout itself, so keys are kept by
765
+ * construction:
766
+ * - a node no operation touches keeps its key (and its place);
767
+ * - move and setParams keep the node's key (a setParams that changes a record param is a
768
+ * different record: a new key, in the same place);
769
+ * - replace puts a new node (a new key) in the old one's place; a replace with the same part is a
770
+ * setParams (key kept); an add or a replace with a part already on the page as often as it may
771
+ * be (`maxUses`) moves that node there instead (key kept);
772
+ * - places: 'top' / 'bottom' of the page, 'above k' / 'below k' (around k's block on the page),
773
+ * 'left of k' / 'right of k' (in k's row, or a new row made of k and the part in k's place).
774
+ * The reference node never moves (a move relative to itself or its own subtree is skipped).
775
+ * Then finish(). A reply with a whole page (`children`) instead is read as a composition with the
776
+ * previous keys reused; no operation is `unavailable` (the layout stays); operations of which none
777
+ * applies are an error (SYG683).
778
+ */
779
+ const own = (o: any, k: string) => !!o && Object.prototype.hasOwnProperty.call(o, k)
780
+
781
+ /** a layout's node keys in preorder, the root first (the edit prompt's n0, n1, …) */
782
+ export function preorder(layout: any): string[] {
783
+ const out: string[] = []
784
+ const nodes = layout?.nodes || {}
785
+ const go = (k: string) => {
786
+ if (out.includes(k) || !nodes[k]) return
787
+ out.push(k)
788
+ for (const ks of Object.values<any>(nodes[k].slots || {})) if (Array.isArray(ks)) ks.forEach(go)
789
+ }
790
+ go(layout?.root || 'page')
791
+ return out
792
+ }
793
+ const hasNodes = (l: any) => !!l?.nodes && preorder(l).length > 1
794
+
795
+ /**
796
+ * a layout as the edit prompt shows it: one node per line (`n1: part param="value"`, params with
797
+ * their defaults filled, as the app holds them), a container's children indented under it
798
+ */
799
+ export function currentPage(layout: any, {ctx, order = preorder(layout)}: {ctx?: Ctx; order?: string[]} = {}): string {
800
+ const lines: string[] = []
801
+ const go = (key: string, depth: number) => {
802
+ const n = layout.nodes[key]
803
+ if (!n) return
804
+ const it = ctx?.byId[n.part]
805
+ const params = it ? fixParams(it, n.params, []).params : n.params
806
+ const ps = params ? ' ' + Object.entries(params).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(' ') : ''
807
+ lines.push(`${' '.repeat(depth)}n${order.indexOf(key)}: ${n.part}${ps}`)
808
+ for (const [s, ks] of Object.entries<any>(n.slots || {})) {
809
+ if (s != 'default') lines.push(`${' '.repeat(depth + 1)}[${s}]`)
810
+ ks.forEach((k: string) => go(k, depth + 1))
811
+ }
812
+ }
813
+ go(layout.root, 0)
814
+ return lines.join('\n')
815
+ }
816
+
817
+ const EDIT = `The user message gives the current page (one node per line: key: part params; a row's parts are indented under it) and a change. Return only the operations that make the change, in order:
818
+ - {"op":"add","part":"<id>","params":{...},"where":"<place>"}
819
+ - {"op":"remove","node":"<key>"}
820
+ - {"op":"move","node":"<key>","where":"<place>"}
821
+ - {"op":"replace","node":"<key>","part":"<id>","params":{...}} (the new part takes the old one's place)
822
+ - {"op":"setParams","node":"<key>","params":{"<param>":"<value>"}}
823
+ A place is "top" or "bottom" of the page, "above <key>" or "below <key>" (on the page), or "left of <key>" / "right of <key>" (side by side with that part, in a row).
824
+ Change only what the request asks; everything else stays as it is. A request that changes nothing gets no operations.
825
+
826
+ Reply with JSON only: {"ops": [ ... ]}`
827
+
828
+ /** the edit's system prompt: the catalog and the rules (no plan line), then the operations (research ops2, exact) */
829
+ export function editPrompt(catalog: any, state: any, {instructions}: {instructions?: LlmOptions['instructions']} = {}, ctx: Ctx = context(catalog, state)) {
830
+ const head = systemPrompt(catalog, state, {plan: false, instructions: typeof instructions == 'string' ? instructions : undefined}, ctx).split('\n\nReply with JSON only')[0]
831
+ const prompt = `${head}\n\n${EDIT}`
832
+ return typeof instructions == 'function' ? instructions(prompt) : prompt
833
+ }
834
+
835
+ /** the edit's JSON Schema: `node` an enum of the current nodes, `where` an enum of every place, `part` + `params` per part */
836
+ export function opsSchema(catalog: any, state: any, previous: any, ctx: Ctx = context(catalog, state), order: string[] = preorder(previous)) {
837
+ const nodeKeys = order.slice(1).map((_, i) => `n${i + 1}`)
838
+ const places = ['top', 'bottom']
839
+ order.forEach((k, i) => {
840
+ if (i == 0) return
841
+ places.push(`above n${i}`, `below n${i}`)
842
+ if (catalog.get?.(previous.nodes[k].part)?.kind != 'container') places.push(`left of n${i}`, `right of n${i}`)
843
+ })
844
+ const where = {enum: places}
845
+ const leaves = ctx.items.filter(i => !i.container)
846
+ const withParams = (b: any, it: any) => {
847
+ const ps = paramsSchema(it)
848
+ // a record part needs its record (the research catalogs have none)
849
+ const rec = it.rec && ps.required.includes(it.rec) ? {required: [it.rec]} : {}
850
+ return Object.keys(ps.props).length ? {...b, params: {type: 'object', properties: ps.props, ...rec, additionalProperties: false}} : b
851
+ }
852
+ const needs = (it: any, r: string[]) => it.rec && paramsSchema(it).required.includes(it.rec) ? [...r, 'params'] : r
853
+ const ops: any[] = leaves.map(it => ({type: 'object', properties: {...withParams({op: {const: 'add'}, part: {const: it.id}}, it), where}, required: needs(it, ['op', 'part', 'where']), additionalProperties: false}))
854
+ if (nodeKeys.length) {
855
+ ops.push(
856
+ {type: 'object', properties: {op: {const: 'remove'}, node: {enum: nodeKeys}}, required: ['op', 'node'], additionalProperties: false},
857
+ {type: 'object', properties: {op: {const: 'move'}, node: {enum: nodeKeys}, where}, required: ['op', 'node', 'where'], additionalProperties: false},
858
+ ...leaves.map(it => ({type: 'object', properties: withParams({op: {const: 'replace'}, node: {enum: nodeKeys}, part: {const: it.id}}, it), required: needs(it, ['op', 'node', 'part']), additionalProperties: false})),
859
+ {type: 'object', properties: {op: {const: 'setParams'}, node: {enum: nodeKeys}, params: {type: 'object'}}, required: ['op', 'node', 'params'], additionalProperties: false},
860
+ )
861
+ }
862
+ return {type: 'object', properties: {ops: {type: 'array', items: {anyOf: ops}}}, required: ['ops'], additionalProperties: false}
863
+ }
864
+
865
+ /** the operations in a reply (lenient: a bare list, `operations`, a single operation); undefined when it has none (a whole page, say) */
866
+ function opsOf(v: any): any[] | undefined {
867
+ const isOp = (o: any) => !!o && typeof o == 'object' && typeof o.op == 'string'
868
+ if (Array.isArray(v)) return v.every(isOp) ? v : undefined
869
+ if (!v || typeof v != 'object') return
870
+ for (const k of ['ops', 'operations', 'changes', 'edits']) if (Array.isArray(v[k])) return v[k]
871
+ if (isOp(v)) return [v]
872
+ }
873
+
874
+ /** apply operations to `previous` (see the section's header): the layout (not finished), how many applied, the operations as applied (layout keys), issues */
875
+ export function applyOps(catalog: any, state: any, previous: any, order: string[], ops: any[]) {
876
+ const ctx = context(catalog, state)
877
+ const issues: Issue[] = [], done: any[] = []
878
+ const root = previous.root || 'page'
879
+ const L: any = {root, nodes: JSON.parse(JSON.stringify(previous.nodes))}
880
+ L.nodes[root].slots ||= {}
881
+ L.nodes[root].slots.default ||= []
882
+ const used = new Set<string>(Object.keys(L.nodes))
883
+ const page = (): string[] => L.nodes[root].slots.default
884
+ const keyOf = (ref: any): string | undefined => {
885
+ if (typeof ref != 'string' && typeof ref != 'number') return
886
+ const s = String(ref).trim()
887
+ const m = /^n(\d+)$/.exec(s)
888
+ const k = m && order[+m[1]] !== undefined ? order[+m[1]] : s
889
+ return own(L.nodes, k) && k != root ? k : undefined
890
+ }
891
+ const parentOf = (k: string) => {
892
+ for (const pk in L.nodes) {
893
+ const sl = L.nodes[pk].slots
894
+ for (const s in sl) { const i = sl[s].indexOf(k); if (i >= 0) return {pk, s, i, arr: sl[s] as string[]} }
895
+ }
896
+ }
897
+ const isRow = (k: string) => { const e = catalog.get(L.nodes[k]?.part); return e?.kind == 'container' && (e.direction == 'row' || e.id == 'row') }
898
+ const rowPart: string | undefined = catalog.get('row')?.kind == 'container' ? 'row' : (Object.values<any>(catalog.containers || {}).find(c => c.direction == 'row')?.id)
899
+ const topOf = (k: string) => { let p = parentOf(k); while (p && p.pk != root) { k = p.pk; p = parentOf(k) } return k }
900
+ const within = (k: string, anc: string) => { for (let x: string | undefined = k; x && x != root; x = parentOf(x)?.pk) if (x == anc) return true; return false }
901
+ const detach = (k: string) => { const p = parentOf(k); if (p) p.arr.splice(p.i, 1) }
902
+ const drop = (k: string) => { const n = L.nodes[k]; if (!n) return; delete L.nodes[k]; for (const s in n.slots) for (const c of n.slots[s]) drop(c) }
903
+ const fresh = (part: string, params?: any) => { let k = identityOf(catalog, part, params); for (let i = 2; used.has(k); i++) k = `${identityOf(catalog, part, params)}~${i}`; used.add(k); return k }
904
+ const idOf = (k: string) => identityOf(catalog, L.nodes[k].part, L.nodes[k].params)
905
+ const existing = (id: string) => preorder(L).find(k => k != root && idOf(k) == id)
906
+ const count = (id: string) => preorder(L).filter(k => k != root && idOf(k) == id).length
907
+ /** a place → the function that inserts a key there (null: no such place; `self`: the node that moves) */
908
+ const placement = (where: any, self?: string): ((k: string) => void) | null => {
909
+ const w = typeof where == 'string' ? where.trim() : ''
910
+ if (w == 'top') return k => { page().unshift(k) }
911
+ if (w == 'bottom') return k => { page().push(k) }
912
+ const m = /^(above|below|left of|right of)\s+(\S+)$/.exec(w)
913
+ const ref = m ? keyOf(m[2]) : undefined
914
+ if (!m || !ref || (self && within(ref, self))) return null
915
+ const rel = m[1]
916
+ if (rel == 'above' || rel == 'below') return k => { const pg = page(); pg.splice(pg.indexOf(topOf(ref)) + (rel == 'below' ? 1 : 0), 0, k) }
917
+ const right = rel == 'right of'
918
+ return k => {
919
+ if (isRow(ref)) { const s = L.nodes[ref].slots.default ||= []; right ? s.push(k) : s.unshift(k); return }
920
+ const p = parentOf(ref)!
921
+ if (isRow(p.pk) || !rowPart) {
922
+ if (!rowPart) issues.push({kind: 'op', detail: `no row container: ${w} is next to it instead`})
923
+ p.arr.splice(p.i + (right ? 1 : 0), 0, k)
924
+ return
925
+ }
926
+ const r = fresh(rowPart)
927
+ L.nodes[r] = {part: rowPart, slots: {default: right ? [ref, k] : [k, ref]}}
928
+ p.arr.splice(p.i, 1, r)
929
+ }
930
+ }
931
+ const setParams = (k: string, given: any): string | null => {
932
+ const n = L.nodes[k], it = ctx.byId[n.part]
933
+ const merged = {...n.params, ...given}
934
+ if (!it) { if (!catalog.get(n.part)) return 'unknown part'; n.params = merged; return null }
935
+ const f = fixParams(it, merged, issues)
936
+ if (f.drop) return 'params'
937
+ const before = idOf(k)
938
+ if (identityOf(catalog, it.id, f.params) != before) {
939
+ // another record: another node, in the same place
940
+ const id = identityOf(catalog, it.id, f.params)
941
+ if (count(id) >= it.uses) return `${id} is on the page`
942
+ const nk = fresh(it.id, f.params), p = parentOf(k)!
943
+ L.nodes[nk] = {part: it.id, ...(f.params && {params: f.params})}
944
+ p.arr.splice(p.i, 1, nk)
945
+ drop(k)
946
+ return null
947
+ }
948
+ if (f.params) n.params = f.params
949
+ else delete n.params
950
+ return null
951
+ }
952
+ for (const op of Array.isArray(ops) ? ops : []) {
953
+ const fail = (why: string) => { issues.push({kind: 'op', detail: `${why}: ${JSON.stringify(op)}`}) }
954
+ if (!op || typeof op != 'object') { fail('not an operation'); continue }
955
+ const k = keyOf(op.node)
956
+ if (op.op == 'add') {
957
+ const it = ctx.byId[op.part]
958
+ if (!it || it.container) { fail('unknown part'); continue }
959
+ const f = fixParams(it, op.params, issues)
960
+ if (f.drop) { fail('params'); continue }
961
+ const id = identityOf(catalog, it.id, f.params)
962
+ if (count(id) >= it.uses) {
963
+ // on the page as often as it may be: moved there instead (its key kept)
964
+ const e = existing(id)!
965
+ const at = placement(op.where, e)
966
+ if (!at) { fail('place'); continue }
967
+ detach(e); at(e)
968
+ issues.push({kind: 'op', detail: `add of ${id}, which is on the page: moved`})
969
+ done.push({op: 'move', node: e, where: op.where})
970
+ } else {
971
+ const nk = fresh(it.id, f.params)
972
+ L.nodes[nk] = {part: it.id, ...(f.params && {params: f.params})}
973
+ const at = placement(op.where)
974
+ if (at) at(nk)
975
+ else { issues.push({kind: 'op', detail: `place ${op.where}: at the bottom`}); page().push(nk) }
976
+ done.push({op: 'add', node: nk, part: it.id, ...(f.params && {params: f.params}), where: op.where})
977
+ }
978
+ } else if (op.op == 'remove') {
979
+ if (!k) { fail('no such node'); continue }
980
+ detach(k); drop(k)
981
+ done.push({op: 'remove', node: k})
982
+ } else if (op.op == 'move') {
983
+ const at = k && placement(op.where, k)
984
+ if (!k || !at) { fail(k ? 'place' : 'no such node'); continue }
985
+ detach(k); at(k)
986
+ done.push({op: 'move', node: k, where: op.where})
987
+ } else if (op.op == 'replace') {
988
+ const it = ctx.byId[op.part]
989
+ if (!k || !it || it.container) { fail(k ? 'unknown part' : 'no such node'); continue }
990
+ if (L.nodes[k].part == it.id) {
991
+ // the same part: a param change (its key kept)
992
+ const why = setParams(k, op.params || {})
993
+ if (why) { fail(why); continue }
994
+ issues.push({kind: 'op', detail: `replace ${k} with the same part = setParams (key kept)`})
995
+ done.push({op: 'setParams', node: k, params: op.params || {}})
996
+ continue
997
+ }
998
+ const f = fixParams(it, op.params, issues)
999
+ if (f.drop) { fail('params'); continue }
1000
+ const id = identityOf(catalog, it.id, f.params)
1001
+ const e = count(id) >= it.uses ? existing(id) : undefined
1002
+ if (e && within(k, e)) { fail('place'); continue }
1003
+ if (e) detach(e)
1004
+ const nk = e || fresh(it.id, f.params)
1005
+ if (!e) L.nodes[nk] = {part: it.id, ...(f.params && {params: f.params})}
1006
+ const p = parentOf(k)!
1007
+ p.arr.splice(p.i, 1, nk)
1008
+ drop(k)
1009
+ done.push({op: 'replace', node: k, part: it.id, ...(f.params && {params: f.params}), ...(e && {moved: e})})
1010
+ } else if (op.op == 'setParams' || op.op == 'setParam') {
1011
+ const given = op.params && typeof op.params == 'object' && !Array.isArray(op.params) ? op.params : typeof op.param == 'string' ? {[op.param]: op.value} : null
1012
+ if (!k || !given) { fail(k ? 'no params' : 'no such node'); continue }
1013
+ const why = setParams(k, given)
1014
+ if (why) { fail(why); continue }
1015
+ done.push({op: 'setParams', node: k, params: given})
1016
+ } else fail('unknown operation')
1017
+ }
1018
+ return {layout: L, applied: done.length, ops: done, issues}
1019
+ }
1020
+
1021
+ /** a value with its object keys sorted (tree comparison) */
1022
+ const canon = (v: any): string => JSON.stringify(v, (_k, x) => x && typeof x == 'object' && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map(k => [k, x[k]])) : x)
1023
+
1024
+ /**
1025
+ * fake(): the operations that turn `previous` into `target` (keys of the parts both have kept),
1026
+ * found greedily and then minimized; null when operations can't reach it (the caller sends the
1027
+ * whole page). Every result is checked by applying it.
1028
+ */
1029
+ function editOps(catalog: any, state: any, previous: any, order: string[], target: any[]): any[] | null {
1030
+ const want = finish(catalog, fromTree(catalog, target, previous), state)
1031
+ const goal = canon(toTree(want))
1032
+ const root = previous.root || 'page'
1033
+ const alias = (k: string) => `n${order.indexOf(k)}`
1034
+ const P = previous.nodes, W = want.nodes
1035
+ const isLeaf = (k: string, nodes: any) => catalog.get(nodes[k]?.part)?.kind == 'part'
1036
+ const isRow = (k: string, nodes: any) => { const e = catalog.get(nodes[k]?.part); return e?.kind == 'container' && (e.direction == 'row' || e.id == 'row') }
1037
+ const kept = new Set(order.filter(k => k != root && own(W, k) && W[k].part == P[k].part))
1038
+ const keptLeaves = [...kept].filter(k => isLeaf(k, P))
1039
+ const ok = (ops: any[]) => {
1040
+ const a = applyOps(catalog, state, previous, order, ops)
1041
+ if (a.issues.some(i => i.kind == 'op')) return false
1042
+ const got = finish(catalog, a.layout, state)
1043
+ return canon(toTree(got)) == goal && keptLeaves.every(k => got.nodes[k]?.part == P[k].part)
1044
+ }
1045
+ const ops: any[] = []
1046
+ // params of the kept nodes (a param back to its default is set to the default)
1047
+ for (const k of kept) {
1048
+ const a = P[k].params || {}, b = W[k].params || {}
1049
+ if (canon(a) == canon(b)) continue
1050
+ if (!isLeaf(k, P)) return null
1051
+ const e: any = catalog.get(P[k].part), params: any = {}
1052
+ for (const n of new Set([...Object.keys(a), ...Object.keys(b)])) if (canon(a[n]) != canon(b[n])) params[n] = n in b ? b[n] : e.params[n]?.default
1053
+ ops.push({op: 'setParams', node: alias(k), params})
1054
+ }
1055
+ // the page's blocks in order: each block's anchor is a kept node it holds
1056
+ const pageOf = (n: any) => n[want.root || 'page']?.slots?.default || []
1057
+ const blocks = pageOf(W).map((b: string) => {
1058
+ if (isLeaf(b, W)) return {b, anchor: kept.has(b) ? b : null, kids: null}
1059
+ if (isRow(b, W)) {
1060
+ const kids: string[] = W[b].slots?.default || []
1061
+ const k = kids.filter(x => kept.has(x))
1062
+ if (!k.length) return null
1063
+ return {b, anchor: k[0], kids}
1064
+ }
1065
+ // another container: only moved as a whole, when it holds the same nodes as before
1066
+ const same = kept.has(b) && canon(toTree({root: 'page', nodes: {...W, page: {part: 'page', slots: {default: [b]}}}} as any)) == canon(toTree({root: 'page', nodes: {...P, page: {part: 'page', slots: {default: [b]}}}} as any))
1067
+ return same ? {b, anchor: b, kids: null} : null
1068
+ })
1069
+ if (blocks.some((x: any) => !x)) return null
1070
+ const parentIn = (nodes: any, k: string) => Object.keys(nodes).find(pk => Object.values<any>(nodes[pk].slots || {}).some((s: string[]) => s.includes(k)))
1071
+ const anchored = blocks.filter((x: any) => x.anchor)
1072
+ const moves: any[] = [], adds: any[] = []
1073
+ const addOp = (k: string, where: string) => ({op: 'add', part: W[k].part, ...(W[k].params && {params: W[k].params}), where})
1074
+ anchored.forEach((x: any, j: number) => {
1075
+ // the block: its row as a whole when the anchor's row is the same row; else the anchor alone
1076
+ const pr = parentIn(P, x.anchor)
1077
+ const mover = x.kids && pr && pr == x.b ? x.b : x.anchor
1078
+ moves.push({op: 'move', node: alias(mover), where: j == 0 ? 'top' : `below ${alias(anchored[j - 1].anchor)}`})
1079
+ if (!x.kids) return
1080
+ // a row's parts: the kept ones in order after the anchor, then the new ones around them
1081
+ const K = x.kids.filter((c: string) => kept.has(c))
1082
+ for (let i = 1; i < K.length; i++) moves.push({op: 'move', node: alias(K[i]), where: `right of ${alias(K[i - 1])}`})
1083
+ const first = x.kids.indexOf(K[0])
1084
+ for (const c of x.kids.slice(0, first)) adds.push(addOp(c, `left of ${alias(K[0])}`))
1085
+ K.forEach((kk: string, i: number) => {
1086
+ const from = x.kids.indexOf(kk) + 1, to = i + 1 < K.length ? x.kids.indexOf(K[i + 1]) : x.kids.length
1087
+ for (const c of x.kids.slice(from, to).reverse()) adds.push(addOp(c, `right of ${alias(kk)}`))
1088
+ })
1089
+ })
1090
+ // new parts on the page: before the first kept block, between, after
1091
+ const firstA = blocks.findIndex((x: any) => x.anchor)
1092
+ if (firstA < 0) for (const x of blocks) adds.push(addOp(x.b, 'bottom'))
1093
+ else {
1094
+ for (const x of blocks.slice(0, firstA)) adds.push(addOp(x.b, `above ${alias(blocks[firstA].anchor)}`))
1095
+ blocks.forEach((x: any, i: number) => {
1096
+ if (!x.anchor) return
1097
+ let to = i + 1
1098
+ while (to < blocks.length && !blocks[to].anchor) to++
1099
+ for (const y of blocks.slice(i + 1, to).reverse()) adds.push(addOp(y.b, `below ${alias(x.anchor)}`))
1100
+ })
1101
+ }
1102
+ // removes last (a removed row's kept parts have moved out by then), outermost first
1103
+ const removes: any[] = []
1104
+ const gone = new Set<string>()
1105
+ for (const k of order) {
1106
+ if (k == root || kept.has(k)) continue
1107
+ const p = parentIn(P, k)
1108
+ if (p && gone.has(p)) { gone.add(k); continue }
1109
+ gone.add(k)
1110
+ removes.push({op: 'remove', node: alias(k)})
1111
+ }
1112
+ let all = [...ops, ...moves, ...adds, ...removes]
1113
+ if (!ok(all)) return null
1114
+ // minimize: drop each operation the result doesn't need
1115
+ for (let i = all.length - 1; i >= 0; i--) {
1116
+ const fewer = all.slice(0, i).concat(all.slice(i + 1))
1117
+ if (ok(fewer)) all = fewer
1118
+ }
1119
+ return all
1120
+ }