@elytracms/studio 0.0.101 → 0.0.103

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 (90) hide show
  1. package/convex/asset-meta-patch.test.ts +7 -27
  2. package/convex/assets.ts +6 -41
  3. package/convex/cms.ts +2 -29
  4. package/convex/delivery.ts +2 -2
  5. package/convex/ingest.test.ts +54 -21
  6. package/convex/ingest.ts +52 -15
  7. package/convex/record-functions.ts +15 -82
  8. package/convex/redirects.ts +11 -116
  9. package/convex/schema.ts +4 -23
  10. package/convex/triggers.ts +7 -14
  11. package/dist/components/ai/ai-chat-panel.js +8 -5
  12. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  13. package/dist/components/ai/translate-document-dialog.d.ts +43 -0
  14. package/dist/components/ai/translate-document-dialog.js +104 -36
  15. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  16. package/dist/components/ai/translate-skipped.d.ts +19 -3
  17. package/dist/components/ai/translate-skipped.js +34 -7
  18. package/dist/components/ai/translate-skipped.js.map +1 -1
  19. package/dist/components/assets/asset-detail.js +1 -2
  20. package/dist/components/assets/asset-detail.js.map +1 -1
  21. package/dist/components/assets/asset-metadata.d.ts +2 -9
  22. package/dist/components/assets/asset-metadata.js +3 -10
  23. package/dist/components/assets/asset-metadata.js.map +1 -1
  24. package/dist/components/assets/asset-search.js +1 -1
  25. package/dist/components/assets/asset-search.js.map +1 -1
  26. package/dist/components/assets/asset-selection.js +2 -2
  27. package/dist/components/assets/asset-selection.js.map +1 -1
  28. package/dist/components/assets/assets-collection-view-source.d.ts +2 -3
  29. package/dist/components/assets/assets-collection-view-source.js +2 -3
  30. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  31. package/dist/components/builder/__fixtures__/reuse-race.d.ts +30 -0
  32. package/dist/components/builder/__fixtures__/reuse-race.js +77 -0
  33. package/dist/components/builder/__fixtures__/reuse-race.js.map +1 -0
  34. package/dist/components/builder/composition-field-editor.js +3 -3
  35. package/dist/components/builder/composition-field-editor.js.map +1 -1
  36. package/dist/components/builder/reuse-context.d.ts +2 -1
  37. package/dist/components/builder/reuse-context.js +5 -20
  38. package/dist/components/builder/reuse-context.js.map +1 -1
  39. package/dist/components/builder/reuse.d.ts +18 -11
  40. package/dist/components/builder/reuse.js +12 -14
  41. package/dist/components/builder/reuse.js.map +1 -1
  42. package/dist/components/cms/asset-field.js +1 -3
  43. package/dist/components/cms/asset-field.js.map +1 -1
  44. package/dist/components/cms/cell.d.ts +2 -2
  45. package/dist/components/cms/cell.js +8 -18
  46. package/dist/components/cms/cell.js.map +1 -1
  47. package/dist/components/cms/detail-editor.js +2 -1
  48. package/dist/components/cms/detail-editor.js.map +1 -1
  49. package/dist/components/cms/field-control.js +13 -1
  50. package/dist/components/cms/field-control.js.map +1 -1
  51. package/dist/lib/ai/chat-context.d.ts +6 -2
  52. package/dist/lib/ai/chat-context.js +11 -5
  53. package/dist/lib/ai/chat-context.js.map +1 -1
  54. package/dist/lib/ai/knowledge.d.ts +9 -9
  55. package/dist/lib/ai/knowledge.js +22 -0
  56. package/dist/lib/ai/knowledge.js.map +1 -1
  57. package/dist/lib/ai/tools.d.ts +8 -1
  58. package/dist/lib/ai/tools.js +33 -15
  59. package/dist/lib/ai/tools.js.map +1 -1
  60. package/dist/lib/ai/translate-apply.d.ts +69 -0
  61. package/dist/lib/ai/translate-apply.js +36 -0
  62. package/dist/lib/ai/translate-apply.js.map +1 -0
  63. package/dist/lib/ai/translate-document.d.ts +101 -7
  64. package/dist/lib/ai/translate-document.js +189 -54
  65. package/dist/lib/ai/translate-document.js.map +1 -1
  66. package/dist/lib/ai/update-fields.js +5 -12
  67. package/dist/lib/ai/update-fields.js.map +1 -1
  68. package/dist/lib/ai/writer-form.d.ts +93 -1
  69. package/dist/lib/ai/writer-form.js +389 -37
  70. package/dist/lib/ai/writer-form.js.map +1 -1
  71. package/dist/lib/assets/upload.d.ts +1 -2
  72. package/dist/lib/assets/upload.js +1 -2
  73. package/dist/lib/assets/upload.js.map +1 -1
  74. package/dist/lib/engine-version.d.ts +1 -1
  75. package/dist/lib/engine-version.js +1 -1
  76. package/dist/lib/engine-version.js.map +1 -1
  77. package/dist/lib/i18n/messages.de.js +54 -4
  78. package/dist/lib/i18n/messages.de.js.map +1 -1
  79. package/dist/lib/i18n/messages.en.d.ts +44 -1
  80. package/dist/lib/i18n/messages.en.js +58 -4
  81. package/dist/lib/i18n/messages.en.js.map +1 -1
  82. package/dist/lib/operations/cms-persistence.d.ts +20 -10
  83. package/dist/lib/operations/cms-persistence.js +11 -3
  84. package/dist/lib/operations/cms-persistence.js.map +1 -1
  85. package/dist/lib/persistence/convex-adapter.js +7 -2
  86. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  87. package/package.json +3 -3
  88. package/convex/asset-alt-migration.test.ts +0 -127
  89. package/convex/redirect-migration.test.ts +0 -205
  90. package/convex/version-kind-stamping.test.ts +0 -118
