@elytracms/studio 0.0.99 → 0.0.101

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 (133) hide show
  1. package/convex/analytics-cost.test.ts +1 -1
  2. package/convex/cms.ts +29 -2
  3. package/convex/records-column-transform.convex.test.ts +5 -3
  4. package/convex/records-one-write.convex.test.ts +218 -0
  5. package/convex/redirect-migration.test.ts +1 -1
  6. package/convex/redirects.ts +2 -2
  7. package/convex/shared-capture.convex.test.ts +165 -0
  8. package/convex/version-kind-stamping.test.ts +118 -0
  9. package/dist/components/ai/ai-chat-panel-lazy.js +7 -2
  10. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
  11. package/dist/components/ai/proposal-card.js +2 -1
  12. package/dist/components/ai/proposal-card.js.map +1 -1
  13. package/dist/components/ai/proposal-unfilled.d.ts +14 -0
  14. package/dist/components/ai/proposal-unfilled.js +33 -0
  15. package/dist/components/ai/proposal-unfilled.js.map +1 -0
  16. package/dist/components/ai/translate-document-action.js +8 -6
  17. package/dist/components/ai/translate-document-action.js.map +1 -1
  18. package/dist/components/app-views/studio-config.d.ts +12 -12
  19. package/dist/components/app-views/studio-config.js.map +1 -1
  20. package/dist/components/builder/builder-store.d.ts +37 -1
  21. package/dist/components/builder/builder-store.js +21 -6
  22. package/dist/components/builder/builder-store.js.map +1 -1
  23. package/dist/components/builder/canvas-chrome-css.d.ts +1 -1
  24. package/dist/components/builder/canvas-chrome-css.js +1 -1
  25. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  26. package/dist/components/builder/canvas-chrome.d.ts +1 -1
  27. package/dist/components/builder/canvas-chrome.js +1 -1
  28. package/dist/components/builder/canvas-chrome.js.map +1 -1
  29. package/dist/components/builder/canvas-rebase.d.ts +4 -4
  30. package/dist/components/builder/canvas-rebase.js +3 -2
  31. package/dist/components/builder/canvas-rebase.js.map +1 -1
  32. package/dist/components/builder/canvas-render-context.d.ts +1 -1
  33. package/dist/components/builder/canvas-render-context.js +1 -1
  34. package/dist/components/builder/canvas-render-context.js.map +1 -1
  35. package/dist/components/builder/composition-field-editor-lazy.js +2 -1
  36. package/dist/components/builder/composition-field-editor-lazy.js.map +1 -1
  37. package/dist/components/builder/reuse-context.d.ts +8 -2
  38. package/dist/components/builder/reuse-context.js +62 -7
  39. package/dist/components/builder/reuse-context.js.map +1 -1
  40. package/dist/components/builder/reuse.d.ts +106 -70
  41. package/dist/components/builder/reuse.js +125 -108
  42. package/dist/components/builder/reuse.js.map +1 -1
  43. package/dist/components/cms/cms-store.d.ts +9 -1
  44. package/dist/components/cms/cms-store.js +1 -0
  45. package/dist/components/cms/cms-store.js.map +1 -1
  46. package/dist/components/cms/detail-editor.js +13 -13
  47. package/dist/components/cms/detail-editor.js.map +1 -1
  48. package/dist/components/cms/prop-fields.js +1 -1
  49. package/dist/components/cms/prop-fields.js.map +1 -1
  50. package/dist/components/cms/rich-text/lazy.js +2 -1
  51. package/dist/components/cms/rich-text/lazy.js.map +1 -1
  52. package/dist/components/cms/rich-text/toolbar-state.d.ts +1 -1
  53. package/dist/components/cms/rich-text/toolbar-state.js +1 -1
  54. package/dist/components/cms/rich-text/toolbar-state.js.map +1 -1
  55. package/dist/components/cms/use-field-focus.js +3 -3
  56. package/dist/components/cms/use-field-focus.js.map +1 -1
  57. package/dist/components/cms/version-compare-lazy.js +2 -1
  58. package/dist/components/cms/version-compare-lazy.js.map +1 -1
  59. package/dist/components/components/components-index.js +1 -1
  60. package/dist/components/components/components-index.js.map +1 -1
  61. package/dist/components/deployment-setup-gate.d.ts +1 -1
  62. package/dist/components/deployment-setup-gate.js +1 -1
  63. package/dist/components/deployment-setup-gate.js.map +1 -1
  64. package/dist/components/load-failure.d.ts +62 -0
  65. package/dist/components/load-failure.js +110 -0
  66. package/dist/components/load-failure.js.map +1 -0
  67. package/dist/components/notifications/shell-findings.js +3 -3
  68. package/dist/components/notifications/shell-findings.js.map +1 -1
  69. package/dist/components/records/record-rail.js +16 -5
  70. package/dist/components/records/record-rail.js.map +1 -1
  71. package/dist/customization.d.ts +3 -3
  72. package/dist/customization.js.map +1 -1
  73. package/dist/lib/ai/apply-content.js +99 -28
  74. package/dist/lib/ai/apply-content.js.map +1 -1
  75. package/dist/lib/ai/chat-context.d.ts +73 -7
  76. package/dist/lib/ai/chat-context.js +133 -14
  77. package/dist/lib/ai/chat-context.js.map +1 -1
  78. package/dist/lib/ai/chat-state.d.ts +7 -1
  79. package/dist/lib/ai/chat-state.js.map +1 -1
  80. package/dist/lib/ai/constraints.d.ts +5 -0
  81. package/dist/lib/ai/constraints.js +21 -6
  82. package/dist/lib/ai/constraints.js.map +1 -1
  83. package/dist/lib/ai/fake-provider.d.ts +76 -26
  84. package/dist/lib/ai/fake-provider.js +210 -66
  85. package/dist/lib/ai/fake-provider.js.map +1 -1
  86. package/dist/lib/ai/index.d.ts +1 -0
  87. package/dist/lib/ai/index.js +1 -0
  88. package/dist/lib/ai/index.js.map +1 -1
  89. package/dist/lib/ai/keyless-assistant.d.ts +37 -0
  90. package/dist/lib/ai/keyless-assistant.js +193 -0
  91. package/dist/lib/ai/keyless-assistant.js.map +1 -0
  92. package/dist/lib/ai/shape.d.ts +25 -2
  93. package/dist/lib/ai/shape.js +58 -7
  94. package/dist/lib/ai/shape.js.map +1 -1
  95. package/dist/lib/ai/tools.d.ts +11 -1
  96. package/dist/lib/ai/tools.js +51 -6
  97. package/dist/lib/ai/tools.js.map +1 -1
  98. package/dist/lib/ai/translate-document.js +6 -5
  99. package/dist/lib/ai/translate-document.js.map +1 -1
  100. package/dist/lib/ai/types.d.ts +22 -7
  101. package/dist/lib/ai/types.js.map +1 -1
  102. package/dist/lib/ai/update-fields.js +9 -6
  103. package/dist/lib/ai/update-fields.js.map +1 -1
  104. package/dist/lib/ai/writer-form.d.ts +11 -3
  105. package/dist/lib/ai/writer-form.js +105 -18
  106. package/dist/lib/ai/writer-form.js.map +1 -1
  107. package/dist/lib/auth-errors.d.ts +1 -1
  108. package/dist/lib/auth-errors.js +1 -1
  109. package/dist/lib/auth-errors.js.map +1 -1
  110. package/dist/lib/engine-version.d.ts +1 -1
  111. package/dist/lib/engine-version.js +1 -1
  112. package/dist/lib/engine-version.js.map +1 -1
  113. package/dist/lib/i18n/messages.de.d.ts +1 -1
  114. package/dist/lib/i18n/messages.de.js +24 -7
  115. package/dist/lib/i18n/messages.de.js.map +1 -1
  116. package/dist/lib/i18n/messages.en.d.ts +19 -3
  117. package/dist/lib/i18n/messages.en.js +30 -9
  118. package/dist/lib/i18n/messages.en.js.map +1 -1
  119. package/dist/lib/operations/cms-persistence.d.ts +18 -0
  120. package/dist/lib/operations/cms-persistence.js +56 -15
  121. package/dist/lib/operations/cms-persistence.js.map +1 -1
  122. package/dist/lib/persistence/convex-adapter.d.ts +2 -0
  123. package/dist/lib/persistence/convex-adapter.js +67 -2
  124. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  125. package/dist/lib/workspace/index.d.ts +3 -3
  126. package/dist/lib/workspace/index.js +3 -3
  127. package/dist/lib/workspace/index.js.map +1 -1
  128. package/dist/router/navigate-once.d.ts +1 -1
  129. package/dist/router/navigate-once.js.map +1 -1
  130. package/dist/router/root.d.ts +3 -2
  131. package/dist/router/root.js +25 -4
  132. package/dist/router/root.js.map +1 -1
  133. package/package.json +3 -3
