@elytracms/studio 0.0.57 → 0.0.59

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 (109) hide show
  1. package/dist/components/ai/ai-chat-panel.js +44 -6
  2. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  3. package/dist/components/ai/chat-ui.d.ts +1 -1
  4. package/dist/components/ai/chat-ui.js +11 -2
  5. package/dist/components/ai/chat-ui.js.map +1 -1
  6. package/dist/components/ai/proposal-card.d.ts +30 -7
  7. package/dist/components/ai/proposal-card.js +81 -21
  8. package/dist/components/ai/proposal-card.js.map +1 -1
  9. package/dist/components/builder/blocks-model.js +12 -7
  10. package/dist/components/builder/blocks-model.js.map +1 -1
  11. package/dist/components/builder/canvas-dnd.d.ts +59 -0
  12. package/dist/components/builder/canvas-dnd.js +207 -0
  13. package/dist/components/builder/canvas-dnd.js.map +1 -0
  14. package/dist/components/builder/canvas-dom.d.ts +11 -0
  15. package/dist/components/builder/canvas-dom.js +17 -0
  16. package/dist/components/builder/canvas-dom.js.map +1 -0
  17. package/dist/components/builder/canvas-inline-editing.d.ts +91 -0
  18. package/dist/components/builder/canvas-inline-editing.js +242 -0
  19. package/dist/components/builder/canvas-inline-editing.js.map +1 -0
  20. package/dist/components/builder/canvas-render-context.d.ts +83 -0
  21. package/dist/components/builder/canvas-render-context.js +196 -0
  22. package/dist/components/builder/canvas-render-context.js.map +1 -0
  23. package/dist/components/builder/canvas.d.ts +4 -1
  24. package/dist/components/builder/canvas.js +32 -567
  25. package/dist/components/builder/canvas.js.map +1 -1
  26. package/dist/components/builder/composition-field-editor.js +14 -4
  27. package/dist/components/builder/composition-field-editor.js.map +1 -1
  28. package/dist/components/builder/insert-context.d.ts +5 -2
  29. package/dist/components/builder/insert-context.js +1 -3
  30. package/dist/components/builder/insert-context.js.map +1 -1
  31. package/dist/components/builder/node-factory.d.ts +12 -0
  32. package/dist/components/builder/node-factory.js +18 -1
  33. package/dist/components/builder/node-factory.js.map +1 -1
  34. package/dist/components/cms/asset-field.d.ts +22 -0
  35. package/dist/components/cms/asset-field.js +300 -0
  36. package/dist/components/cms/asset-field.js.map +1 -0
  37. package/dist/components/cms/detail-editor.js +12 -0
  38. package/dist/components/cms/detail-editor.js.map +1 -1
  39. package/dist/components/cms/document-diff-view.d.ts +12 -1
  40. package/dist/components/cms/document-diff-view.js +119 -42
  41. package/dist/components/cms/document-diff-view.js.map +1 -1
  42. package/dist/components/cms/field-control-lib.d.ts +43 -0
  43. package/dist/components/cms/field-control-lib.js +98 -0
  44. package/dist/components/cms/field-control-lib.js.map +1 -0
  45. package/dist/components/cms/field-control.d.ts +7 -35
  46. package/dist/components/cms/field-control.js +13 -556
  47. package/dist/components/cms/field-control.js.map +1 -1
  48. package/dist/components/cms/object-array-field.d.ts +29 -0
  49. package/dist/components/cms/object-array-field.js +205 -0
  50. package/dist/components/cms/object-array-field.js.map +1 -0
  51. package/dist/components/cms/rich-text-preview.d.ts +15 -0
  52. package/dist/components/cms/rich-text-preview.js +81 -0
  53. package/dist/components/cms/rich-text-preview.js.map +1 -0
  54. package/dist/components/cms/version-compare-text.d.ts +26 -0
  55. package/dist/components/cms/version-compare-text.js +60 -0
  56. package/dist/components/cms/version-compare-text.js.map +1 -1
  57. package/dist/components/project-session.d.ts +19 -0
  58. package/dist/components/project-session.js +29 -2
  59. package/dist/components/project-session.js.map +1 -1
  60. package/dist/components/use-hydration-phase.d.ts +6 -4
  61. package/dist/components/use-hydration-phase.js.map +1 -1
  62. package/dist/lib/ai/apply-content.js +52 -10
  63. package/dist/lib/ai/apply-content.js.map +1 -1
  64. package/dist/lib/ai/chat-context.d.ts +19 -3
  65. package/dist/lib/ai/chat-context.js +33 -4
  66. package/dist/lib/ai/chat-context.js.map +1 -1
  67. package/dist/lib/ai/chat-state.d.ts +6 -3
  68. package/dist/lib/ai/chat-state.js.map +1 -1
  69. package/dist/lib/ai/fake-provider.d.ts +60 -5
  70. package/dist/lib/ai/fake-provider.js +74 -6
  71. package/dist/lib/ai/fake-provider.js.map +1 -1
  72. package/dist/lib/ai/index.d.ts +2 -0
  73. package/dist/lib/ai/index.js +2 -0
  74. package/dist/lib/ai/index.js.map +1 -1
  75. package/dist/lib/ai/rich-text-input.d.ts +27 -0
  76. package/dist/lib/ai/rich-text-input.js +25 -0
  77. package/dist/lib/ai/rich-text-input.js.map +1 -0
  78. package/dist/lib/ai/shape.d.ts +12 -1
  79. package/dist/lib/ai/shape.js +46 -20
  80. package/dist/lib/ai/shape.js.map +1 -1
  81. package/dist/lib/ai/tools.d.ts +88 -13
  82. package/dist/lib/ai/tools.js +253 -14
  83. package/dist/lib/ai/tools.js.map +1 -1
  84. package/dist/lib/ai/types.d.ts +20 -0
  85. package/dist/lib/ai/types.js.map +1 -1
  86. package/dist/lib/ai/update-fields.d.ts +68 -0
  87. package/dist/lib/ai/update-fields.js +296 -0
  88. package/dist/lib/ai/update-fields.js.map +1 -0
  89. package/dist/lib/ai-current-document.d.ts +53 -0
  90. package/dist/lib/ai-current-document.js +54 -0
  91. package/dist/lib/ai-current-document.js.map +1 -0
  92. package/dist/lib/cms-views/view-model.d.ts +6 -6
  93. package/dist/lib/data/collection-content.d.ts +12 -7
  94. package/dist/lib/data/collection-content.js +6 -4
  95. package/dist/lib/data/collection-content.js.map +1 -1
  96. package/dist/lib/i18n/messages.en.js +10 -1
  97. package/dist/lib/i18n/messages.en.js.map +1 -1
  98. package/dist/lib/operations/session-hydration.d.ts +4 -1
  99. package/dist/lib/operations/session-hydration.js +4 -1
  100. package/dist/lib/operations/session-hydration.js.map +1 -1
  101. package/dist/lib/persistence/index.js +13 -1
  102. package/dist/lib/persistence/index.js.map +1 -1
  103. package/dist/lib/workspace/index.d.ts +1 -1
  104. package/dist/lib/workspace/index.js +1 -1
  105. package/dist/lib/workspace/index.js.map +1 -1
  106. package/dist/lib/workspace/workspace-source.d.ts +17 -22
  107. package/dist/lib/workspace/workspace-source.js +1 -49
  108. package/dist/lib/workspace/workspace-source.js.map +1 -1
  109. package/package.json +2 -2