@@ -41,11 +41,28 @@
41
41
  * `writerLockOf` locks (`ai.exclude` or `form.readOnly`, and everything below it) keeps
42
42
  * its CURRENT value. Leaving it out of the written value, or echoing it back unchanged,
43
43
  * is no write; any other value is reported as a `locked` problem (create drops it, edit
44
- * refuses — the same two policies as the id leash).
44
+ * refuses — the same two policies as the id leash);
45
+ * - **the locked-content judge** (EC-738) — a write to a WRITABLE position must not erase
46
+ * the locked content below it, which its own walk never visits: a holder omitted below
47
+ * the write's root keeps its current value whole (omission is no write — and the model
48
+ * may not know the holder holds anything, EC-741); an explicit removal (a root `null` /
49
+ * omitted value, a nested `null`, a shorter list, a removed asset entry) of a holder whose
50
+ * current value holds a defined locked value is a `locked` problem, located at the holder,
51
+ * and the holder keeps its current value. A repeater whose items hold one cannot be
52
+ * shortened at all (items are matched by index). Translate reuses the judge
53
+ * ({@link withLockedFrom}, {@link lockedChanges});
54
+ * - **the unreadable value** (EC-748, Codex r9 F44) — a stored value the traversal cannot
55
+ * read (a holder of the other cardinality after a schema edit, a repeater item that is no
56
+ * object, a body that is no v2 sequence, a body read without the prop lookup, …) is
57
+ * judged by its DECLARATION (`declarationMayHold`, failing closed on bodies), never read
58
+ * as empty: the model read WITHHOLDS it when it may hold an excluded value, and a write
59
+ * that would change or clear it is REFUSED when it may hold a locked one — until a non-AI
60
+ * save stores it in its canonical form. A value the MODEL writes in a shape its
61
+ * declaration does not read is refused too (nothing below it could be leashed or judged).
45
62
  *
46
63
  * Pure and deterministic; no provider, no I/O.
47
64
  */
48
- import { carryAssetPlacements, childPositions, fieldPath, fieldTypeOf, fieldValuePosition, isSequenceValue, rebuildFromChildren, referenceNode, seedObjectItem, sequenceComponentNodes, walkReferences, walkValuePositions, writerLockOf, } from '@elytracms/core/cms-core';
65
+ import { carryAssetPlacements, childPositions, declarationMayHold, fieldPath, fieldTypeOf, fieldValuePosition, isOpaquePosition, isSequenceValue, readAssetId, rebuildFromChildren, referenceNode, resolveDocumentTitle, seedObjectItem, selfTypeFieldValue, sequenceComponentNodes, walkReferences, walkValuePositions, writerLockOf, } from '@elytracms/core/cms-core';
49
66
  import { parseRichTextValue, richTextValueToMarkdownProse } from '@elytracms/core/rich-text';
50
67
  import { richTextValueFromModelInput } from './rich-text-input.js';
51
68
  export const NO_HELD_REFERENCES = { documents: new Map(), assets: new Set() };