@@ -0,0 +1,193 @@
1
+ /**
2
+ * **The keyless assistant** (EC-715): the ONE deterministic, offline provider a project runs
3
+ * when no model key is set — so a fresh scaffold's assistant works on WHATEVER schema it has,
4
+ * not on the collections one starter kit happens to declare.
5
+ *
6
+ * - the conversation: {@link createFakeCreateFlowChatModel}, which reads the project's catalog
7
+ * and the open document from the system prompt (the route has no data access; that prose is
8
+ * the only channel) and derives every answer from the tool results;
9
+ * - the draft: {@link keylessDraft}, the structured responder `applyContentWithShape` calls
10
+ * with the create's target shape — it fills what the shape OFFERS, at every depth (nested
11
+ * objects included), and nothing else, so it can never name a hallucinated id, a read-only
12
+ * field (EC-716) or the slug the engine derives; a slug role the shape offers (marked with
13
+ * THE slug format) is the topic as a URL segment, never prose;
14
+ * - the translation: {@link keylessTranslate}, a visible pseudo-translation (`[de] …`) that
15
+ * keeps every placeholder token, so the engine rebuilds rich text exactly.
16
+ *
17
+ * A scaffold's AI handler and the demo's are one call: `createKeylessProvider()`. Swap in a
18
+ * real key for real output.
19
+ */
20
+ import { slugifySegment } from '@elytracms/core/cms-core';
21
+ import { createFakeCreateFlowChatModel, createFakeProvider } from './fake-provider.js';
22
+ import { mapEncodedSegments } from './rich-text-translate.js';
23
+ import { SLUG_SEGMENT_DESCRIPTION } from './shape.js';
24
+ import { TRANSLATE_SCHEMA_NAME } from './translate-document.js';
25
+ /** A leading imperative the topic leaves out ("Create a new …", "Please write …"). */
26
+ const IMPERATIVE = /^(?:please\s+)?(?:create|write|draft|add|make|compose)\s+(?:me\s+)?(?:(?:a|an|the|some|one|another)\s+)?(?:new\s+)?/i;
27
+ /**
28
+ * What a keyless draft writes about: the instruction's `about …` clause, else the
29
+ * instruction without its leading imperative — read off the authoring prompt
30
+ * (`Instruction:\n…`, `applyContentWithShape`'s first part).
31
+ */
32
+ export function keylessTopic(prompt) {
33
+ const instruction = /Instruction:\n([\s\S]*?)(?:\n\n|$)/.exec(prompt)?.[1]?.trim() ?? prompt.trim();
34
+ const about = /\babout\s+(.+)/i.exec(instruction)?.[1] ?? instruction.replace(IMPERATIVE, '');
35
+ return about.replace(/["'.?!\s]+$/g, '').trim() || 'your new document';
36
+ }
37
+ function titleCase(text) {
38
+ return text.replace(/\b\w/g, (c) => c.toUpperCase());
39
+ }
40
+ /** The topic as a URL segment — THE default slug rule (core's `slugifySegment`), never a second one. */
41
+ function slugOf(topic) {
42
+ return slugifySegment(topic) || 'draft';
43
+ }
44
+ /** An identifier-like name (`layoutId`, `key`): no prose fits it — left empty unless a pattern says how. */
45
+ const IDENTIFIER = /(?:^(?:id|key)$|[a-z0-9](?:Id|Key)$)/;
46
+ function num(value) {
47
+ return typeof value === 'number' ? value : undefined;
48
+ }
49
+ function propertiesOf(schema) {
50
+ const properties = schema.properties;
51
+ return typeof properties === 'object' && properties !== null && !Array.isArray(properties)
52
+ ? properties
53
+ : {};
54
+ }
55
+ /** A string within the schema's length bounds. */
56
+ function bounded(text, schema) {
57
+ const max = num(schema.maxLength);
58
+ const min = num(schema.minLength) ?? 0;
59
+ let out = text;
60
+ while (out.length < min)
61
+ out = `${out} More details follow.`;
62
+ return max !== undefined ? out.slice(0, max).trimEnd() : out;
63
+ }
64
+ /**
65
+ * A plain string position: by its role, its format, its pattern, then its name. `undefined` —
66
+ * left empty (and so listed on the proposal).
67
+ */
68
+ function draftString(name, schema, topic) {
69
+ // EC-715: the collection's AUTHORED slug role (the shape marks it) — a URL segment, never prose.
70
+ if (typeof schema.description === 'string' && schema.description.startsWith(SLUG_SEGMENT_DESCRIPTION)) {
71
+ return slugOf(topic);
72
+ }
73
+ const format = typeof schema.format === 'string' ? schema.format : undefined;
74
+ if (format === 'date-time' || format === 'date' || format === 'time')
75
+ return undefined;
76
+ if (format === 'email' || /e-?mail/i.test(name))
77
+ return 'hello@example.com';
78
+ if (format === 'uri' || format === 'url' || /^(url|href|link|website)$/i.test(name))
79
+ return 'https://example.com/';
80
+ if (typeof schema.pattern === 'string') {
81
+ const candidate = slugOf(topic);
82
+ try {
83
+ return new RegExp(schema.pattern).test(candidate) ? candidate : undefined;
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ if (IDENTIFIER.test(name))
90
+ return undefined;
91
+ if (/title|name|heading|headline|label/i.test(name))
92
+ return bounded(titleCase(topic), schema);
93
+ return bounded(`An overview of ${topic}.`, schema);
94
+ }
95
+ /** The value one offered position gets — recursing into objects; `undefined` leaves it empty. */
96
+ function draftValue(name, schema, topic) {
97
+ if (schema.const !== undefined)
98
+ return schema.const;
99
+ if (Array.isArray(schema.enum) && schema.enum.length > 0)
100
+ return schema.enum[0];
101
+ if (typeof schema.description === 'string' && schema.description.startsWith('Rich text')) {
102
+ // Authored as MARKDOWN (EC-379); the engine converts it to the stored value.
103
+ return `## ${titleCase(topic)}\n\nA draft about **${topic}**, written by the keyless assistant for you to review and edit.`;
104
+ }
105
+ const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
106
+ switch (type) {
107
+ case 'object':
108
+ return draftObject(schema, topic);
109
+ case 'array': {
110
+ const items = schema.items;
111
+ if (typeof items !== 'object' || items === null || Array.isArray(items))
112
+ return undefined;
113
+ const item = items;
114
+ // A relation list offers its searched ids as an enum: take the first.
115
+ if (Array.isArray(item.enum) && item.enum.length > 0)
116
+ return [item.enum[0]];
117
+ if ((num(schema.minItems) ?? 0) > 0) {
118
+ const one = draftValue(name, item, topic);
119
+ return one === undefined ? undefined : [one];
120
+ }
121
+ return undefined;
122
+ }
123
+ case 'number':
124
+ case 'integer': {
125
+ const minimum = num(schema.minimum) ?? (num(schema.exclusiveMinimum) !== undefined ? num(schema.exclusiveMinimum) + 1 : 0);
126
+ return minimum;
127
+ }
128
+ case 'boolean':
129
+ return false;
130
+ case 'string':
131
+ return draftString(name, schema, topic);
132
+ default:
133
+ return undefined;
134
+ }
135
+ }
136
+ function draftObject(schema, topic) {
137
+ const out = {};
138
+ for (const [key, sub] of Object.entries(propertiesOf(schema))) {
139
+ const value = draftValue(key, sub, topic);
140
+ if (value !== undefined)
141
+ out[key] = value;
142
+ }
143
+ return out;
144
+ }
145
+ /**
146
+ * The keyless DRAFT (EC-715): a deterministic document for a create's target shape. It fills
147
+ * every position the shape offers — text from the instruction's topic, the first offered id
148
+ * of a relation or asset enum, nested objects recursively — and leaves a date, an unbounded
149
+ * list and anything unrecognised empty (the engine lists those on the proposal).
150
+ */
151
+ export function keylessDraft(request) {
152
+ return draftObject(request.schema, keylessTopic(request.prompt));
153
+ }
154
+ /**
155
+ * The keyless TRANSLATION: every human-readable segment prefixed with the target locale
156
+ * (`[de] …`), placeholder tokens kept verbatim — visible, so a live check can see which locale
157
+ * each fill came from.
158
+ */
159
+ export function keylessTranslate(request) {
160
+ const locale = /locale code "([^"]+)"/.exec(request.prompt)?.[1] ?? 'xx';
161
+ const json = /Units:\n(\[[\s\S]*\])\s*$/.exec(request.prompt)?.[1] ?? '[]';
162
+ let units;
163
+ try {
164
+ units = JSON.parse(json);
165
+ }
166
+ catch {
167
+ units = [];
168
+ }
169
+ return {
170
+ units: units.map((unit) => ({
171
+ id: unit.id,
172
+ text: mapEncodedSegments(unit.text, (segment) => (segment.trim().length > 0 ? `[${locale}] ${segment}` : segment)),
173
+ })),
174
+ };
175
+ }
176
+ /** The keyless structured responder: a translation batch, else a create's draft. */
177
+ export function keylessRespond(request) {
178
+ return request.schemaName === TRANSLATE_SCHEMA_NAME ? keylessTranslate(request) : keylessDraft(request);
179
+ }
180
+ /**
181
+ * THE keyless provider (EC-715): the project-reading scripted conversation plus the draft and
182
+ * translation responders, as one `AiProvider`. A host's AI handler falls back to it when no
183
+ * key is set.
184
+ */
185
+ export function createKeylessProvider(options = {}) {
186
+ return createFakeProvider({
187
+ ...(options.id !== undefined ? { id: options.id } : {}),
188
+ ...(options.model !== undefined ? { model: options.model } : {}),
189
+ respond: keylessRespond,
190
+ chatModel: createFakeCreateFlowChatModel(options.searchAssets !== undefined ? { searchAssets: options.searchAssets } : {}),
191
+ });
192
+ }
193
+ //# sourceMappingURL=keyless-assistant.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyless-assistant.js","sourceRoot":"","sources":["../../../src/lib/ai/keyless-assistant.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * **The keyless assistant** (EC-715): the ONE deterministic, offline provider a project runs\n * when no model key is set — so a fresh scaffold's assistant works on WHATEVER schema it has,\n * not on the collections one starter kit happens to declare.\n *\n * - the conversation: {@link createFakeCreateFlowChatModel}, which reads the project's catalog\n * and the open document from the system prompt (the route has no data access; that prose is\n * the only channel) and derives every answer from the tool results;\n * - the draft: {@link keylessDraft}, the structured responder `applyContentWithShape` calls\n * with the create's target shape — it fills what the shape OFFERS, at every depth (nested\n * objects included), and nothing else, so it can never name a hallucinated id, a read-only\n * field (EC-716) or the slug the engine derives; a slug role the shape offers (marked with\n * THE slug format) is the topic as a URL segment, never prose;\n * - the translation: {@link keylessTranslate}, a visible pseudo-translation (`[de] …`) that\n * keeps every placeholder token, so the engine rebuilds rich text exactly.\n *\n * A scaffold's AI handler and the demo's are one call: `createKeylessProvider()`. Swap in a\n * real key for real output.\n */\nimport { slugifySegment } from '@elytracms/core/cms-core'\nimport type { JsonSchema } from '@elytracms/core/cms-core'\nimport { createFakeCreateFlowChatModel, createFakeProvider } from './fake-provider'\nimport type { FakeProvider } from './fake-provider'\nimport { mapEncodedSegments } from './rich-text-translate'\nimport { SLUG_SEGMENT_DESCRIPTION } from './shape'\nimport { TRANSLATE_SCHEMA_NAME } from './translate-document'\nimport type { GenerateStructuredRequest } from './types'\n\n/** A leading imperative the topic leaves out (\"Create a new …\", \"Please write …\"). */\nconst IMPERATIVE = /^(?:please\\s+)?(?:create|write|draft|add|make|compose)\\s+(?:me\\s+)?(?:(?:a|an|the|some|one|another)\\s+)?(?:new\\s+)?/i\n\n/**\n * What a keyless draft writes about: the instruction's `about …` clause, else the\n * instruction without its leading imperative — read off the authoring prompt\n * (`Instruction:\\n…`, `applyContentWithShape`'s first part).\n */\nexport function keylessTopic(prompt: string): string {\n const instruction = /Instruction:\\n([\\s\\S]*?)(?:\\n\\n|$)/.exec(prompt)?.[1]?.trim() ?? prompt.trim()\n const about = /\\babout\\s+(.+)/i.exec(instruction)?.[1] ?? instruction.replace(IMPERATIVE, '')\n return about.replace(/[\"'.?!\\s]+$/g, '').trim() || 'your new document'\n}\n\nfunction titleCase(text: string): string {\n return text.replace(/\\b\\w/g, (c) => c.toUpperCase())\n}\n\n/** The topic as a URL segment — THE default slug rule (core's `slugifySegment`), never a second one. */\nfunction slugOf(topic: string): string {\n return slugifySegment(topic) || 'draft'\n}\n\n/** An identifier-like name (`layoutId`, `key`): no prose fits it — left empty unless a pattern says how. */\nconst IDENTIFIER = /(?:^(?:id|key)$|[a-z0-9](?:Id|Key)$)/\n\nfunction num(value: unknown): number | undefined {\n return typeof value === 'number' ? value : undefined\n}\n\nfunction propertiesOf(schema: JsonSchema): Record<string, JsonSchema> {\n const properties = schema.properties\n return typeof properties === 'object' && properties !== null && !Array.isArray(properties)\n ? (properties as Record<string, JsonSchema>)\n : {}\n}\n\n/** A string within the schema's length bounds. */\nfunction bounded(text: string, schema: JsonSchema): string {\n const max = num(schema.maxLength)\n const min = num(schema.minLength) ?? 0\n let out = text\n while (out.length < min) out = `${out} More details follow.`\n return max !== undefined ? out.slice(0, max).trimEnd() : out\n}\n\n/**\n * A plain string position: by its role, its format, its pattern, then its name. `undefined` —\n * left empty (and so listed on the proposal).\n */\nfunction draftString(name: string, schema: JsonSchema, topic: string): string | undefined {\n // EC-715: the collection's AUTHORED slug role (the shape marks it) — a URL segment, never prose.\n if (typeof schema.description === 'string' && schema.description.startsWith(SLUG_SEGMENT_DESCRIPTION)) {\n return slugOf(topic)\n }\n const format = typeof schema.format === 'string' ? schema.format : undefined\n if (format === 'date-time' || format === 'date' || format === 'time') return undefined\n if (format === 'email' || /e-?mail/i.test(name)) return 'hello@example.com'\n if (format === 'uri' || format === 'url' || /^(url|href|link|website)$/i.test(name)) return 'https://example.com/'\n if (typeof schema.pattern === 'string') {\n const candidate = slugOf(topic)\n try {\n return new RegExp(schema.pattern).test(candidate) ? candidate : undefined\n } catch {\n return undefined\n }\n }\n if (IDENTIFIER.test(name)) return undefined\n if (/title|name|heading|headline|label/i.test(name)) return bounded(titleCase(topic), schema)\n return bounded(`An overview of ${topic}.`, schema)\n}\n\n/** The value one offered position gets — recursing into objects; `undefined` leaves it empty. */\nfunction draftValue(name: string, schema: JsonSchema, topic: string): unknown {\n if (schema.const !== undefined) return schema.const\n if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0]\n if (typeof schema.description === 'string' && schema.description.startsWith('Rich text')) {\n // Authored as MARKDOWN (EC-379); the engine converts it to the stored value.\n return `## ${titleCase(topic)}\\n\\nA draft about **${topic}**, written by the keyless assistant for you to review and edit.`\n }\n const type = Array.isArray(schema.type) ? schema.type[0] : schema.type\n switch (type) {\n case 'object':\n return draftObject(schema, topic)\n case 'array': {\n const items = schema.items\n if (typeof items !== 'object' || items === null || Array.isArray(items)) return undefined\n const item = items as JsonSchema\n // A relation list offers its searched ids as an enum: take the first.\n if (Array.isArray(item.enum) && item.enum.length > 0) return [item.enum[0]]\n if ((num(schema.minItems) ?? 0) > 0) {\n const one = draftValue(name, item, topic)\n return one === undefined ? undefined : [one]\n }\n return undefined\n }\n case 'number':\n case 'integer': {\n const minimum = num(schema.minimum) ?? (num(schema.exclusiveMinimum) !== undefined ? num(schema.exclusiveMinimum)! + 1 : 0)\n return minimum\n }\n case 'boolean':\n return false\n case 'string':\n return draftString(name, schema, topic)\n default:\n return undefined\n }\n}\n\nfunction draftObject(schema: JsonSchema, topic: string): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n for (const [key, sub] of Object.entries(propertiesOf(schema))) {\n const value = draftValue(key, sub, topic)\n if (value !== undefined) out[key] = value\n }\n return out\n}\n\n/**\n * The keyless DRAFT (EC-715): a deterministic document for a create's target shape. It fills\n * every position the shape offers — text from the instruction's topic, the first offered id\n * of a relation or asset enum, nested objects recursively — and leaves a date, an unbounded\n * list and anything unrecognised empty (the engine lists those on the proposal).\n */\nexport function keylessDraft(request: GenerateStructuredRequest): Record<string, unknown> {\n return draftObject(request.schema, keylessTopic(request.prompt))\n}\n\n/**\n * The keyless TRANSLATION: every human-readable segment prefixed with the target locale\n * (`[de] …`), placeholder tokens kept verbatim — visible, so a live check can see which locale\n * each fill came from.\n */\nexport function keylessTranslate(request: GenerateStructuredRequest): unknown {\n const locale = /locale code \"([^\"]+)\"/.exec(request.prompt)?.[1] ?? 'xx'\n const json = /Units:\\n(\\[[\\s\\S]*\\])\\s*$/.exec(request.prompt)?.[1] ?? '[]'\n let units: { id: string; text: string }[]\n try {\n units = JSON.parse(json) as { id: string; text: string }[]\n } catch {\n units = []\n }\n return {\n units: units.map((unit) => ({\n id: unit.id,\n text: mapEncodedSegments(unit.text, (segment) => (segment.trim().length > 0 ? `[${locale}] ${segment}` : segment)),\n })),\n }\n}\n\n/** The keyless structured responder: a translation batch, else a create's draft. */\nexport function keylessRespond(request: GenerateStructuredRequest): unknown {\n return request.schemaName === TRANSLATE_SCHEMA_NAME ? keylessTranslate(request) : keylessDraft(request)\n}\n\nexport interface KeylessProviderOptions {\n /** Provider id recorded in provenance (default `fake`). */\n id?: string\n /** Model id recorded in provenance (default `fake-model`). */\n model?: string\n /** Whether a create lists the media library first (default: when the collection has an asset field). */\n searchAssets?: boolean\n}\n\n/**\n * THE keyless provider (EC-715): the project-reading scripted conversation plus the draft and\n * translation responders, as one `AiProvider`. A host's AI handler falls back to it when no\n * key is set.\n */\nexport function createKeylessProvider(options: KeylessProviderOptions = {}): FakeProvider {\n return createFakeProvider({\n ...(options.id !== undefined ? { id: options.id } : {}),\n ...(options.model !== undefined ? { model: options.model } : {}),\n respond: keylessRespond,\n chatModel: createFakeCreateFlowChatModel(\n options.searchAssets !== undefined ? { searchAssets: options.searchAssets } : {},\n ),\n })\n}\n"]}
@@ -1,5 +1,26 @@
1
- import type { CollectionDef, JsonSchema } from '@elytracms/core/cms-core';
1
+ import type { CollectionDef, FieldDef, JsonSchema } from '@elytracms/core/cms-core';
2
2
  import type { WriterLeash } from './types.js';
3
+ /**
4
+ * The collection's SLUG ROLE when a writer never authors it (EC-715): a text `slugField`
5
+ * (EC-590) that has a source — its `slug.source`, else the title role — and is not locked.
6
+ * The create path derives its value with core's `autoDeriveSlug` (THE derivation the slug
7
+ * control and translate read) instead of asking the model, which used to fill it with prose
8
+ * wherever no pattern stopped it. `slug.source: false` (EC-691) opts out: the model writes it.
9
+ */
10
+ export declare function derivedSlugField(collection: CollectionDef): FieldDef | undefined;
11
+ /**
12
+ * How the writer form of an AUTHORED slug role begins (EC-715): the slug role a writer does
13
+ * write — `slug.source: false`, or no source to derive from. A reader of the shape (the keyless
14
+ * draft, like a real model) recognises the role by it; {@link slugSegmentDescription} says the rest.
15
+ */
16
+ export declare const SLUG_SEGMENT_DESCRIPTION = "URL segment";
17
+ /**
18
+ * The format an authored slug role takes, stated by THE slug rule itself (core's `applySlugify`:
19
+ * the field's own `slug.slugify`, else the default `slugifySegment`) — an example it computes,
20
+ * never a second regex restating it, and never prose: a value is a slug when the rule leaves it
21
+ * as it is.
22
+ */
23
+ export declare function slugSegmentDescription(field: FieldDef): string;
3
24
  /** A top-level relation field's resolved candidate set (the enum the model chose from). */
4
25
  export interface TargetRelationField {
5
26
  fieldName: string;
@@ -20,10 +41,12 @@ export interface TargetAssetField {
20
41
  export interface TargetShape {
21
42
  /** The JSON Schema the structured output must satisfy. */
22
43
  schema: JsonSchema;
23
- /** Field names removed by `ai.exclude` (untouchable). */
44
+ /** Field names removed as writer-locked — `ai.exclude` or `form.readOnly` (EC-716). */
24
45
  excluded: string[];
25
46
  /** Field names omitted because a writer never writes them — a composition (4c). */
26
47
  compositions: string[];
48
+ /** Field names omitted because the engine derives them — the slug role (EC-715). */
49
+ derived: string[];
27
50
  /** Field names whose values route under `localized[locale]`. */
28
51
  localizedFields: string[];
29
52
  /** Top-level relation fields with their candidate enums. */
@@ -5,8 +5,11 @@
5
5
  * cms-core) rather than writing a new one, then applies the leashes the AI engine
6
6
  * adds on top:
7
7
  *
8
- * - `ai.exclude` fields are removed entirely (the model never sees them → cannot
9
- * write them).
8
+ * - writer-locked fields are removed entirely (EC-716, owner ruling "Yes, honour
9
+ * readOnly"): `ai.exclude` and `form.readOnly`, at EVERY depth — core's one predicate
10
+ * (`isWriterLocked`), so a read-only sub-field of an object is as absent as a
11
+ * read-only top-level field. The model cannot name a position the editor's own form
12
+ * cannot change.
10
13
  * - relation ids become an ENUM over the searched candidates of the relation's
11
14
  * target (or the position is omitted when none were searched) — so a hallucinated
12
15
  * relation id is not representable as valid structured output.
@@ -21,6 +24,10 @@
21
24
  * value at proposal-build time (`writer-form.ts`). The field's `allow` config leashes
22
25
  * component EMBEDS only — markdown cannot express an embed, so the leash is
23
26
  * satisfied by construction (and still enforced downstream by `validateRichTextEmbeds`).
27
+ * - the slug role is DERIVED, never authored (EC-715, {@link derivedSlugField}): it is
28
+ * omitted, and the create path fills it from the title with core's `autoDeriveSlug`; a
29
+ * slug role a writer DOES author (`slug.source: false`, or nothing to derive from) is
30
+ * offered in THE slug format ({@link slugSegmentDescription}), never as a free string;
24
31
  * - a composition (`blocks`) has NO writer form (ratified 4c, EC-593): it is omitted,
25
32
  * and the schema's own description says so — the omission is visible, never a field
26
33
  * the model simply never hears of.
@@ -53,9 +60,43 @@
53
60
  * id, a relation id, a markdown string — so an asset, relation or rich-text SUBFIELD of
54
61
  * an `object` is told the same form as a top-level one.
55
62
  */
56
- import { collectionJsonSchema, resolveLabel } from '@elytracms/core/cms-core';
63
+ import { applySlugify, collectionJsonSchema, isWriterLocked, resolveLabel, slugFieldOf, titleFieldOf, } from '@elytracms/core/cms-core';
57
64
  import { constraintSentence, toModelFacingSchema, writerFieldSchema } from './constraints.js';
58
65
  import { NO_SEARCHED_IDS } from './leash.js';
66
+ /**
67
+ * The collection's SLUG ROLE when a writer never authors it (EC-715): a text `slugField`
68
+ * (EC-590) that has a source — its `slug.source`, else the title role — and is not locked.
69
+ * The create path derives its value with core's `autoDeriveSlug` (THE derivation the slug
70
+ * control and translate read) instead of asking the model, which used to fill it with prose
71
+ * wherever no pattern stopped it. `slug.source: false` (EC-691) opts out: the model writes it.
72
+ */
73
+ export function derivedSlugField(collection) {
74
+ const field = slugFieldOf(collection);
75
+ if (!field || field.type !== 'text' || isWriterLocked(field))
76
+ return undefined;
77
+ const source = field.slug?.source;
78
+ if (source === false)
79
+ return undefined;
80
+ if (source !== undefined)
81
+ return field;
82
+ return typeof collection.titleField === 'function' || titleFieldOf(collection) !== undefined ? field : undefined;
83
+ }
84
+ /**
85
+ * How the writer form of an AUTHORED slug role begins (EC-715): the slug role a writer does
86
+ * write — `slug.source: false`, or no source to derive from. A reader of the shape (the keyless
87
+ * draft, like a real model) recognises the role by it; {@link slugSegmentDescription} says the rest.
88
+ */
89
+ export const SLUG_SEGMENT_DESCRIPTION = 'URL segment';
90
+ /**
91
+ * The format an authored slug role takes, stated by THE slug rule itself (core's `applySlugify`:
92
+ * the field's own `slug.slugify`, else the default `slugifySegment`) — an example it computes,
93
+ * never a second regex restating it, and never prose: a value is a slug when the rule leaves it
94
+ * as it is.
95
+ */
96
+ export function slugSegmentDescription(field) {
97
+ const example = applySlugify(field.slug, 'Our team');
98
+ return `${SLUG_SEGMENT_DESCRIPTION}, written exactly as the slug rule writes one (e.g. "${example}" for "Our team") — never a sentence.`;
99
+ }
59
100
  /**
60
101
  * A field's model-facing description: resolved label + optional `ai.hint` + the
61
102
  * constraint clause (EC-473). The constraints are stated in PROSE because that is
@@ -83,17 +124,26 @@ export function buildTargetShape(input) {
83
124
  };
84
125
  const excluded = [];
85
126
  const compositions = [];
127
+ const derived = [];
128
+ const slugField = derivedSlugField(input.collection);
129
+ // EC-715: the slug role a writer DOES author (not derived) — offered in THE slug format.
130
+ const authoredSlug = slugField === undefined ? slugFieldOf(input.collection) : undefined;
86
131
  const localizedFields = [];
87
132
  const relationFields = [];
88
133
  const assetFields = [];
89
134
  for (const field of input.collection.fields) {
90
135
  if (field.localized)
91
136
  localizedFields.push(field.name);
92
- if (field.ai?.exclude) {
137
+ if (isWriterLocked(field)) {
93
138
  excluded.push(field.name);
94
139
  delete properties[field.name];
95
140
  continue;
96
141
  }
142
+ if (field === slugField) {
143
+ derived.push(field.name);
144
+ delete properties[field.name];
145
+ continue;
146
+ }
97
147
  // EC-556: the writer form at every depth (rich text → markdown, EC-379 / D2;
98
148
  // nested asset / relation → the bare forms the write-time lens wraps); EC-593:
99
149
  // every id an enum over what was searched for that position.
@@ -117,7 +167,8 @@ export function buildTargetShape(input) {
117
167
  }
118
168
  // EC-473: a `pattern` next to a `format` is 200+ characters that teach the
119
169
  // model nothing — the format (and the prose constraints below) say it better.
120
- const stripped = toModelFacingSchema(written);
170
+ // EC-715: an authored slug role carries its format first, as rich text carries its own.
171
+ const stripped = toModelFacingSchema(field === authoredSlug && field.type === 'text' ? { ...written, description: slugSegmentDescription(field) } : written);
121
172
  const desc = describe(field, input.locale);
122
173
  // Preserve an existing description (e.g. relation enum / rich-text note), but
123
174
  // append the constraint clause to it — the enum leash and the value rules are
@@ -136,7 +187,7 @@ export function buildTargetShape(input) {
136
187
  properties[field.name] = next;
137
188
  }
138
189
  const requiredIn = Array.isArray(base.required) ? base.required : [];
139
- const required = requiredIn.filter((n) => !excluded.includes(n) && properties[n] !== undefined);
190
+ const required = requiredIn.filter((n) => properties[n] !== undefined);
140
191
  const schema = {
141
192
  type: 'object',
142
193
  properties,
@@ -149,6 +200,6 @@ export function buildTargetShape(input) {
149
200
  }
150
201
  : {}),
151
202
  };
152
- return { schema, excluded, compositions, localizedFields, relationFields, assetFields };
203
+ return { schema, excluded, compositions, derived, localizedFields, relationFields, assetFields };
153
204
  }
154
205
  //# sourceMappingURL=shape.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"shape.js","sourceRoot":"","sources":["../../../src/lib/ai/shape.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Target-shape derivation for `applyContentWithShape` (EC-331).\n *\n * Reuses the existing collection→JSON-Schema derivation (`collectionJsonSchema`,\n * cms-core) rather than writing a new one, then applies the leashes the AI engine\n * adds on top:\n *\n * - `ai.exclude` fields are removed entirely (the model never sees them → cannot\n * write them).\n * - relation ids become an ENUM over the searched candidates of the relation's\n * target (or the position is omitted when none were searched) — so a hallucinated\n * relation id is not representable as valid structured output.\n * - asset ids become an ENUM over the searched library assets that fit the\n * position's `accept` (or the position is omitted when none fit) — EC-381, the SAME\n * leash as relations: a proposed asset id must be a real, `listAssets`-returned\n * library asset (empty beats wrong; the assistant never generates or uploads).\n * EC-593: both leashes hold at EVERY depth — a relation in an object, an asset in a\n * repeater item — not only on a top-level field (`writerFieldSchema(field, leash)`).\n * - rich-text fields are MARKDOWN STRINGS (EC-379): the description names the\n * allowed subset, and the engine converts md → the `{ version: 2, content }` storage\n * value at proposal-build time (`writer-form.ts`). The field's `allow` config leashes\n * component EMBEDS only — markdown cannot express an embed, so the leash is\n * satisfied by construction (and still enforced downstream by `validateRichTextEmbeds`).\n * - a composition (`blocks`) has NO writer form (ratified 4c, EC-593): it is omitted,\n * and the schema's own description says so — the omission is visible, never a field\n * the model simply never hears of.\n *\n * Localized fields stay flat in the shape (the model authors one locale at a time);\n * the engine routes their values under `localized[locale]`.\n *\n * EC-473 — the shape now consumes the ONE constraint projection, so a rule declared\n * in config actually reaches the model:\n *\n * - `collectionJsonSchema` merges declarative rules with the fragment derived from\n * a schema-form `validation.validate`, so `{ min: 2 }` and `z.string().min(2)`\n * arrive identically (and a declared DATE RANGE arrives at all — it previously\n * reached no consumer whatsoever);\n * - a rich-text field no longer OVERWRITES its constraints: the markdown schema is\n * merged over them instead of replacing them (measured drift, EC-474 §2);\n * - a `pattern` is dropped wherever a `format` explains the same thing (a\n * 200-character email regex teaches a model nothing and costs real tokens);\n * - a field whose projection is knowingly partial (`schemaComplete: false`) SAYS SO\n * in its description rather than presenting the partial rule as the whole rule.\n *\n * EC-505 — one further narrowing of what the model is TOLD (never of what the engine\n * accepts): a `date` property is `type: 'string'` + `format: 'date-time'` rather than\n * the `['string','number']` union the general projection carries, because the model is\n * asked to author an ISO instant (EC-472) and some providers reject a union `type`.\n *\n * EC-556 — the projection is VALIDATOR-facing (the value at rest: asset/relation\n * envelopes, the v2 rich-text object). Every property here is its WRITER form\n * (`writerFieldSchema`), derived from that projection at every depth — a bare asset\n * id, a relation id, a markdown string — so an asset, relation or rich-text SUBFIELD of\n * an `object` is told the same form as a top-level one.\n */\nimport { collectionJsonSchema, resolveLabel } from '@elytracms/core/cms-core'\nimport type { CollectionDef, FieldDef, JsonSchema } from '@elytracms/core/cms-core'\nimport { constraintSentence, toModelFacingSchema, writerFieldSchema } from './constraints'\nimport { NO_SEARCHED_IDS } from './leash'\nimport type { WriterLeash } from './types'\n\n/** A top-level relation field's resolved candidate set (the enum the model chose from). */\nexport interface TargetRelationField {\n fieldName: string\n /** The relation's target collection id. */\n target: string\n multiple: boolean\n /** The allowed candidate ids (the enum the model chose from). */\n candidateIds: string[]\n}\n\n/** A top-level asset field's resolved candidate set (EC-381). */\nexport interface TargetAssetField {\n fieldName: string\n multiple: boolean\n /** The allowed library asset ids (the enum the model chose from). */\n candidateIds: string[]\n}\n\n/** The derived target shape + the metadata the engine needs to post-process the output. */\nexport interface TargetShape {\n /** The JSON Schema the structured output must satisfy. */\n schema: JsonSchema\n /** Field names removed by `ai.exclude` (untouchable). */\n excluded: string[]\n /** Field names omitted because a writer never writes them — a composition (4c). */\n compositions: string[]\n /** Field names whose values route under `localized[locale]`. */\n localizedFields: string[]\n /** Top-level relation fields with their candidate enums. */\n relationFields: TargetRelationField[]\n /** Top-level asset fields with their candidate enums (EC-381). */\n assetFields: TargetAssetField[]\n}\n\nexport interface BuildTargetShapeInput {\n collection: CollectionDef\n /** What the model searched — the id leash at every position (absent → nothing searched). */\n leash?: WriterLeash\n /** UI locale for resolving field labels into descriptions. */\n locale?: string\n}\n\n/**\n * A field's model-facing description: resolved label + optional `ai.hint` + the\n * constraint clause (EC-473). The constraints are stated in PROSE because that is\n * what a model acts on — the same facts also ride as JSON-Schema keywords for the\n * structured-output validator, which is a different reader.\n */\nfunction describe(field: FieldDef, locale?: string): string | undefined {\n const label = resolveLabel(field.form?.label, locale) ?? field.name\n const hint = field.ai?.hint\n const constraints = constraintSentence(field)\n const parts = [label, hint, constraints].filter(\n (p): p is string => typeof p === 'string' && p.length > 0,\n )\n return parts.length > 0 ? parts.join(' — ') : undefined\n}\n\n/**\n * Build the leash-aware target shape from a collection + what the model searched.\n * Deterministic; never throws. Reuses `collectionJsonSchema` as the base (its\n * `required` list), then gives every property its writer form (EC-556) — id enums at\n * every depth (EC-593) — and strips excluded fields and compositions.\n */\nexport function buildTargetShape(input: BuildTargetShapeInput): TargetShape {\n const leash = input.leash ?? NO_SEARCHED_IDS\n const base = collectionJsonSchema(input.collection)\n const properties: Record<string, JsonSchema> = {\n ...((base.properties as Record<string, JsonSchema>) ?? {}),\n }\n const excluded: string[] = []\n const compositions: string[] = []\n const localizedFields: string[] = []\n const relationFields: TargetRelationField[] = []\n const assetFields: TargetAssetField[] = []\n\n for (const field of input.collection.fields) {\n if (field.localized) localizedFields.push(field.name)\n\n if (field.ai?.exclude) {\n excluded.push(field.name)\n delete properties[field.name]\n continue\n }\n\n // EC-556: the writer form at every depth (rich text → markdown, EC-379 / D2;\n // nested asset / relation → the bare forms the write-time lens wraps); EC-593:\n // every id an enum over what was searched for that position.\n const written = writerFieldSchema(field, leash)\n if (written === undefined) {\n // No writer form: a composition (4c), or a relation / asset with nothing\n // searched for it — the model cannot emit a value, so a hallucinated id is\n // structurally impossible (empty beats wrong).\n if (field.type === 'blocks') compositions.push(field.name)\n delete properties[field.name]\n continue\n }\n if (field.type === 'relation') {\n const candidateIds = leash.relationCandidates(field.target).map((c) => c.id)\n relationFields.push({ fieldName: field.name, target: field.target, multiple: field.multiple, candidateIds })\n } else if (field.type === 'asset') {\n const candidateIds = leash.assetCandidates(field).map((c) => c.id)\n assetFields.push({ fieldName: field.name, multiple: field.multiple, candidateIds })\n }\n\n // EC-473: a `pattern` next to a `format` is 200+ characters that teach the\n // model nothing — the format (and the prose constraints below) say it better.\n const stripped = toModelFacingSchema(written)\n const desc = describe(field, input.locale)\n // Preserve an existing description (e.g. relation enum / rich-text note), but\n // append the constraint clause to it — the enum leash and the value rules are\n // different facts and the model needs both.\n const next: JsonSchema = stripped === written ? { ...written } : { ...stripped }\n if (desc !== undefined) {\n const existing = typeof next.description === 'string' ? next.description : undefined\n const constraints = constraintSentence(field)\n next.description =\n existing === undefined\n ? desc\n : constraints !== undefined\n ? `${existing} — ${constraints}`\n : existing\n }\n properties[field.name] = next\n }\n\n const requiredIn = Array.isArray(base.required) ? (base.required as string[]) : []\n const required = requiredIn.filter((n) => !excluded.includes(n) && properties[n] !== undefined)\n\n const schema: JsonSchema = {\n type: 'object',\n properties,\n additionalProperties: false,\n ...(required.length > 0 ? { required } : {}),\n // 4c (EC-593): the omission is said, not silent.\n ...(compositions.length > 0\n ? {\n description: `Not part of this object: ${compositions.join(', ')} — page-builder composition${compositions.length > 1 ? 's' : ''} whose components are added, moved and removed on the canvas, never by the assistant.`,\n }\n : {}),\n }\n return { schema, excluded, compositions, localizedFields, relationFields, assetFields }\n}\n"]}
1
+ {"version":3,"file":"shape.js","sourceRoot":"","sources":["../../../src/lib/ai/shape.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Target-shape derivation for `applyContentWithShape` (EC-331).\n *\n * Reuses the existing collection→JSON-Schema derivation (`collectionJsonSchema`,\n * cms-core) rather than writing a new one, then applies the leashes the AI engine\n * adds on top:\n *\n * - writer-locked fields are removed entirely (EC-716, owner ruling \"Yes, honour\n * readOnly\"): `ai.exclude` and `form.readOnly`, at EVERY depth — core's one predicate\n * (`isWriterLocked`), so a read-only sub-field of an object is as absent as a\n * read-only top-level field. The model cannot name a position the editor's own form\n * cannot change.\n * - relation ids become an ENUM over the searched candidates of the relation's\n * target (or the position is omitted when none were searched) — so a hallucinated\n * relation id is not representable as valid structured output.\n * - asset ids become an ENUM over the searched library assets that fit the\n * position's `accept` (or the position is omitted when none fit) — EC-381, the SAME\n * leash as relations: a proposed asset id must be a real, `listAssets`-returned\n * library asset (empty beats wrong; the assistant never generates or uploads).\n * EC-593: both leashes hold at EVERY depth — a relation in an object, an asset in a\n * repeater item — not only on a top-level field (`writerFieldSchema(field, leash)`).\n * - rich-text fields are MARKDOWN STRINGS (EC-379): the description names the\n * allowed subset, and the engine converts md → the `{ version: 2, content }` storage\n * value at proposal-build time (`writer-form.ts`). The field's `allow` config leashes\n * component EMBEDS only — markdown cannot express an embed, so the leash is\n * satisfied by construction (and still enforced downstream by `validateRichTextEmbeds`).\n * - the slug role is DERIVED, never authored (EC-715, {@link derivedSlugField}): it is\n * omitted, and the create path fills it from the title with core's `autoDeriveSlug`; a\n * slug role a writer DOES author (`slug.source: false`, or nothing to derive from) is\n * offered in THE slug format ({@link slugSegmentDescription}), never as a free string;\n * - a composition (`blocks`) has NO writer form (ratified 4c, EC-593): it is omitted,\n * and the schema's own description says so — the omission is visible, never a field\n * the model simply never hears of.\n *\n * Localized fields stay flat in the shape (the model authors one locale at a time);\n * the engine routes their values under `localized[locale]`.\n *\n * EC-473 — the shape now consumes the ONE constraint projection, so a rule declared\n * in config actually reaches the model:\n *\n * - `collectionJsonSchema` merges declarative rules with the fragment derived from\n * a schema-form `validation.validate`, so `{ min: 2 }` and `z.string().min(2)`\n * arrive identically (and a declared DATE RANGE arrives at all — it previously\n * reached no consumer whatsoever);\n * - a rich-text field no longer OVERWRITES its constraints: the markdown schema is\n * merged over them instead of replacing them (measured drift, EC-474 §2);\n * - a `pattern` is dropped wherever a `format` explains the same thing (a\n * 200-character email regex teaches a model nothing and costs real tokens);\n * - a field whose projection is knowingly partial (`schemaComplete: false`) SAYS SO\n * in its description rather than presenting the partial rule as the whole rule.\n *\n * EC-505 — one further narrowing of what the model is TOLD (never of what the engine\n * accepts): a `date` property is `type: 'string'` + `format: 'date-time'` rather than\n * the `['string','number']` union the general projection carries, because the model is\n * asked to author an ISO instant (EC-472) and some providers reject a union `type`.\n *\n * EC-556 — the projection is VALIDATOR-facing (the value at rest: asset/relation\n * envelopes, the v2 rich-text object). Every property here is its WRITER form\n * (`writerFieldSchema`), derived from that projection at every depth — a bare asset\n * id, a relation id, a markdown string — so an asset, relation or rich-text SUBFIELD of\n * an `object` is told the same form as a top-level one.\n */\nimport {\n applySlugify,\n collectionJsonSchema,\n isWriterLocked,\n resolveLabel,\n slugFieldOf,\n titleFieldOf,\n} from '@elytracms/core/cms-core'\nimport type { CollectionDef, FieldDef, JsonSchema } from '@elytracms/core/cms-core'\nimport { constraintSentence, toModelFacingSchema, writerFieldSchema } from './constraints'\nimport { NO_SEARCHED_IDS } from './leash'\nimport type { WriterLeash } from './types'\n\n/**\n * The collection's SLUG ROLE when a writer never authors it (EC-715): a text `slugField`\n * (EC-590) that has a source — its `slug.source`, else the title role — and is not locked.\n * The create path derives its value with core's `autoDeriveSlug` (THE derivation the slug\n * control and translate read) instead of asking the model, which used to fill it with prose\n * wherever no pattern stopped it. `slug.source: false` (EC-691) opts out: the model writes it.\n */\nexport function derivedSlugField(collection: CollectionDef): FieldDef | undefined {\n const field = slugFieldOf(collection)\n if (!field || field.type !== 'text' || isWriterLocked(field)) return undefined\n const source = field.slug?.source\n if (source === false) return undefined\n if (source !== undefined) return field\n return typeof collection.titleField === 'function' || titleFieldOf(collection) !== undefined ? field : undefined\n}\n\n/**\n * How the writer form of an AUTHORED slug role begins (EC-715): the slug role a writer does\n * write — `slug.source: false`, or no source to derive from. A reader of the shape (the keyless\n * draft, like a real model) recognises the role by it; {@link slugSegmentDescription} says the rest.\n */\nexport const SLUG_SEGMENT_DESCRIPTION = 'URL segment'\n\n/**\n * The format an authored slug role takes, stated by THE slug rule itself (core's `applySlugify`:\n * the field's own `slug.slugify`, else the default `slugifySegment`) — an example it computes,\n * never a second regex restating it, and never prose: a value is a slug when the rule leaves it\n * as it is.\n */\nexport function slugSegmentDescription(field: FieldDef): string {\n const example = applySlugify(field.slug, 'Our team')\n return `${SLUG_SEGMENT_DESCRIPTION}, written exactly as the slug rule writes one (e.g. \"${example}\" for \"Our team\") — never a sentence.`\n}\n\n/** A top-level relation field's resolved candidate set (the enum the model chose from). */\nexport interface TargetRelationField {\n fieldName: string\n /** The relation's target collection id. */\n target: string\n multiple: boolean\n /** The allowed candidate ids (the enum the model chose from). */\n candidateIds: string[]\n}\n\n/** A top-level asset field's resolved candidate set (EC-381). */\nexport interface TargetAssetField {\n fieldName: string\n multiple: boolean\n /** The allowed library asset ids (the enum the model chose from). */\n candidateIds: string[]\n}\n\n/** The derived target shape + the metadata the engine needs to post-process the output. */\nexport interface TargetShape {\n /** The JSON Schema the structured output must satisfy. */\n schema: JsonSchema\n /** Field names removed as writer-locked — `ai.exclude` or `form.readOnly` (EC-716). */\n excluded: string[]\n /** Field names omitted because a writer never writes them — a composition (4c). */\n compositions: string[]\n /** Field names omitted because the engine derives them — the slug role (EC-715). */\n derived: string[]\n /** Field names whose values route under `localized[locale]`. */\n localizedFields: string[]\n /** Top-level relation fields with their candidate enums. */\n relationFields: TargetRelationField[]\n /** Top-level asset fields with their candidate enums (EC-381). */\n assetFields: TargetAssetField[]\n}\n\nexport interface BuildTargetShapeInput {\n collection: CollectionDef\n /** What the model searched — the id leash at every position (absent → nothing searched). */\n leash?: WriterLeash\n /** UI locale for resolving field labels into descriptions. */\n locale?: string\n}\n\n/**\n * A field's model-facing description: resolved label + optional `ai.hint` + the\n * constraint clause (EC-473). The constraints are stated in PROSE because that is\n * what a model acts on — the same facts also ride as JSON-Schema keywords for the\n * structured-output validator, which is a different reader.\n */\nfunction describe(field: FieldDef, locale?: string): string | undefined {\n const label = resolveLabel(field.form?.label, locale) ?? field.name\n const hint = field.ai?.hint\n const constraints = constraintSentence(field)\n const parts = [label, hint, constraints].filter(\n (p): p is string => typeof p === 'string' && p.length > 0,\n )\n return parts.length > 0 ? parts.join(' — ') : undefined\n}\n\n/**\n * Build the leash-aware target shape from a collection + what the model searched.\n * Deterministic; never throws. Reuses `collectionJsonSchema` as the base (its\n * `required` list), then gives every property its writer form (EC-556) — id enums at\n * every depth (EC-593) — and strips excluded fields and compositions.\n */\nexport function buildTargetShape(input: BuildTargetShapeInput): TargetShape {\n const leash = input.leash ?? NO_SEARCHED_IDS\n const base = collectionJsonSchema(input.collection)\n const properties: Record<string, JsonSchema> = {\n ...((base.properties as Record<string, JsonSchema>) ?? {}),\n }\n const excluded: string[] = []\n const compositions: string[] = []\n const derived: string[] = []\n const slugField = derivedSlugField(input.collection)\n // EC-715: the slug role a writer DOES author (not derived) — offered in THE slug format.\n const authoredSlug = slugField === undefined ? slugFieldOf(input.collection) : undefined\n const localizedFields: string[] = []\n const relationFields: TargetRelationField[] = []\n const assetFields: TargetAssetField[] = []\n\n for (const field of input.collection.fields) {\n if (field.localized) localizedFields.push(field.name)\n\n if (isWriterLocked(field)) {\n excluded.push(field.name)\n delete properties[field.name]\n continue\n }\n if (field === slugField) {\n derived.push(field.name)\n delete properties[field.name]\n continue\n }\n\n // EC-556: the writer form at every depth (rich text → markdown, EC-379 / D2;\n // nested asset / relation → the bare forms the write-time lens wraps); EC-593:\n // every id an enum over what was searched for that position.\n const written = writerFieldSchema(field, leash)\n if (written === undefined) {\n // No writer form: a composition (4c), or a relation / asset with nothing\n // searched for it — the model cannot emit a value, so a hallucinated id is\n // structurally impossible (empty beats wrong).\n if (field.type === 'blocks') compositions.push(field.name)\n delete properties[field.name]\n continue\n }\n if (field.type === 'relation') {\n const candidateIds = leash.relationCandidates(field.target).map((c) => c.id)\n relationFields.push({ fieldName: field.name, target: field.target, multiple: field.multiple, candidateIds })\n } else if (field.type === 'asset') {\n const candidateIds = leash.assetCandidates(field).map((c) => c.id)\n assetFields.push({ fieldName: field.name, multiple: field.multiple, candidateIds })\n }\n\n // EC-473: a `pattern` next to a `format` is 200+ characters that teach the\n // model nothing — the format (and the prose constraints below) say it better.\n // EC-715: an authored slug role carries its format first, as rich text carries its own.\n const stripped = toModelFacingSchema(\n field === authoredSlug && field.type === 'text' ? { ...written, description: slugSegmentDescription(field) } : written,\n )\n const desc = describe(field, input.locale)\n // Preserve an existing description (e.g. relation enum / rich-text note), but\n // append the constraint clause to it — the enum leash and the value rules are\n // different facts and the model needs both.\n const next: JsonSchema = stripped === written ? { ...written } : { ...stripped }\n if (desc !== undefined) {\n const existing = typeof next.description === 'string' ? next.description : undefined\n const constraints = constraintSentence(field)\n next.description =\n existing === undefined\n ? desc\n : constraints !== undefined\n ? `${existing} — ${constraints}`\n : existing\n }\n properties[field.name] = next\n }\n\n const requiredIn = Array.isArray(base.required) ? (base.required as string[]) : []\n const required = requiredIn.filter((n) => properties[n] !== undefined)\n\n const schema: JsonSchema = {\n type: 'object',\n properties,\n additionalProperties: false,\n ...(required.length > 0 ? { required } : {}),\n // 4c (EC-593): the omission is said, not silent.\n ...(compositions.length > 0\n ? {\n description: `Not part of this object: ${compositions.join(', ')} — page-builder composition${compositions.length > 1 ? 's' : ''} whose components are added, moved and removed on the canvas, never by the assistant.`,\n }\n : {}),\n }\n return { schema, excluded, compositions, derived, localizedFields, relationFields, assetFields }\n}\n"]}
@@ -36,7 +36,7 @@ import type { CmsDocument, CollectionDef, CollectionRegistry, LocaleConfig, Vali
36
36
  import type { SearchedAsset, SearchedDocument } from './leash.js';
37
37
  import type { TranslateSkip } from './translate-document.js';
38
38
  import type { ChatProposal } from './chat-state.js';
39
- import type { AiProvider, ResolvedKnowledge, WriterLeash } from './types.js';
39
+ import type { AiProvider, ResolvedKnowledge, UnfilledPosition, WriterLeash } from './types.js';
40
40
  /** A document the model may reference as a relation target (from a `searchDocuments` read). */
41
41
  export type DocumentSearchResult = SearchedDocument;
42
42
  /**
@@ -267,6 +267,11 @@ export interface ChatToolExecutor {
267
267
  }
268
268
  /** A translate skip as one model-facing phrase: `menu[1].label (de): the provider returned no text`. */
269
269
  export declare function describeTranslateSkip(skip: TranslateSkip): string;
270
+ /**
271
+ * An unfilled position as one model-facing phrase (EC-715): `cover (no searched library
272
+ * asset fits it)`, `card.image (an id outside the search results was dropped)`.
273
+ */
274
+ export declare function describeUnfilled(position: UnfilledPosition): string;
270
275
  /**
271
276
  * The `getDocument` output (EC-379 → EC-593): the document in the form the model reads
272
277
  * — the ONE converter's read side (`toWriterDocument`), so rich text is a markdown
@@ -281,9 +286,14 @@ export declare function describeTranslateSkip(skip: TranslateSkip): string;
281
286
  * document, the props `updateFields` may change and the rules each value must satisfy,
282
287
  * read off the same declarations the validator judges (`lookupManifest`). Applies to
283
288
  * the TOOL OUTPUT only; the proposal/apply paths always read the raw stored document.
289
+ *
290
+ * `readOnly` (EC-716) lists every writer-locked position the collection declares
291
+ * (`views`, `meta.locked`): the values stay in the output — the read context keeps them —
292
+ * and the list says they are read, never written.
284
293
  */
285
294
  export declare function modelFacingDocument(document: CmsDocument, collection: CollectionDef, validateOptions?: ValidateDocumentOptions): CmsDocument & {
286
295
  components?: Record<string, string[]>;
296
+ readOnly?: string[];
287
297
  };
288
298
  /**
289
299
  * Build the client executor map. Every entry validates its input against
@@ -32,9 +32,9 @@
32
32
  * with the hand-rolled orchestrator (EC-334 slice 2).
33
33
  */
34
34
  import { z } from 'zod';
35
- import { resolveDocumentTitle } from '@elytracms/core/cms-core';
35
+ import { isSingleton, resolveDocumentTitle } from '@elytracms/core/cms-core';
36
36
  import { applyContentWithShape } from './apply-content.js';
37
- import { describeComponentProps } from './chat-context.js';
37
+ import { describeComponentProps, lockedPositionsOf } from './chat-context.js';
38
38
  import { assetCandidatesOf, relationCandidatesOf } from './leash.js';
39
39
  import { translateDocument } from './translate-document.js';
40
40
  import { buildUpdateProposal } from './update-fields.js';
@@ -82,7 +82,8 @@ export const chatToolDefinitions = {
82
82
  'id. Omit both to read the document currently open in the editor. Rich-text values — ' +
83
83
  'at any depth — are returned as markdown strings where faithfully expressible. ' +
84
84
  '`components` lists, for every component on the document, the props you may change ' +
85
- 'with updateFields and the rules each value must satisfy.',
85
+ 'with updateFields and the rules each value must satisfy. `readOnly` lists the ' +
86
+ 'positions you can read but never change.',
86
87
  inputSchema: z.object({
87
88
  collection: z
88
89
  .string()
@@ -233,6 +234,19 @@ export function describeTranslateSkip(skip) {
233
234
  : 'the translation did not keep its formatting, links or embeds, so the original was kept';
234
235
  return `${where}${component} (${skip.locale}): ${why}`;
235
236
  }
237
+ /**
238
+ * An unfilled position as one model-facing phrase (EC-715): `cover (no searched library
239
+ * asset fits it)`, `card.image (an id outside the search results was dropped)`.
240
+ */
241
+ export function describeUnfilled(position) {
242
+ const where = position.at.reduce((out, key) => (typeof key === 'number' ? `${out}[${key}]` : `${out}.${key}`), position.field);
243
+ const why = position.reason === 'dropped'
244
+ ? 'an id outside the search results was dropped'
245
+ : position.reason === 'no-candidates'
246
+ ? 'nothing searched fits it'
247
+ : 'left empty';
248
+ return `${where} (${why})`;
249
+ }
236
250
  function issueSummary(issues) {
237
251
  return issues
238
252
  .slice(0, 5)
@@ -253,10 +267,18 @@ function issueSummary(issues) {
253
267
  * document, the props `updateFields` may change and the rules each value must satisfy,
254
268
  * read off the same declarations the validator judges (`lookupManifest`). Applies to
255
269
  * the TOOL OUTPUT only; the proposal/apply paths always read the raw stored document.
270
+ *
271
+ * `readOnly` (EC-716) lists every writer-locked position the collection declares
272
+ * (`views`, `meta.locked`): the values stay in the output — the read context keeps them —
273
+ * and the list says they are read, never written.
256
274
  */
257
275
  export function modelFacingDocument(document, collection, validateOptions = {}) {
258
276
  const lookupManifest = validateOptions.lookupManifest;
259
- const read = toWriterDocument(collection, document, lookupManifest ? { lookupManifest } : {});
277
+ const locked = lockedPositionsOf(collection.fields);
278
+ const read = {
279
+ ...toWriterDocument(collection, document, lookupManifest ? { lookupManifest } : {}),
280
+ ...(locked.length > 0 ? { readOnly: locked } : {}),
281
+ };
260
282
  if (!lookupManifest)
261
283
  return read;
262
284
  const ids = componentIdsOf(collection, document, lookupManifest);
@@ -416,6 +438,20 @@ export function createChatToolExecutor(options) {
416
438
  if (!collection) {
417
439
  return { ok: false, errorText: `Unknown collection "${input.collection}" — use a collection id from the catalog.` };
418
440
  }
441
+ // EC-715: a singleton has its one document; the media library holds uploads. Neither
442
+ // takes a create — refused here, not proposed for the editor to discover on apply.
443
+ if (isSingleton(collection)) {
444
+ return {
445
+ ok: false,
446
+ errorText: `"${collection.id}" is a singleton — it has exactly one document, which is changed with updateFields, never created.`,
447
+ };
448
+ }
449
+ if (collection.kind === 'asset') {
450
+ return {
451
+ ok: false,
452
+ errorText: `"${collection.id}" is the media library — assets are uploaded by people, never created by the assistant.`,
453
+ };
454
+ }
419
455
  const documentId = options.mintDocumentId(collection.id);
420
456
  const knowledge = options.knowledge?.();
421
457
  const result = await applyContentWithShape({
@@ -448,16 +484,25 @@ export function createChatToolExecutor(options) {
448
484
  provenance: result.provenance,
449
485
  attempts: result.attempts,
450
486
  issues: result.issues,
487
+ ...(result.unfilled.length > 0 ? { unfilled: result.unfilled } : {}),
451
488
  });
452
489
  // Compact summary only — the full patch goes to the review UI, not the model.
490
+ const unfilled = result.unfilled.map(describeUnfilled);
491
+ const titleValues = {
492
+ ...result.patch.values,
493
+ ...(options.locale ? result.patch.localized?.[options.locale] : {}),
494
+ };
453
495
  return {
454
496
  ok: true,
455
497
  output: {
456
498
  status: 'proposed',
457
499
  collection: collection.id,
458
500
  documentId,
459
- title: resolveDocumentTitle(collection, result.patch.values, documentId),
460
- note: 'Draft proposal created; the editor reviews and applies it in the studio. Summarize briefly — do not repeat the content.',
501
+ title: resolveDocumentTitle(collection, titleValues, documentId),
502
+ ...(unfilled.length > 0 ? { unfilled } : {}),
503
+ note: unfilled.length > 0
504
+ ? 'Draft proposal created; the editor reviews and applies it in the studio. Summarize briefly — do not repeat the content — and tell the user which fields were left empty (unfilled).'
505
+ : 'Draft proposal created; the editor reviews and applies it in the studio. Summarize briefly — do not repeat the content.',
461
506
  },
462
507
  };
463
508
  }