@@ -14,13 +14,19 @@
14
14
  * a write path; apply is a separate, human-gated draft write.
15
15
  * 3. {@link createChatToolExecutor} — the client executor map `useChat`'s
16
16
  * `onToolCall` dispatches into. Readers pass through; the terminal tools run
17
- * the deterministic engines (`applyContentWithShape` / `translateDocument`)
18
- * through the route's kept `generate` branch and return a COMPACT proposal
19
- * summary as the tool output — the full proposal goes to the review UI via
17
+ * the deterministic engines (`applyContentWithShape` / `translateDocument` /
18
+ * `buildUpdateProposal`) and return a COMPACT proposal summary as the tool
19
+ * output — the full proposal goes to the review UI via
20
20
  * `onProposal(toolCallId, …)`, never to the model.
21
21
  *
22
- * EC-378 adds `updateFields` as one more definition + one more executor case —
23
- * the map's shape leaves that slot open.
22
+ * EC-378: `updateFields` is the edit-flow terminal tool — the model authors
23
+ * per-field changes directly (it has `getDocument` output + the conversation as
24
+ * context); the executor reads the CURRENT document, builds a validated update
25
+ * proposal (`buildUpdateProposal`), and hands it to the review sink. Its target
26
+ * (and `getDocument`'s) may be omitted to mean the document currently open in
27
+ * the editor — resolved via the {@link ChatToolExecutorOptions.currentDocument}
28
+ * seam ({@link resolveDocumentTarget}); the context block the panel injects is
29
+ * ADVISORY, the resolution here is what makes "this document" deterministic.
24
30
  *
25
31
  * The EC-199 planner enum/schema (`plannerSchema` / `parsePlannerAction`) retired
26
32
  * with the hand-rolled orchestrator (EC-334 slice 2).
@@ -29,7 +35,7 @@ import { z } from 'zod';
29
35
  import type { CmsDocument, CollectionDef, CollectionRegistry, LocaleConfig, ValidateDocumentOptions } from '@elytracms/core/cms-core';
30
36
  import type { ComponentManifest } from '@elytracms/core/component-registry';
31
37
  import type { ChatProposal } from './chat-state.js';
32
- import type { AiProvider, RelationCandidates, ResolvedKnowledge } from './types.js';
38
+ import type { AiProvider, AssetCandidates, RelationCandidates, ResolvedKnowledge } from './types.js';
33
39
  /** A document the model may reference as a relation target (from a `searchDocuments` read). */
34
40
  export interface DocumentSearchResult {
35
41
  id: string;
@@ -108,8 +114,8 @@ export declare const chatToolDefinitions: {
108
114
  getDocument: {
109
115
  description: string;
110
116
  inputSchema: z.ZodObject<{
111
- collection: z.ZodString;
112
- id: z.ZodString;
117
+ collection: z.ZodOptional<z.ZodString>;
118
+ id: z.ZodOptional<z.ZodString>;
113
119
  }, z.core.$strip>;
114
120
  };
115
121
  createDocument: {
@@ -119,6 +125,20 @@ export declare const chatToolDefinitions: {
119
125
  instruction: z.ZodString;
120
126
  }, z.core.$strip>;
121
127
  };
128
+ updateFields: {
129
+ description: string;
130
+ inputSchema: z.ZodObject<{
131
+ collection: z.ZodOptional<z.ZodString>;
132
+ id: z.ZodOptional<z.ZodString>;
133
+ changes: z.ZodArray<z.ZodObject<{
134
+ field: z.ZodString;
135
+ locale: z.ZodOptional<z.ZodString>;
136
+ blockId: z.ZodOptional<z.ZodString>;
137
+ prop: z.ZodOptional<z.ZodString>;
138
+ value: z.ZodOptional<z.ZodUnknown>;
139
+ }, z.core.$strip>>;
140
+ }, z.core.$strip>;
141
+ };
122
142
  translateDocument: {
123
143
  description: string;
124
144
  inputSchema: z.ZodObject<{
@@ -134,6 +154,33 @@ export interface ChatToolCall {
134
154
  toolName: string;
135
155
  input: unknown;
136
156
  }
157
+ /** The coordinates of the document currently open in the editor (EC-378 seam). */
158
+ export interface CurrentDocumentRef {
159
+ collection: string;
160
+ id: string;
161
+ }
162
+ /**
163
+ * Resolve a tool call's target document from its (optional) input coordinates
164
+ * and the current-document seam (EC-378). Deterministic:
165
+ *
166
+ * - both given → use them verbatim;
167
+ * - both omitted → the currently open document (error when none is open);
168
+ * - `collection` given with `id` omitted → the open document ONLY when the
169
+ * collections match — a named collection must never silently cross onto a
170
+ * different open document.
171
+ *
172
+ * Pure — tested directly; `getDocument` and `updateFields` share it.
173
+ */
174
+ export declare function resolveDocumentTarget(input: {
175
+ collection?: string;
176
+ id?: string;
177
+ }, current: CurrentDocumentRef | null | undefined): {
178
+ ok: true;
179
+ ref: CurrentDocumentRef;
180
+ } | {
181
+ ok: false;
182
+ errorText: string;
183
+ };
137
184
  /**
138
185
  * A tool execution outcome: an `output` posted back as the tool result, or an
139
186
  * `errorText` posted as an `output-error` part (the model sees the failure and
@@ -147,16 +194,27 @@ export type ChatToolOutcome = {
147
194
  errorText: string;
148
195
  };
149
196
  /**
150
- * The per-conversation relation-candidate store: every `searchDocuments` result
151
- * is recorded per collection, and `createDocument` may only reference the ids
152
- * gathered here (the `applyContentWithShape` enum leash makes a hallucinated id
153
- * structurally unrepresentable). Held by the panel per conversation — a new
154
- * conversation (EC-380 clear) starts an empty store.
197
+ * The per-conversation candidate store: every `searchDocuments` result is recorded
198
+ * per relation target collection, every `listAssets` result into one flat library
199
+ * pool (EC-381), and the terminal tools may only reference the ids gathered here
200
+ * (the `applyContentWithShape` / `buildUpdateProposal` enum + drop/loud leash makes
201
+ * a hallucinated relation OR asset id structurally unrepresentable / fail loud).
202
+ * Held by the panel per conversation — a new conversation (EC-380 clear) starts an
203
+ * empty store.
155
204
  */
156
205
  export interface ChatCandidateStore {
157
206
  record(collection: string, results: readonly DocumentSearchResult[]): void;
207
+ /** Record every `listAssets` result into the flat library pool (EC-381). */
208
+ recordAssets(results: readonly AssetSearchResult[]): void;
158
209
  /** Candidates for a target collection's relation fields, keyed by field name. */
159
210
  candidatesFor(collection: CollectionDef): RelationCandidates;
211
+ /**
212
+ * Candidates for a target collection's asset/image fields (EC-381), keyed by
213
+ * field name — every asset field shares the one flat searched-asset pool
214
+ * (`listAssets` has no field dimension). Empty when nothing was searched, so the
215
+ * asset field is then omitted from the shape (empty beats wrong).
216
+ */
217
+ assetCandidatesFor(collection: CollectionDef): AssetCandidates;
160
218
  }
161
219
  export declare function createChatCandidateStore(): ChatCandidateStore;
162
220
  export interface ChatToolExecutorOptions {
@@ -176,6 +234,13 @@ export interface ChatToolExecutorOptions {
176
234
  * The tool OUTPUT stays a compact summary; the patch never reaches the model.
177
235
  */
178
236
  onProposal: (toolCallId: string, proposal: ChatProposal) => void;
237
+ /**
238
+ * The document currently open in the editor (EC-378 seam) — a thunk read at
239
+ * EXECUTION time, so "this document" resolves against what is open NOW.
240
+ * `getDocument` / `updateFields` fall back to it when the model omits the
241
+ * target (see {@link resolveDocumentTarget}).
242
+ */
243
+ currentDocument?: () => CurrentDocumentRef | null | undefined;
179
244
  /** Current knowledge snapshot (thunk — the body thunk refreshes it per request). */
180
245
  knowledge?: () => ResolvedKnowledge | undefined;
181
246
  /** Component manifests — context for `allow`-leashed richText/blocks fields. */
@@ -197,6 +262,16 @@ export interface ChatToolExecutorOptions {
197
262
  export interface ChatToolExecutor {
198
263
  execute(call: ChatToolCall): Promise<ChatToolOutcome>;
199
264
  }
265
+ /**
266
+ * The MODEL-facing view of a document (EC-379): rich-text field values are
267
+ * projected to markdown strings — but ONLY when the engine's loss-guarded
268
+ * inverse can express them faithfully (subset-only content, round-trip proven).
269
+ * A document carrying embeds, internal `ref` links, or out-of-subset marks keeps
270
+ * its `{ version, doc }` envelope, so nothing the model later echoes back can
271
+ * silently lose structure. Applies to the `getDocument` TOOL OUTPUT only; the
272
+ * proposal/apply paths always read the raw stored document.
273
+ */
274
+ export declare function projectRichTextAsMarkdown(document: CmsDocument, collection: CollectionDef): CmsDocument;
200
275
  /**
201
276
  * Build the client executor map. Every entry validates its input against
202
277
  * {@link chatToolDefinitions}' zod schema (the route validates UIMessages only
@@ -14,21 +14,30 @@
14
14
  * a write path; apply is a separate, human-gated draft write.
15
15
  * 3. {@link createChatToolExecutor} — the client executor map `useChat`'s
16
16
  * `onToolCall` dispatches into. Readers pass through; the terminal tools run
17
- * the deterministic engines (`applyContentWithShape` / `translateDocument`)
18
- * through the route's kept `generate` branch and return a COMPACT proposal
19
- * summary as the tool output — the full proposal goes to the review UI via
17
+ * the deterministic engines (`applyContentWithShape` / `translateDocument` /
18
+ * `buildUpdateProposal`) and return a COMPACT proposal summary as the tool
19
+ * output — the full proposal goes to the review UI via
20
20
  * `onProposal(toolCallId, …)`, never to the model.
21
21
  *
22
- * EC-378 adds `updateFields` as one more definition + one more executor case —
23
- * the map's shape leaves that slot open.
22
+ * EC-378: `updateFields` is the edit-flow terminal tool — the model authors
23
+ * per-field changes directly (it has `getDocument` output + the conversation as
24
+ * context); the executor reads the CURRENT document, builds a validated update
25
+ * proposal (`buildUpdateProposal`), and hands it to the review sink. Its target
26
+ * (and `getDocument`'s) may be omitted to mean the document currently open in
27
+ * the editor — resolved via the {@link ChatToolExecutorOptions.currentDocument}
28
+ * seam ({@link resolveDocumentTarget}); the context block the panel injects is
29
+ * ADVISORY, the resolution here is what makes "this document" deterministic.
24
30
  *
25
31
  * The EC-199 planner enum/schema (`plannerSchema` / `parsePlannerAction`) retired
26
32
  * with the hand-rolled orchestrator (EC-334 slice 2).
27
33
  */
28
34
  import { z } from 'zod';
29
35
  import { resolveDocumentTitle } from '@elytracms/core/cms-core';
36
+ import { parseRichTextValue, richTextValueToMarkdownProse } from '@elytracms/core/rich-text';
30
37
  import { applyContentWithShape } from './apply-content.js';
31
38
  import { translateDocument } from './translate-document.js';
39
+ import { buildUpdateProposal } from './update-fields.js';
40
+ import { aiProvenance } from './provenance.js';
32
41
  /** Per-search result cap (protects the wire — results become model context). */
33
42
  export const DEFAULT_SEARCH_LIMIT = 20;
34
43
  /**
@@ -66,10 +75,18 @@ export const chatToolDefinitions = {
66
75
  }),
67
76
  },
68
77
  getDocument: {
69
- description: 'Fetch one full document (values + localized values) by collection and id.',
78
+ description: 'Fetch one full document (values + localized values + block ids) by collection and ' +
79
+ 'id. Omit both to read the document currently open in the editor. Rich-text field ' +
80
+ 'values are returned as markdown strings where faithfully expressible.',
70
81
  inputSchema: z.object({
71
- collection: z.string().describe('Collection id.'),
72
- id: z.string().describe('Document id (from a searchDocuments result).'),
82
+ collection: z
83
+ .string()
84
+ .optional()
85
+ .describe('Collection id. Omit to target the currently open document.'),
86
+ id: z
87
+ .string()
88
+ .optional()
89
+ .describe('Document id (from a searchDocuments result). Omit to target the currently open document.'),
73
90
  }),
74
91
  },
75
92
  createDocument: {
@@ -81,9 +98,52 @@ export const chatToolDefinitions = {
81
98
  instruction: z.string().describe('What to write, refining the user request.'),
82
99
  }),
83
100
  },
101
+ updateFields: {
102
+ description: 'Change specific fields of an EXISTING document as a reviewable proposal — THE ' +
103
+ 'tool for every edit request (change / fix / remove / replace / rewrite wording, ' +
104
+ 'values, or a page-builder element). Call getDocument first to see the current ' +
105
+ 'values (and block ids), then pass ONLY the fields that change. A block prop ' +
106
+ '(e.g. a Hero headline) is addressed as field + blockId + prop. Omit collection ' +
107
+ 'and id to target the document currently open in the editor. A rich-text field ' +
108
+ 'takes the FULL new value as a MARKDOWN string (headings, lists, links, bold/italic, ' +
109
+ 'blockquotes — no images, code, tables, or HTML).',
110
+ inputSchema: z.object({
111
+ collection: z
112
+ .string()
113
+ .optional()
114
+ .describe('Collection id. Omit to target the currently open document.'),
115
+ id: z
116
+ .string()
117
+ .optional()
118
+ .describe('Document id. Omit to target the currently open document.'),
119
+ changes: z
120
+ .array(z.object({
121
+ field: z.string().describe('Field name from the collection schema.'),
122
+ locale: z
123
+ .string()
124
+ .optional()
125
+ .describe("Locale of a LOCALIZED field (defaults to the editor's working locale)."),
126
+ blockId: z
127
+ .string()
128
+ .optional()
129
+ .describe('For a blocks field: the id of the block to change (from getDocument).'),
130
+ prop: z
131
+ .string()
132
+ .optional()
133
+ .describe('With blockId: the component prop to change.'),
134
+ value: z
135
+ .unknown()
136
+ .optional()
137
+ .describe('The new value (a rich-text field takes a markdown string). null clears the field/prop.'),
138
+ }))
139
+ .min(1)
140
+ .describe('The field changes — only what changes, never the whole document.'),
141
+ }),
142
+ },
84
143
  translateDocument: {
85
- description: 'Fill the empty locale values of an EXISTING document (search for it first to ' +
86
- 'get its id). Returns a compact proposal summary.',
144
+ description: 'Fill the EMPTY locale values of an existing document (search for it first to ' +
145
+ 'get its id). Only for translation requests — never for edits (use updateFields ' +
146
+ 'to change existing content). Returns a compact proposal summary.',
87
147
  inputSchema: z.object({
88
148
  collection: z.string().describe('Collection id.'),
89
149
  id: z.string().describe('Document id (from a searchDocuments result).'),
@@ -91,8 +151,39 @@ export const chatToolDefinitions = {
91
151
  }),
92
152
  },
93
153
  };
154
+ /**
155
+ * Resolve a tool call's target document from its (optional) input coordinates
156
+ * and the current-document seam (EC-378). Deterministic:
157
+ *
158
+ * - both given → use them verbatim;
159
+ * - both omitted → the currently open document (error when none is open);
160
+ * - `collection` given with `id` omitted → the open document ONLY when the
161
+ * collections match — a named collection must never silently cross onto a
162
+ * different open document.
163
+ *
164
+ * Pure — tested directly; `getDocument` and `updateFields` share it.
165
+ */
166
+ export function resolveDocumentTarget(input, current) {
167
+ if (input.collection !== undefined && input.id !== undefined) {
168
+ return { ok: true, ref: { collection: input.collection, id: input.id } };
169
+ }
170
+ if (!current) {
171
+ return {
172
+ ok: false,
173
+ errorText: 'No target document — pass collection and id, or ask the user to open the document in the editor.',
174
+ };
175
+ }
176
+ if (input.collection !== undefined && input.collection !== current.collection) {
177
+ return {
178
+ ok: false,
179
+ errorText: `The open document is in collection "${current.collection}", not "${input.collection}" — pass the document id explicitly.`,
180
+ };
181
+ }
182
+ return { ok: true, ref: { collection: current.collection, id: input.id ?? current.id } };
183
+ }
94
184
  export function createChatCandidateStore() {
95
185
  const searched = new Map();
186
+ const searchedAssets = new Map();
96
187
  return {
97
188
  record(collection, results) {
98
189
  const byId = searched.get(collection) ?? new Map();
@@ -100,6 +191,10 @@ export function createChatCandidateStore() {
100
191
  byId.set(result.id, result);
101
192
  searched.set(collection, byId);
102
193
  },
194
+ recordAssets(results) {
195
+ for (const result of results)
196
+ searchedAssets.set(result.id, result);
197
+ },
103
198
  candidatesFor(collection) {
104
199
  const candidates = {};
105
200
  for (const field of collection.fields) {
@@ -112,6 +207,18 @@ export function createChatCandidateStore() {
112
207
  }
113
208
  return candidates;
114
209
  },
210
+ assetCandidatesFor(collection) {
211
+ const candidates = {};
212
+ if (searchedAssets.size === 0)
213
+ return candidates;
214
+ const pool = [...searchedAssets.values()].map((a) => ({ id: a.id, ...(a.title ? { label: a.title } : {}) }));
215
+ for (const field of collection.fields) {
216
+ if (field.type !== 'asset')
217
+ continue;
218
+ candidates[field.name] = pool;
219
+ }
220
+ return candidates;
221
+ },
115
222
  };
116
223
  }
117
224
  function issueSummary(issues) {
@@ -120,6 +227,45 @@ function issueSummary(issues) {
120
227
  .map((issue) => issue.message)
121
228
  .join('; ');
122
229
  }
230
+ /**
231
+ * The MODEL-facing view of a document (EC-379): rich-text field values are
232
+ * projected to markdown strings — but ONLY when the engine's loss-guarded
233
+ * inverse can express them faithfully (subset-only content, round-trip proven).
234
+ * A document carrying embeds, internal `ref` links, or out-of-subset marks keeps
235
+ * its `{ version, doc }` envelope, so nothing the model later echoes back can
236
+ * silently lose structure. Applies to the `getDocument` TOOL OUTPUT only; the
237
+ * proposal/apply paths always read the raw stored document.
238
+ */
239
+ export function projectRichTextAsMarkdown(document, collection) {
240
+ const richTextFields = collection.fields
241
+ .filter((field) => field.type === 'richText')
242
+ .map((field) => field.name);
243
+ if (richTextFields.length === 0)
244
+ return document;
245
+ const mapBucket = (bucket) => {
246
+ let next = bucket;
247
+ for (const name of richTextFields) {
248
+ const value = next[name];
249
+ if (value === undefined || value === null)
250
+ continue;
251
+ const parsed = parseRichTextValue(value);
252
+ if (!parsed.ok)
253
+ continue;
254
+ const markdown = richTextValueToMarkdownProse(parsed.value);
255
+ if (markdown === undefined)
256
+ continue; // not faithfully expressible → keep the envelope
257
+ if (next === bucket)
258
+ next = { ...bucket };
259
+ next[name] = markdown;
260
+ }
261
+ return next;
262
+ };
263
+ const values = mapBucket(document.values);
264
+ const localized = document.localized
265
+ ? Object.fromEntries(Object.entries(document.localized).map(([locale, bucket]) => [locale, mapBucket(bucket)]))
266
+ : undefined;
267
+ return { ...document, values, ...(localized ? { localized } : {}) };
268
+ }
123
269
  /**
124
270
  * Build the client executor map. Every entry validates its input against
125
271
  * {@link chatToolDefinitions}' zod schema (the route validates UIMessages only
@@ -151,17 +297,107 @@ export function createChatToolExecutor(options) {
151
297
  ...(input.query ? { query: input.query } : {}),
152
298
  limit,
153
299
  })).slice(0, limit);
300
+ // EC-381 — leash asset fields to what the model actually searched, exactly as
301
+ // searchDocuments feeds the relation store.
302
+ options.candidates.recordAssets(results);
154
303
  return { ok: true, output: results };
155
304
  }
156
305
  async function getDocument(input) {
157
306
  if (!options.readers.getDocument) {
158
307
  return { ok: false, errorText: 'Reading documents is not available here.' };
159
308
  }
160
- const document = await options.readers.getDocument(input);
309
+ const target = resolveDocumentTarget(input, options.currentDocument?.());
310
+ if (!target.ok)
311
+ return { ok: false, errorText: target.errorText };
312
+ const document = await options.readers.getDocument(target.ref);
161
313
  if (!document) {
162
- return { ok: false, errorText: `Document "${input.collection}/${input.id}" was not found.` };
314
+ return {
315
+ ok: false,
316
+ errorText: `Document "${target.ref.collection}/${target.ref.id}" was not found.`,
317
+ };
163
318
  }
164
- return { ok: true, output: document };
319
+ // EC-379: the model reads (and re-authors) rich text as markdown.
320
+ const collection = collectionById(target.ref.collection);
321
+ return { ok: true, output: collection ? projectRichTextAsMarkdown(document, collection) : document };
322
+ }
323
+ /** EC-378 — the edit-flow terminal tool: current doc + model-authored changes → validated proposal. */
324
+ async function updateFields(toolCallId, input) {
325
+ if (!options.readers.getDocument) {
326
+ return { ok: false, errorText: 'Reading documents is not available here.' };
327
+ }
328
+ const target = resolveDocumentTarget(input, options.currentDocument?.());
329
+ if (!target.ok)
330
+ return { ok: false, errorText: target.errorText };
331
+ const collection = collectionById(target.ref.collection);
332
+ if (!collection) {
333
+ return {
334
+ ok: false,
335
+ errorText: `Unknown collection "${target.ref.collection}" — use a collection id from the catalog.`,
336
+ };
337
+ }
338
+ const document = await options.readers.getDocument(target.ref);
339
+ if (!document) {
340
+ return {
341
+ ok: false,
342
+ errorText: `Document "${target.ref.collection}/${target.ref.id}" was not found.`,
343
+ };
344
+ }
345
+ const result = buildUpdateProposal({
346
+ registry: options.registry,
347
+ collection,
348
+ document,
349
+ changes: input.changes,
350
+ candidates: options.candidates.candidatesFor(collection),
351
+ assetCandidates: options.candidates.assetCandidatesFor(collection),
352
+ ...(options.locale ? { locale: options.locale } : {}),
353
+ ...(options.localeConfig ? { localeConfig: options.localeConfig } : {}),
354
+ ...(options.validateRichTextEmbeds
355
+ ? { validateRichTextEmbeds: options.validateRichTextEmbeds }
356
+ : {}),
357
+ ...(options.validateComposition ? { validateComposition: options.validateComposition } : {}),
358
+ ...(options.store ? { store: options.store } : {}),
359
+ });
360
+ if (!result.ok) {
361
+ return {
362
+ ok: false,
363
+ errorText: `The proposed changes did not pass validation: ${issueSummary(result.issues)}`,
364
+ };
365
+ }
366
+ if (result.kind === 'no-change') {
367
+ return {
368
+ ok: true,
369
+ output: {
370
+ status: 'no-change',
371
+ note: 'The requested change matches the current values — there is nothing to update.',
372
+ },
373
+ };
374
+ }
375
+ options.onProposal(toolCallId, {
376
+ origin: 'update',
377
+ collection: collection.id,
378
+ documentId: document.id,
379
+ patch: result.patch,
380
+ provenance: aiProvenance(options.provider),
381
+ attempts: 1,
382
+ issues: result.issues,
383
+ before: result.before,
384
+ });
385
+ // Compact summary only — the full patch goes to the review UI, not the model.
386
+ const titleValues = {
387
+ ...result.patch.values,
388
+ ...(options.locale ? result.patch.localized?.[options.locale] : {}),
389
+ };
390
+ return {
391
+ ok: true,
392
+ output: {
393
+ status: 'proposed',
394
+ collection: collection.id,
395
+ documentId: document.id,
396
+ title: resolveDocumentTitle(collection, titleValues, document.id),
397
+ changedFields: result.changedFields,
398
+ note: 'Update proposal created; the editor reviews and applies it in the studio. Summarize the change briefly — do not repeat the content.',
399
+ },
400
+ };
165
401
  }
166
402
  async function createDocument(toolCallId, input) {
167
403
  const collection = collectionById(input.collection);
@@ -179,6 +415,8 @@ export function createChatToolExecutor(options) {
179
415
  ...(knowledge ? { knowledge } : {}),
180
416
  ...(options.components ? { components: options.components } : {}),
181
417
  candidates: options.candidates.candidatesFor(collection),
418
+ // EC-381 — asset/image fields are leashed to searched library assets.
419
+ assetCandidates: options.candidates.assetCandidatesFor(collection),
182
420
  ...(options.locale ? { locale: options.locale } : {}),
183
421
  ...(options.validateRichTextEmbeds
184
422
  ? { validateRichTextEmbeds: options.validateRichTextEmbeds }
@@ -301,9 +539,10 @@ export function createChatToolExecutor(options) {
301
539
  return await getDocument(parsed.data);
302
540
  case 'createDocument':
303
541
  return await createDocument(call.toolCallId, parsed.data);
542
+ case 'updateFields':
543
+ return await updateFields(call.toolCallId, parsed.data);
304
544
  case 'translateDocument':
305
545
  return await translate(call.toolCallId, parsed.data);
306
- // EC-378: case 'updateFields' lands here (leashed field-update proposals).
307
546
  default:
308
547
  return { ok: false, errorText: `Unknown tool "${call.toolName}".` };
309
548
  }