@@ -87,8 +104,12 @@ export function lockedWriteMessage(subjectText, lock) {
87
104
  * which the placement carry and the composition leash read. `null` / `undefined` clears.
88
105
  */
89
106
  export function fromWriterForm(position, current, ctx) {
107
+ return convertRoot(position, current, ctx, position.field.name);
108
+ }
109
+ /** One write, rooted at `position`; `root` names the top-level field in messages. */
110
+ function convertRoot(position, current, ctx, root) {
90
111
  const problems = [];
91
- const value = convert(position, current, ctx, problems, position.field.name);
112
+ const value = convert(position, current, ctx, problems, root, true);
92
113
  return { value, problems };
93
114
  }
94
115
  /** {@link fromWriterForm} for a top-level field's value. */
@@ -137,23 +158,87 @@ function childKey(parent, child) {
137
158
  ? `node:${child.component.nodeId}:${child.field.name}`
138
159
  : `at:${JSON.stringify(child.at.slice(parent.at.length))}`;
139
160
  }
161
+ /** {@link childKey} for a value the traversal cannot read, in its own key space (EC-748). */
162
+ function opaqueChildKey(parent, child) {
163
+ return child.opaque === 'not-a-static-prop' && child.component
164
+ ? `opaque:node:${child.component.nodeId}:${child.declaration.name}`
165
+ : `opaque:at:${JSON.stringify(child.at.slice(parent.at.length))}`;
166
+ }
167
+ /** The model-facing name of an unreadable value: its declaration's name, located when nested. */
168
+ function opaqueSubject(position, root) {
169
+ const name = `"${position.declaration.name}"`;
170
+ return position.at.length === 0 ? name : `${name} (at ${label(root, position.at)})`;
171
+ }
172
+ /**
173
+ * What a writer is told about a value it wrote in a shape the declaration does not read
174
+ * (EC-748): nothing below it could be converted, leashed or judged for locks, so it is not
175
+ * written — said in the shape the field does take.
176
+ */
177
+ function unreadableWriteMessage(position, root) {
178
+ const what = `Field ${opaqueSubject(position, root)}`;
179
+ switch (position.opaque) {
180
+ case 'cardinality':
181
+ return Array.isArray(position.value)
182
+ ? `${what} holds one value, not a list — send the value itself.`
183
+ : `${what} holds a list — send a list.`;
184
+ case 'not-an-object':
185
+ return `${what} takes an object of its declared sub-fields.`;
186
+ case 'not-an-asset':
187
+ return `${what} takes asset id string(s) from listAssets results.`;
188
+ case 'not-a-sequence':
189
+ return `${what} takes rich text as markdown, or the stored { version: 2, content } object.`;
190
+ case 'not-a-static-prop':
191
+ return `${what} is a component prop stored as no { value } member; the assistant cannot write it.`;
192
+ case 'no-prop-declarations':
193
+ return `${what} holds components whose props cannot be judged here: the project's components are not available.`;
194
+ }
195
+ }
140
196
  /** Convert every child position of `pos` (its value already in a readable shape). */
141
197
  function convertChildren(pos, current, ctx, problems, root) {
142
198
  const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
143
199
  const currentChildren = new Map();
144
200
  if (current !== undefined) {
145
201
  for (const child of childPositions({ ...pos, value: current }, positionCtx)) {
146
- currentChildren.set(childKey(pos, child), child.value);
202
+ if (isOpaquePosition(child))
203
+ currentChildren.set(opaqueChildKey(pos, child), child.value);
204
+ else
205
+ currentChildren.set(childKey(pos, child), child.value);
147
206
  }
148
207
  }
149
- return rebuildFromChildren(pos, positionCtx, (child) => convert(child, currentChildren.get(childKey(pos, child)), ctx, problems, root));
208
+ return rebuildFromChildren(pos, positionCtx, (child) => convert(child, currentChildren.get(childKey(pos, child)), ctx, problems, root, false),
209
+ // EC-748: a value the model wrote in a shape its declaration does not read — nothing
210
+ // below it can be converted, leashed or judged for locks (a list written for a single
211
+ // object would reach a locked sub-field through the canonicaliser's unwrap). The stored
212
+ // value echoed back, unchanged, is no write; anything else is a problem, and not written.
213
+ (unreadable) => {
214
+ const stored = currentChildren.get(opaqueChildKey(pos, unreadable));
215
+ if (stored !== undefined && canonicalJson(stored) === canonicalJson(unreadable.value))
216
+ return unreadable.value;
217
+ problems.push({ at: unreadable.at, code: 'invalid-field-value', message: unreadableWriteMessage(unreadable, root) });
218
+ return undefined;
219
+ });
150
220
  }
151
- function convert(pos, current, ctx, problems, root) {
221
+ /**
222
+ * `isRoot` — whether `pos` is the write's root (the field, or the prop a node change
223
+ * names): there an omitted value CLEARS (`update-fields`: "`null` / omitted clears");
224
+ * below it an omitted holder of locked content is kept whole (EC-738).
225
+ */
226
+ function convert(pos, current, ctx, problems, root, isRoot) {
152
227
  // EC-716: a locked position keeps its current value — and so, by not being visited,
153
228
  // does everything below it (the lock cascades as the form's `readOnly` does).
154
229
  const lock = writerLockOf(pos.field);
155
- if (lock === undefined)
156
- return convertValue(pos, current, ctx, problems, root);
230
+ if (lock === undefined) {
231
+ const written = convertWritable(pos, current, ctx, problems, root, isRoot);
232
+ // EC-748: a component prop is judged against its canonical form (THE drift issue, at prop
233
+ // depth), and a proposal's write does not re-canonicalise props (update-fields: it would
234
+ // rewrite every untouched node's wrappers) — so a prop the converter WRITES is stored in
235
+ // its canonical form here, by the write lens itself (`writeField` does it for a field): a
236
+ // multi-select's lone option becomes a list, an emptied list or sequence absent. A value
237
+ // kept as it is (`current`) is never rewritten.
238
+ if (pos.kind !== 'prop' || written === current || written === undefined)
239
+ return written;
240
+ return selfTypeFieldValue(pos.field, written, ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {});
241
+ }
157
242
  if (pos.value === undefined || isEcho(pos, current, ctx, root))
158
243
  return current;
159
244
  problems.push({
@@ -164,6 +249,194 @@ function convert(pos, current, ctx, problems, root) {
164
249
  });
165
250
  return current;
166
251
  }
252
+ /**
253
+ * {@link convert} for a WRITABLE position, judged for the locked content below it (EC-738).
254
+ * The conversion never visits a locked position its written value no longer holds (a
255
+ * cleared object, a shortened list, a removed asset entry), so the judge compares the locked
256
+ * values under the CURRENT value with those under the converted one.
257
+ */
258
+ function convertWritable(pos, current, ctx, problems, root, isRoot) {
259
+ if (!isPresent(current))
260
+ return convertValue(pos, current, ctx, problems, root);
261
+ const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
262
+ const before = lockedPositionsUnder({ ...pos, value: current }, positionCtx);
263
+ const held = [...before.values()].filter((entry) => isPresent(entry.value));
264
+ if (held.length === 0)
265
+ return convertValue(pos, current, ctx, problems, root);
266
+ // An omission below the write's root is no write (EC-716's rule for a locked position,
267
+ // applied to the holder of one): the holder keeps its current value, whole.
268
+ if (!isRoot && pos.value === undefined)
269
+ return current;
270
+ const next = convertValue(pos, current, ctx, problems, root);
271
+ const lost = lockedLosses(before, lockedPositionsUnder({ ...pos, value: next }, positionCtx));
272
+ // A repeater's items are matched by INDEX: removing one shifts every later item's
273
+ // writable content onto the locked values of the item before it — invisible to a per-key
274
+ // comparison, so any shortening of a repeater that holds locked content is refused.
275
+ const shortened = pos.field.type === 'object' &&
276
+ pos.field.multiple === true &&
277
+ Array.isArray(current) &&
278
+ (Array.isArray(next) ? next.length : 0) < current.length;
279
+ if (lost.length === 0 && !shortened)
280
+ return next;
281
+ const judged = lost.length > 0 ? lost : held;
282
+ problems.push({
283
+ at: pos.at,
284
+ code: 'invalid-field-value',
285
+ message: judged.some((entry) => entry.opaque !== undefined)
286
+ ? unreadableLockedMessage(subject(pos, root), root, judged)
287
+ : lockedRemovalMessage(subject(pos, root), root, judged, shortened),
288
+ locked: true,
289
+ });
290
+ return current;
291
+ }
292
+ // --- The locked-content judge (EC-738) -----------------------------------------------
293
+ /** A value that is there (`undefined` / `null` — nothing to lose; the converter's own rule). */
294
+ function isPresent(value) {
295
+ return value !== undefined && value !== null;
296
+ }
297
+ /**
298
+ * The writer lock an unreadable value's declaration may hold (EC-748): `excluded` when it may
299
+ * hold an `ai.exclude` position, else `read-only` when it may hold a `form.readOnly` one, else
300
+ * none — over the declaration alone (`declarationMayHold`: a body may hold any component, so
301
+ * it may hold either; `excluded` wins, the stricter lock).
302
+ */
303
+ export function declaredWriterLock(declaration) {
304
+ if (declarationMayHold(declaration, (field) => writerLockOf(field) === 'excluded'))
305
+ return 'excluded';
306
+ if (declarationMayHold(declaration, (field) => writerLockOf(field) === 'read-only'))
307
+ return 'read-only';
308
+ return undefined;
309
+ }
310
+ /** {@link lockedChildKey} for a value the traversal cannot read — its own key space (EC-748). */
311
+ function lockedOpaqueKey(parent, parentKey, child) {
312
+ if (child.opaque === 'not-a-static-prop' && child.component) {
313
+ return `opaque:node:${child.component.nodeId}:${child.declaration.name}`;
314
+ }
315
+ return `${parentKey}/opaque:${JSON.stringify(child.at.slice(parent.at.length))}`;
316
+ }
317
+ /**
318
+ * The key matching a child position across two values of one position: a component prop by
319
+ * its NODE (ids are unique in a document and survive a prose edit that shifts indices), an
320
+ * entry of an asset LIST by its asset id and occurrence (the carry's own matching,
321
+ * `carryAssetPlacements` — a reordered gallery keeps each placement), anything else by its
322
+ * location below the parent (a repeater item by index, a sub-field by name).
323
+ */
324
+ function lockedChildKey(parent, parentKey, child) {
325
+ if (child.kind === 'prop' && child.component)
326
+ return `node:${child.component.nodeId}:${child.field.name}`;
327
+ const below = child.at.slice(parent.at.length);
328
+ if (parent.field.type === 'asset' && parent.field.multiple !== false && Array.isArray(parent.value)) {
329
+ const index = below[0];
330
+ const entries = parent.value;
331
+ const id = readAssetId(entries[index]);
332
+ const occurrence = entries.slice(0, index).filter((entry) => readAssetId(entry) === id).length;
333
+ return `${parentKey}/asset:${JSON.stringify(id)}#${occurrence}/${JSON.stringify(below.slice(1))}`;
334
+ }
335
+ return `${parentKey}/${JSON.stringify(below)}`;
336
+ }
337
+ /** Every topmost writer-locked position at or below `position`, by {@link lockedChildKey}. */
338
+ function lockedPositionsUnder(position, positionCtx) {
339
+ const out = new Map();
340
+ const visit = (pos, key) => {
341
+ const lock = writerLockOf(pos.field);
342
+ if (lock !== undefined) {
343
+ out.set(key, { value: pos.value, lock, at: pos.at });
344
+ return;
345
+ }
346
+ for (const child of childPositions(pos, positionCtx)) {
347
+ if (!isOpaquePosition(child)) {
348
+ visit(child, lockedChildKey(pos, key, child));
349
+ continue;
350
+ }
351
+ // EC-748: what cannot be read is judged by its declaration — never "nothing locked here".
352
+ const lock = declaredWriterLock(child.declaration);
353
+ if (lock !== undefined)
354
+ out.set(lockedOpaqueKey(pos, key, child), { value: child.value, lock, at: child.at, opaque: child.opaque });
355
+ }
356
+ };
357
+ visit(position, '');
358
+ return out;
359
+ }
360
+ /** Whether `entry` sits in `other` unchanged at `key` (a defined value, the same JSON). */
361
+ function keptAt(other, key, entry) {
362
+ const now = other.get(key);
363
+ return now !== undefined && isPresent(now.value) && canonicalJson(now.value) === canonicalJson(entry.value);
364
+ }
365
+ /**
366
+ * The defined locked values of `before` that `after` does not hold, unchanged, at their key —
367
+ * and (EC-748) every unreadable value that may hold one on EITHER side and is not on the other
368
+ * unchanged: what it holds cannot be compared, so a change to it (or one written where none
369
+ * was) is counted lost — fail closed.
370
+ */
371
+ function lockedLosses(before, after) {
372
+ const lost = [...before].flatMap(([key, entry]) => (isPresent(entry.value) && !keptAt(after, key, entry) ? [entry] : []));
373
+ const written = [...after].flatMap(([key, entry]) => entry.opaque !== undefined && isPresent(entry.value) && !keptAt(before, key, entry) ? [entry] : []);
374
+ return [...lost, ...written];
375
+ }
376
+ /**
377
+ * What a writer is told about a write that would change or clear a value stored in a shape
378
+ * its declaration cannot read, which may hold locked content (EC-748), located at the holder
379
+ * the model wrote. Its content is never named (it cannot be read — and excluded content may
380
+ * not be disclosed, EC-741); the repair is a save outside the assistant.
381
+ */
382
+ export function unreadableLockedMessage(holder, root, locked) {
383
+ const excluded = locked.some((entry) => entry.lock === 'excluded');
384
+ const where = locked.filter((entry) => entry.opaque !== undefined && entry.at.length > 0).map((entry) => label(root, entry.at));
385
+ const at = !excluded && where.length > 0 ? ` (${where.join(', ')})` : '';
386
+ const what = excluded ? 'content the assistant must not change' : 'read-only content';
387
+ return `Field ${holder} holds a value stored in a shape its declaration cannot read${at} — it was saved under an earlier schema or written past the editor — and it may hold ${what}; the assistant cannot change, replace or clear it. Repair it outside the assistant: save the field again in the editor, in the shape its declaration reads; then the assistant can change its other values.`;
388
+ }
389
+ /**
390
+ * What a writer is told about a write that would remove locked content (EC-738), located at
391
+ * the HOLDER the model wrote. Read-only content is named (the model reads it); excluded
392
+ * content never is (EC-741: the model never sees it, so naming its path would disclose it).
393
+ */
394
+ export function lockedRemovalMessage(holder, root, locked, shortened) {
395
+ const excluded = locked.some((entry) => entry.lock === 'excluded');
396
+ const what = excluded
397
+ ? 'content the assistant must not change'
398
+ : `read-only content (${locked.map((entry) => label(root, entry.at)).join(', ')}) — no one changes it in the studio, the assistant included`;
399
+ return shortened
400
+ ? `Field ${holder} holds ${what}; the assistant cannot remove items from it (items are matched by position, so removing one would delete that content or move it onto another item). Keep every item and change only their other values.`
401
+ : `Field ${holder} holds ${what}; the assistant cannot clear or remove it. Change only its other values.`;
402
+ }
403
+ /**
404
+ * The value at `position` with every writer-locked position taken from `current` by key
405
+ * (EC-738, translate): where `current` holds that position — its value, an absent one
406
+ * included — the current one wins; a position `current` does not hold at all (a new item, a
407
+ * node it lacks) keeps `position`'s own. {@link lockedChanges} then says whether that kept
408
+ * the locked content as it is.
409
+ */
410
+ export function withLockedFrom(position, current, ctx) {
411
+ const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
412
+ const held = lockedPositionsUnder({ ...position, value: current }, positionCtx);
413
+ const rebuild = (pos, key) => {
414
+ if (writerLockOf(pos.field) !== undefined)
415
+ return held.has(key) ? held.get(key).value : pos.value;
416
+ // EC-748: a value `position` holds unreadably is kept as it is — nothing below it can be
417
+ // matched by key — and {@link lockedChanges} counts it lost unless `current` holds it too.
418
+ return rebuildFromChildren(pos, positionCtx, (child) => rebuild(child, lockedChildKey(pos, key, child)), (unreadable) => unreadable.value);
419
+ };
420
+ return rebuild(position, '');
421
+ }
422
+ /**
423
+ * The locked values a write from `position.value` (the CURRENT value) to `next` would lose or
424
+ * create, judged both ways (EC-738, translate): `lost` — a defined locked value `next` does
425
+ * not hold unchanged; `created` — a defined locked value `next` holds where the current value
426
+ * holds none. EC-748: a value stored in a shape its declaration cannot read, which may hold a
427
+ * locked value, on EITHER side and not on the other unchanged, is `lost` (fail closed) — so a
428
+ * drifted target keeps its value whole, and a drifted source never copies its locked values
429
+ * across.
430
+ */
431
+ export function lockedChanges(position, next, ctx) {
432
+ const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
433
+ const before = lockedPositionsUnder(position, positionCtx);
434
+ const after = lockedPositionsUnder({ ...position, value: next }, positionCtx);
435
+ return {
436
+ lost: lockedLosses(before, after),
437
+ created: [...after].flatMap(([key, entry]) => entry.opaque === undefined && isPresent(entry.value) && !isPresent(before.get(key)?.value) ? [entry] : []),
438
+ };
439
+ }
167
440
  /**
168
441
  * Whether a value written to a locked position leaves it as it is: a clear of an empty
169
442
  * position, or the current value echoed back — as stored, in the form the model READ it
@@ -208,8 +481,13 @@ function convertValue(pos, current, ctx, problems, root) {
208
481
  switch (fieldTypeOf(field).writer) {
209
482
  case 'composition': {
210
483
  // 4c: a composition is never written whole — the value stays exactly what it is
211
- // (re-sending it unchanged is no change); its props change one at a time.
212
- if (canonicalJson(raw) === canonicalJson(current))
484
+ // (re-sending it unchanged is no change); its props change one at a time. EC-741:
485
+ // "unchanged" includes the value as the model READ it ({@link toWriterForm}: excluded
486
+ // props absent, rich-text props as markdown) — the rule `isEcho` reads.
487
+ const json = canonicalJson(raw);
488
+ if (json === canonicalJson(current))
489
+ return current;
490
+ if (current !== undefined && json === canonicalJson(toWriterForm({ ...pos, value: current }, ctx)))
213
491
  return current;
214
492
  return report('invalid-field-value', `Field ${subject(pos, root)} is a page-builder composition: adding, reordering or removing its components is done on the canvas, not by the assistant. Change one component's prop with blockId + prop (the component's id from getDocument).`);
215
493
  }
@@ -266,7 +544,11 @@ function convertValue(pos, current, ctx, problems, root) {
266
544
  return undefined;
267
545
  // EC-558 / 2c: the ONE carry rule — a held placement keeps its per-use sub-fields,
268
546
  // a swap carries them to the new image, a new id is an envelope.
269
- const carried = carryAssetPlacements(current, asset.multiple === true ? { multiple: true, ids: kept.map((e) => e.id) } : { multiple: false, id: kept[0].id });
547
+ const carriedAsStored = carryAssetPlacements(current, asset.multiple === true ? { multiple: true, ids: kept.map((e) => e.id) } : { multiple: false, id: kept[0].id });
548
+ // EC-748: the carry hands back a single field's stored one-item list as it is (same id);
549
+ // the WRITTEN value takes the declared cardinality — the model wrote one value. (Whether
550
+ // replacing that unreadable stored list is allowed is the lock judge's question.)
551
+ const carried = asset.multiple !== true && Array.isArray(carriedAsStored) ? carriedAsStored[0] : carriedAsStored;
270
552
  // The writer's own sub-field values win over the carried ones.
271
553
  const withWriter = (entry, index) => {
272
554
  const rest = kept[index]?.rest ?? {};
@@ -348,6 +630,8 @@ export function fromWriterFormAtNode(field, displayed, nodeId, prop, raw, ctx, l
348
630
  let targetLock;
349
631
  const declared = [];
350
632
  let componentId;
633
+ // EC-748: the target prop stored as no static member — declared, but unreadable.
634
+ let unreadableTarget = false;
351
635
  const visit = (position, inherited) => {
352
636
  const lock = writerLockOf(position.field) ?? inherited;
353
637
  if (position.kind === 'prop' && position.component?.nodeId === nodeId) {
@@ -358,10 +642,29 @@ export function fromWriterFormAtNode(field, displayed, nodeId, prop, raw, ctx, l
358
642
  targetLock = lock;
359
643
  }
360
644
  }
361
- for (const child of childPositions(position, positionCtx))
362
- visit(child, lock);
645
+ for (const child of childPositions(position, positionCtx)) {
646
+ if (!isOpaquePosition(child)) {
647
+ visit(child, lock);
648
+ continue;
649
+ }
650
+ // EC-748: a node inside a value the traversal cannot read is not addressable — it is not
651
+ // found (the write never reaches into an unreadable holder); a prop of the node stored as
652
+ // no static member is declared but cannot be written.
653
+ if (child.opaque === 'not-a-static-prop' && child.component?.nodeId === nodeId) {
654
+ componentId = child.component.componentId;
655
+ declared.push(child.declaration.name);
656
+ if (child.declaration.name === prop)
657
+ unreadableTarget = true;
658
+ }
659
+ }
363
660
  };
364
661
  visit(root, undefined);
662
+ if (!target && unreadableTarget) {
663
+ return {
664
+ ok: false,
665
+ message: `Prop "${prop}" of component "${nodeId}" (${componentId}) is stored as no { value } member (a retired shape); the assistant cannot change it. Repair it outside the assistant: set the prop again in the editor.`,
666
+ };
667
+ }
365
668
  if (target && targetLock !== undefined) {
366
669
  return {
367
670
  ok: false,
@@ -381,8 +684,10 @@ export function fromWriterFormAtNode(field, displayed, nodeId, prop, raw, ctx, l
381
684
  };
382
685
  }
383
686
  const found = target;
384
- const { value: converted, problems } = fromWriterForm({ ...found, value: raw }, found.value, ctx);
385
- const rebuild = (position) => rebuildFromChildren(position, positionCtx, (child) => (isTarget(child) ? converted : rebuild(child)));
687
+ // The prop is the write's ROOT (an omitted value clears it); messages locate positions
688
+ // from the top-level field (`body.content[0].props.group.locked`).
689
+ const { value: converted, problems } = convertRoot({ ...found, value: raw }, found.value, ctx, field.name);
690
+ const rebuild = (position) => rebuildFromChildren(position, positionCtx, (child) => (isTarget(child) ? converted : rebuild(child)), (unreadable) => unreadable.value);
386
691
  const value = rebuild(root);
387
692
  return found.exact
388
693
  ? { ok: true, value, path: found.path, propValue: converted, exact: true, problems }
@@ -417,8 +722,11 @@ export function textPositionsOf(position, ctx) {
417
722
  out.push(current);
418
723
  if (current.field.type === 'text')
419
724
  return;
725
+ // EC-748: a value the traversal cannot read yields no unit — its text cannot be found
726
+ // (and translate skips a bucket where one may hold locked content: `lockedChanges`).
420
727
  for (const child of childPositions(current, positionCtx))
421
- visit(child);
728
+ if (!isOpaquePosition(child))
729
+ visit(child);
422
730
  };
423
731
  visit(position);
424
732
  return out;
@@ -438,7 +746,8 @@ export function rebuildTextPositions(position, ctx, replace) {
438
746
  if (current.field.type === 'text')
439
747
  return replace(current);
440
748
  const value = isTextPosition(current) ? replace(current) : current.value;
441
- return rebuildFromChildren({ ...current, value }, positionCtx, rebuild);
749
+ // EC-748: an unreadable value comes back as it was (it held no unit).
750
+ return rebuildFromChildren({ ...current, value }, positionCtx, rebuild, (unreadable) => unreadable.value);
442
751
  };
443
752
  return rebuild(position);
444
753
  }
@@ -451,8 +760,27 @@ export function rebuildTextPositions(position, ctx, replace) {
451
760
  * stays, so nothing echoed back can silently lose structure), at EVERY position: a
452
761
  * top-level field, an object sub-field, a repeater item, a component's rich-text prop.
453
762
  * Everything else reads as stored (envelopes are writer forms too).
763
+ *
764
+ * **THE model-read projection** (EC-741): a position core's `writerLockOf` marks
765
+ * `excluded` (`ai: { exclude: true }` — "the model never sees or writes the field") reads
766
+ * as ABSENT, and so does everything below it, at every depth: a sub-field, a repeater
767
+ * item's sub-field, a placement sub-field, a component prop in a composition, a slot or
768
+ * typography content. A `read-only` position stays readable. Every model read crosses this
769
+ * one fold — `getDocument`'s output, the titles the model is shown
770
+ * ({@link modelFacingTitle}) and the echo checks (a value the model read and sent back is
771
+ * no change) — so what the model can see and what it can echo are one declaration. The raw
772
+ * stored document stays local: proposals are always built and validated against it.
773
+ *
774
+ * EC-748 (Codex r9 F44): a stored value the traversal cannot read (a holder of the other
775
+ * cardinality after a schema edit, a repeater item that is no object, a body that is no v2
776
+ * sequence or is read without the prop lookup, …) is WITHHELD — absent where it sits: the
777
+ * field, the list entry, the prop — whenever its declaration may hold an excluded position
778
+ * (`declaredWriterLock`, failing closed on bodies). What cannot be read cannot be projected,
779
+ * so it is never sent whole. One that may hold none reads as stored.
454
780
  */
455
781
  export function toWriterForm(position, ctx) {
782
+ if (writerLockOf(position.field) === 'excluded')
783
+ return undefined;
456
784
  const { value } = position;
457
785
  if (value === undefined || value === null)
458
786
  return value;
@@ -463,7 +791,7 @@ export function toWriterForm(position, ctx) {
463
791
  if (markdown !== undefined)
464
792
  return markdown;
465
793
  }
466
- return rebuildFromChildren(position, positionCtx, (child) => toWriterForm(child, ctx));
794
+ return rebuildFromChildren(position, positionCtx, (child) => toWriterForm(child, ctx), (unreadable) => (declaredWriterLock(unreadable.declaration) === 'excluded' ? undefined : unreadable.value));
467
795
  }
468
796
  /**
469
797
  * The components a document holds whose props a writer may change — every component
@@ -481,7 +809,9 @@ export function componentIdsOf(collection, document, lookupManifest) {
481
809
  walkValuePositions(fieldValuePosition(field, value), { lookupManifest }, (position) => {
482
810
  if (position.kind === 'prop' && position.component)
483
811
  ids.add(position.component.componentId);
484
- });
812
+ },
813
+ // EC-748: a component inside a value the traversal cannot read has no prop a writer can change.
814
+ () => { });
485
815
  }
486
816
  };
487
817
  visitBucket(document.values);
@@ -489,28 +819,50 @@ export function componentIdsOf(collection, document, lookupManifest) {
489
819
  visitBucket(bucket);
490
820
  return [...ids];
491
821
  }
822
+ /**
823
+ * {@link toWriterForm} over one values bag: each declared field in its read form, an
824
+ * excluded one (and an excluded position inside any field) left out. A key the collection
825
+ * does not declare reads as stored (AD-14: the schema is a lens).
826
+ */
827
+ export function toWriterValues(collection, bag, ctx, locale) {
828
+ let next;
829
+ for (const field of collection.fields) {
830
+ if (!Object.hasOwn(bag, field.name))
831
+ continue;
832
+ const value = bag[field.name];
833
+ const read = toWriterForm(fieldValuePosition(field, value, locale !== undefined ? { locale } : {}), ctx);
834
+ if (read === value)
835
+ continue;
836
+ next ??= { ...bag };
837
+ if (read === undefined)
838
+ delete next[field.name];
839
+ else
840
+ next[field.name] = read;
841
+ }
842
+ return next ?? bag;
843
+ }
492
844
  /** {@link toWriterForm} over every field of every bucket of a document. */
493
845
  export function toWriterDocument(collection, document, ctx) {
494
- const byName = new Map(collection.fields.map((field) => [field.name, field]));
495
- const mapBucket = (bucket, locale) => {
496
- let next = bucket;
497
- for (const [name, value] of Object.entries(bucket)) {
498
- const field = byName.get(name);
499
- if (!field)
500
- continue;
501
- const read = toWriterForm(fieldValuePosition(field, value, locale !== undefined ? { locale } : {}), ctx);
502
- if (read === value)
503
- continue;
504
- if (next === bucket)
505
- next = { ...bucket };
506
- next[name] = read;
507
- }
508
- return next;
509
- };
510
- const values = mapBucket(document.values);
846
+ const values = toWriterValues(collection, document.values, ctx);
511
847
  const localized = document.localized
512
- ? Object.fromEntries(Object.entries(document.localized).map(([locale, bucket]) => [locale, mapBucket(bucket, locale)]))
848
+ ? Object.fromEntries(Object.entries(document.localized).map(([locale, bucket]) => [
849
+ locale,
850
+ toWriterValues(collection, bucket, ctx, locale),
851
+ ]))
513
852
  : undefined;
514
853
  return { ...document, values, ...(localized ? { localized } : {}) };
515
854
  }
855
+ /**
856
+ * The title a MODEL is shown for a document (EC-741) — core's `resolveDocumentTitle` over
857
+ * the projected values ({@link toWriterValues}), so an excluded field never reaches the
858
+ * model as a title: neither as the named or first-field fallback, nor through a function
859
+ * `titleField` that reads it (the function sees the field absent). Every title the model
860
+ * reads goes through here: the `updateFields` / `createDocument` outputs, the
861
+ * `searchDocuments` results (their query is matched against this title too, so an
862
+ * excluded value cannot be probed by search) and the system prompt's "currently open"
863
+ * line. `values` is the bag the caller resolves titles from (a locale's merged values).
864
+ */
865
+ export function modelFacingTitle(collection, values, fallbackId, ctx = {}) {
866
+ return resolveDocumentTitle(collection, toWriterValues(collection, values, ctx), fallbackId);
867
+ }
516
868
  //# sourceMappingURL=writer-form.js.map