@json-layout/core 2.8.2 → 2.9.1

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 (64) hide show
  1. package/package.json +4 -2
  2. package/src/compile/index.js +3 -1
  3. package/src/compile/skeleton-node.js +46 -5
  4. package/src/compile/types.ts +1 -0
  5. package/src/compile/utils/resolve-refs.js +5 -8
  6. package/src/compile/utils/x-i18n.js +15 -6
  7. package/src/state/index.js +18 -1
  8. package/src/state/state-node.js +11 -2
  9. package/src/state/utils/urls.js +2 -0
  10. package/src/utils/json-pointer.js +29 -0
  11. package/src/webmcp/README.md +144 -0
  12. package/src/webmcp/index.js +88 -82
  13. package/src/webmcp/project.js +542 -111
  14. package/src/webmcp/resolve.js +37 -1
  15. package/src/webmcp/schema.js +169 -0
  16. package/src/webmcp/suggestions-store.js +121 -0
  17. package/src/webmcp/tools/describe-state.js +20 -64
  18. package/src/webmcp/tools/edit-array.js +51 -28
  19. package/src/webmcp/tools/fill-form-skill.js +17 -41
  20. package/src/webmcp/tools/get-data.js +66 -13
  21. package/src/webmcp/tools/get-field-suggestions.js +12 -23
  22. package/src/webmcp/tools/set-data.js +79 -28
  23. package/src/webmcp/tools/set-field-value.js +88 -39
  24. package/src/webmcp/variants-memo.js +53 -0
  25. package/types/compile/index.d.ts.map +1 -1
  26. package/types/compile/skeleton-node.d.ts +9 -2
  27. package/types/compile/skeleton-node.d.ts.map +1 -1
  28. package/types/compile/types.d.ts +1 -0
  29. package/types/compile/types.d.ts.map +1 -1
  30. package/types/compile/utils/resolve-refs.d.ts.map +1 -1
  31. package/types/compile/utils/x-i18n.d.ts +1 -1
  32. package/types/compile/utils/x-i18n.d.ts.map +1 -1
  33. package/types/state/index.d.ts +10 -0
  34. package/types/state/index.d.ts.map +1 -1
  35. package/types/state/state-node.d.ts.map +1 -1
  36. package/types/state/utils/urls.d.ts.map +1 -1
  37. package/types/utils/json-pointer.d.ts +22 -0
  38. package/types/utils/json-pointer.d.ts.map +1 -0
  39. package/types/webmcp/index.d.ts +23 -15
  40. package/types/webmcp/index.d.ts.map +1 -1
  41. package/types/webmcp/project.d.ts +178 -57
  42. package/types/webmcp/project.d.ts.map +1 -1
  43. package/types/webmcp/resolve.d.ts +7 -3
  44. package/types/webmcp/resolve.d.ts.map +1 -1
  45. package/types/webmcp/schema.d.ts +44 -0
  46. package/types/webmcp/schema.d.ts.map +1 -0
  47. package/types/webmcp/suggestions-store.d.ts +82 -0
  48. package/types/webmcp/suggestions-store.d.ts.map +1 -0
  49. package/types/webmcp/tools/describe-state.d.ts +5 -57
  50. package/types/webmcp/tools/describe-state.d.ts.map +1 -1
  51. package/types/webmcp/tools/edit-array.d.ts +8 -37
  52. package/types/webmcp/tools/edit-array.d.ts.map +1 -1
  53. package/types/webmcp/tools/fill-form-skill.d.ts +10 -13
  54. package/types/webmcp/tools/fill-form-skill.d.ts.map +1 -1
  55. package/types/webmcp/tools/get-data.d.ts +17 -15
  56. package/types/webmcp/tools/get-data.d.ts.map +1 -1
  57. package/types/webmcp/tools/get-field-suggestions.d.ts +4 -30
  58. package/types/webmcp/tools/get-field-suggestions.d.ts.map +1 -1
  59. package/types/webmcp/tools/set-data.d.ts +17 -34
  60. package/types/webmcp/tools/set-data.d.ts.map +1 -1
  61. package/types/webmcp/tools/set-field-value.d.ts +20 -57
  62. package/types/webmcp/tools/set-field-value.d.ts.map +1 -1
  63. package/types/webmcp/variants-memo.d.ts +42 -0
  64. package/types/webmcp/variants-memo.d.ts.map +1 -0
@@ -4,6 +4,190 @@
4
4
 
5
5
  import { isItemsLayout } from '@json-layout/vocabulary'
6
6
 
7
+ import { visibleChildren, resolveNode } from './resolve.js'
8
+ import { projectDeclaredFields } from './schema.js'
9
+
10
+ /**
11
+ * Suggestion values can be arbitrarily large objects (a whole dataset definition for example),
12
+ * they are kept out of the tools output and retrieved by index with setFieldValue.
13
+ */
14
+ /**
15
+ * Longest value inlined in a suggestion listing. Only scalars are ever inlined: a picker
16
+ * shows a person titles, not the objects behind them, and an agent picks a row the same
17
+ * way, by index. Printing a slice of each object cost 61-77% of every suggestion response
18
+ * measured, for bytes the tool's own description tells the agent never to copy.
19
+ */
20
+ export const SUGGESTION_VALUE_MAX_LENGTH = 100
21
+
22
+ /**
23
+ * Longest value rendered in full anywhere the agent reads state. Beyond it a value is
24
+ * named rather than printed: a picked data-fair dataset is 4-13 KB of column schema, and
25
+ * echoing it on the write, again in the state tree and a third time from getData was the
26
+ * single largest cost in the eval — for content the agent applied by index and never had
27
+ * to handle.
28
+ */
29
+ export const DISPLAYED_VALUE_MAX_LENGTH = 1000
30
+
31
+ /** Most revealed or hidden paths named before the list is summarised instead. */
32
+ export const REVEALED_PATHS_MAX = 10
33
+
34
+ /**
35
+ * Longest rendered list of options inlined into a state line instead of being flagged as
36
+ * something to go and fetch. A closed enum is already in hand — the state layer resolves it
37
+ * into itemsCacheKey without a request — so flagging it sent the agent on a round trip for
38
+ * a list nobody had to look up: charts spent two of sixteen calls reading four-const enums,
39
+ * and sortBy, sortOrder, color and strValue would each have cost another.
40
+ */
41
+ export const INLINE_ITEMS_MAX_LENGTH = 200
42
+
43
+ /**
44
+ * The options of a node when they are already resolved, and short enough to say out loud.
45
+ *
46
+ * itemsCacheKey is what the state layer fetched or evaluated for this node: an array once
47
+ * the options are known locally, the resolved URL string for a remote picker. So an array
48
+ * is exactly the case where getFieldSuggestions would tell the agent something the form
49
+ * could already have said.
50
+ * @param {import('../state/types.js').StateNode} node
51
+ * @returns {string | undefined} the rendered list, or undefined to keep flagging it
52
+ */
53
+ function inlineItems (node) {
54
+ const items = /** @type {any} */(node).itemsCacheKey
55
+ if (!Array.isArray(items) || items.length === 0) return undefined
56
+ const values = items.map((item) => (item && typeof item === 'object' && 'value' in item) ? item.value : item)
57
+ // only a short scalar can be written straight back; an object value has to be applied by
58
+ // suggestionIndex, so stating it would cost bytes and still leave the agent a lookup
59
+ if (!values.every((v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')) return undefined
60
+ const rendered = JSON.stringify(values)
61
+ return rendered.length <= INLINE_ITEMS_MAX_LENGTH ? rendered : undefined
62
+ }
63
+
64
+ /**
65
+ * Longest help inlined on a node the agent did not ask about. Help is written for someone
66
+ * looking at a form, where it sits behind a "?" icon and is read on demand; inlined into
67
+ * every state read it is pushed instead, and portal-page spends 782 characters of SEO
68
+ * advice on a field no agent in the eval has ever filled. Past this length it is named and
69
+ * left to be fetched, the same bargain oversized values get. Short help stays inline
70
+ * whatever the node — it is the kind that changes what an agent writes, such as a negative
71
+ * height meaning automatic sizing.
72
+ */
73
+ export const HELP_MAX_LENGTH = 300
74
+
75
+ /** the few named entities that show up in form help, plus the numeric forms */
76
+ const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
77
+
78
+ /**
79
+ * Help is authored as HTML for a browser. An agent reads text, so the markup is pure cost —
80
+ * `&#39;` is not merely wasted, it is harder to read than the apostrophe it stands for —
81
+ * and the newlines between block tags break the one-line-per-node markdown the state tree
82
+ * is made of.
83
+ * @param {string} html
84
+ * @returns {string}
85
+ */
86
+ export function helpToText (html) {
87
+ return html
88
+ .replace(/<li\b[^>]*>/gi, ' - ')
89
+ .replace(/<[^>]+>/g, ' ')
90
+ .replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)))
91
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
92
+ .replace(/&([a-z]+);/gi, (m, name) => /** @type {any} */(NAMED_ENTITIES)[name.toLowerCase()] ?? m)
93
+ .replace(/\s+/g, ' ')
94
+ .trim()
95
+ }
96
+
97
+ /**
98
+ * Which nodes are currently rendered, by path. A node hidden by a layout `if` stays in
99
+ * the tree as comp "none", so what a write changes there is visibility rather than the
100
+ * set of paths — comparing paths alone would report nothing. A node governed by a schema
101
+ * if/then is the other way round: it is absent until the condition holds, so the set of
102
+ * paths is all there is to compare. Recording both facts lets one diff serve both.
103
+ * @param {import('../state/types.js').StateNode} node
104
+ * @param {Map<string, {comp: string, owns: boolean}>} [into]
105
+ * @returns {Map<string, {comp: string, owns: boolean}>}
106
+ */
107
+ export function visibilitySnapshot (node, into = new Map()) {
108
+ // `owns` separates a field from the wrappers around it. A condition turning true brings
109
+ // its `$then` section along with the fields inside it, and naming the section among the
110
+ // things that "became available" would point the agent at a path it cannot write.
111
+ if (node.fullKey !== undefined) into.set(node.fullKey, { comp: node.layout?.comp, owns: node.dataPath !== node.parentDataPath })
112
+ for (const child of node.children ?? []) visibilitySnapshot(child, into)
113
+ return into
114
+ }
115
+
116
+ /**
117
+ * What a write turned visible or invisible.
118
+ *
119
+ * A field can arrive two ways: a layout `if` toggles a node that already exists between
120
+ * comp "none" and its real component, while a schema-level if/then has no node at all
121
+ * until the condition holds and then creates one. Both are the same event to an agent —
122
+ * something it must now fill that it could not before — so both count.
123
+ *
124
+ * `activated` is the variant selector a write just switched, if any. Activating a variant
125
+ * replaces a whole branch, and setFieldValue already lists the branch it activated;
126
+ * counting those nodes here too would print the same subtree twice.
127
+ * @param {Map<string, {comp: string, owns: boolean}>} before
128
+ * @param {Map<string, {comp: string, owns: boolean}>} after
129
+ * @param {string} [activated] - fullKey of a variant selector whose subtree is reported elsewhere
130
+ * @returns {{ revealed: string[], hidden: string[] }}
131
+ */
132
+ export function diffVisibility (before, after, activated) {
133
+ /** @type {string[]} */
134
+ const revealed = []
135
+ /** @type {string[]} */
136
+ const hidden = []
137
+ /** @param {string} path */
138
+ const reportedElsewhere = (path) => activated !== undefined && (path === activated || path.startsWith(activated + '/'))
139
+ for (const [path, node] of after) {
140
+ if (reportedElsewhere(path)) continue
141
+ const was = before.get(path)
142
+ if (was === undefined) {
143
+ if (node.comp !== 'none' && node.owns) revealed.push(path)
144
+ } else if (was.comp === 'none' && node.comp !== 'none') revealed.push(path)
145
+ else if (was.comp !== 'none' && node.comp === 'none') hidden.push(path)
146
+ }
147
+ // A condition turning false takes its subtree away entirely rather than hiding it. That
148
+ // is worth one line: an agent holding a path from an earlier describeState would
149
+ // otherwise keep trying to write somewhere that no longer exists.
150
+ for (const [path, node] of before) {
151
+ if (after.has(path) || reportedElsewhere(path)) continue
152
+ if (node.comp !== 'none' && node.owns) hidden.push(path)
153
+ }
154
+ return { revealed, hidden }
155
+ }
156
+
157
+ /**
158
+ * @param {{ revealed: string[], hidden: string[] }} diff
159
+ * @returns {string}
160
+ */
161
+ export function formatVisibilityDiff (diff) {
162
+ /**
163
+ * @param {string[]} paths
164
+ * @param {string} what
165
+ * @returns {string}
166
+ */
167
+ const line = (paths, what) => {
168
+ if (!paths.length) return ''
169
+ const shown = paths.slice(0, REVEALED_PATHS_MAX).join(', ')
170
+ const rest = paths.length > REVEALED_PATHS_MAX ? `, and ${paths.length - REVEALED_PATHS_MAX} more` : ''
171
+ return `\n${paths.length} field(s) ${what}: ${shown}${rest}`
172
+ }
173
+ return line(diff.revealed, 'became available') + line(diff.hidden, 'are no longer available')
174
+ }
175
+
176
+ /**
177
+ * Render a value for the agent: in full when it is small enough to be worth reading,
178
+ * otherwise named with its kind and size so the agent knows what is there without paying
179
+ * for it. It can always read a node's own subtree with describeState.
180
+ * @param {unknown} value
181
+ * @returns {string | undefined}
182
+ */
183
+ export function abbreviateValue (value) {
184
+ const json = JSON.stringify(value)
185
+ if (json === undefined || json.length <= DISPLAYED_VALUE_MAX_LENGTH) return json
186
+ if (Array.isArray(value)) return `<array of ${value.length} items, ${json.length} chars — call getData with this path to read it>`
187
+ if (value !== null && typeof value === 'object') return `<object, ${json.length} chars — call getData with this path to read it>`
188
+ return `<${typeof value}, ${json.length} chars>`
189
+ }
190
+
7
191
  const constraintKeys = {
8
192
  'number-field': ['min', 'max', 'step', 'precision'],
9
193
  slider: ['min', 'max', 'step'],
@@ -46,77 +230,131 @@ function getConstraintKeys (comp) {
46
230
  }
47
231
 
48
232
  /**
49
- * @typedef {{
50
- * path: string,
51
- * type: string,
52
- * data: unknown,
53
- * title?: string,
54
- * label?: string,
55
- * help?: string,
56
- * error?: string,
57
- * required?: boolean,
58
- * readOnly?: boolean,
59
- * modified?: boolean,
60
- * constraints?: Record<string, unknown>,
61
- * variants?: Array<{index: number, title: string}>,
62
- * selectedVariant?: number,
63
- * children?: Array<ProjectedNode>,
64
- * getSuggestions?: boolean
65
- * }} ProjectedNode
233
+ * A list item rendered as a summary is read-only only because the list did not activate it,
234
+ * the agent should not be told that this item cannot be edited.
235
+ * @param {import('../state/types.js').StateNode} node
236
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
237
+ * @returns {boolean}
66
238
  */
239
+ function isEditableListItemSummary (node, statefulLayout) {
240
+ if (!node.options.summary) return false
241
+ if (node.parentFullKey === null || node.parentFullKey === undefined) return false
242
+ const parent = resolveNode(statefulLayout.stateTree.root, node.parentFullKey)
243
+ if (!parent || parent.layout.comp !== 'list' || parent.options.readOnly) return false
244
+ // a list that does not allow item edition really has read-only items
245
+ const listActions = /** @type {Record<string, unknown>} */(parent.layout).listActions
246
+ if (Array.isArray(listActions) && !listActions.includes('edit')) return false
247
+ return true
248
+ }
67
249
 
68
250
  /**
251
+ * readOnly is inherited by everything below a list item rendered as a summary, so the exemption
252
+ * has to look at the ancestors too: the fields of such an item are writable, and presenting them
253
+ * as read-only makes an agent skip fields it is allowed to fill. Only walked for a readOnly node.
69
254
  * @param {import('../state/types.js').StateNode} node
70
255
  * @param {import('../state/index.js').StatefulLayout} statefulLayout
71
- * @returns {ProjectedNode}
256
+ * @returns {boolean}
72
257
  */
73
- export function projectNode (node, statefulLayout) {
74
- /** @type {ProjectedNode} */
75
- const out = {
76
- path: node.fullKey,
77
- type: compToType[node.layout.comp] || node.layout.comp,
78
- data: node.data
258
+ function isReadOnly (node, statefulLayout) {
259
+ if (!node.options.readOnly) return false
260
+ /** @type {import('../state/types.js').StateNode|undefined} */
261
+ let current = node
262
+ while (current) {
263
+ if (isEditableListItemSummary(current, statefulLayout)) return false
264
+ const parentFullKey = current.parentFullKey
265
+ if (parentFullKey === null || parentFullKey === undefined) break
266
+ current = resolveNode(statefulLayout.stateTree.root, parentFullKey)
79
267
  }
268
+ return true
269
+ }
80
270
 
81
- const layout = /** @type {Record<string, unknown>} */(node.layout)
82
- if (typeof layout.title === 'string') out.title = layout.title
83
- if (typeof layout.label === 'string') out.label = layout.label
84
- if (node.layout.help) out.help = node.layout.help
85
-
86
- if (node.error) out.error = node.error
271
+ /**
272
+ * Validation errors of the whole state tree indexed by node path.
273
+ * Only the first of the two occurrences of an activated list item captures the errors, so a
274
+ * node reached through the editable occurrence has to look its own error up by path.
275
+ * @param {import('../state/types.js').StateNode} root
276
+ * @returns {Record<string, string>}
277
+ */
278
+ function indexErrorsByPath (root) {
279
+ /** @type {Record<string, string>} */
280
+ const byPath = {}
281
+ /** @param {import('../state/types.js').StateNode} node */
282
+ const recurse = (node) => {
283
+ if (node.error && byPath[node.fullKey] === undefined) byPath[node.fullKey] = node.error
284
+ for (const child of node.children ?? []) recurse(child)
285
+ }
286
+ recurse(root)
287
+ return byPath
288
+ }
87
289
 
88
- if (node.skeleton.required) out.required = true
89
- if (node.options.readOnly) out.readOnly = true
90
- if (node.modified) out.modified = true
91
- if (isItemsLayout(node.layout, statefulLayout.compiledLayout.components)) out.getSuggestions = true
290
+ /**
291
+ * A node whose value is picked as a whole from getItems (a select or an autocomplete over
292
+ * objects) declares properties that are not nodes of the form: it is filled from
293
+ * getFieldSuggestions, never field by field. A list is itemsBased too, but its items do
294
+ * become real nodes, so its declared fields remain useful.
295
+ * @param {import('../state/types.js').StateNode} node
296
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
297
+ * @returns {boolean}
298
+ */
299
+ function isValuePickedFromItems (node, statefulLayout) {
300
+ if (node.layout.comp === 'list') return false
301
+ return isItemsLayout(node.layout, statefulLayout.compiledLayout.components)
302
+ }
92
303
 
93
- const keys = getConstraintKeys(node.layout.comp)
94
- if (keys) {
95
- /** @type {Record<string, unknown>} */
96
- const constraints = {}
97
- for (const k of keys) {
98
- const v = layout[k]
99
- if (v !== undefined && v !== null) constraints[k] = v
100
- }
101
- if (Object.keys(constraints).length > 0) out.constraints = constraints
102
- }
304
+ /**
305
+ * Whether this field's options cannot be fetched yet because the request that would
306
+ * produce them cannot be built.
307
+ *
308
+ * The state layer resolves a remote list's URL up front and stores it as `itemsCacheKey`;
309
+ * when the expression THROWS — because it reads a field nobody has filled in — the key is
310
+ * null. That is a different situation from "your query matched nothing" and from "this
311
+ * field has no list", and all three used to arrive as the same four words. The review that
312
+ * prompted this put 45% of the option lists across thirty real applications in this state
313
+ * until some other field is written first, so it is the common case, not an edge.
314
+ * @param {import('../state/types.js').StateNode} node
315
+ * @returns {boolean}
316
+ */
317
+ export function suggestionsBlocked (node) {
318
+ return node.itemsCacheKey === null
319
+ }
103
320
 
104
- if (node.layout.comp === 'one-of-select' && Array.isArray(layout.oneOfItems)) {
105
- out.variants = layout.oneOfItems
106
- .filter((item) => !item.header)
107
- .map((item) => ({ index: item.key, title: item.title }))
108
- // find selected variant
109
- const selected = layout.oneOfItems.find((item) => item.selected)
110
- if (selected) out.selectedVariant = selected.key
111
- }
321
+ /**
322
+ * The expression a blocked list is waiting on, so the answer can say what to go and set.
323
+ * @param {import('../state/types.js').StateNode} node
324
+ * @returns {string|undefined}
325
+ */
326
+ export function suggestionsSource (node) {
327
+ const getItems = /** @type {any} */(node.layout).getItems
328
+ const expr = getItems?.url?.expr ?? getItems?.expr
329
+ return typeof expr === 'string' ? expr : undefined
330
+ }
112
331
 
113
- if (node.children) {
114
- out.children = node.children
115
- .filter((c) => c.layout.comp !== 'none')
116
- .map(node => projectNode(node, statefulLayout))
117
- }
332
+ /**
333
+ * Whether this node can actually answer getFieldSuggestions.
334
+ *
335
+ * isItemsLayout only says the component KIND is items-based; it is true of a plain array
336
+ * of strings, which renders as a combobox and has no source of items at all. The state
337
+ * layer asks a stricter question — state-node.js gates prefetching on
338
+ * `layout.items || layout.getItems`, and index.js throws "missing items or getItems
339
+ * parameters" when neither produces any — so announcing the flag on kind alone promises
340
+ * the agent something the tool cannot deliver. The fill-form guide tells agents they MUST
341
+ * call getFieldSuggestions whenever they see the flag, so they obey and hit that error.
342
+ * @param {import('../state/types.js').StateNode} node
343
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
344
+ * @returns {boolean}
345
+ */
346
+ function hasSuggestions (node, statefulLayout) {
347
+ if (!isItemsLayout(node.layout, statefulLayout.compiledLayout.components)) return false
348
+ return !!(node.layout.items ?? node.layout.getItems)
349
+ }
118
350
 
119
- return out
351
+ /**
352
+ * @param {import('../state/types.js').StateNode} node
353
+ * @param {Record<string, string>} [errorsByPath]
354
+ * @returns {string|undefined}
355
+ */
356
+ function nodeError (node, errorsByPath) {
357
+ return node.error ?? errorsByPath?.[node.fullKey]
120
358
  }
121
359
 
122
360
  /**
@@ -132,42 +370,36 @@ export function projectFieldResult (node, statefulLayout) {
132
370
  type: compToType[node.layout.comp] || node.layout.comp,
133
371
  data: node.data
134
372
  }
135
- if (node.error) out.error = node.error
373
+ const error = nodeError(node, indexErrorsByPath(statefulLayout.stateTree.root))
374
+ if (error) out.error = error
136
375
  return out
137
376
  }
138
377
 
139
- /**
140
- * @param {import('../state/types.js').StateTree} stateTree
141
- * @param {import('../state/index.js').StatefulLayout} statefulLayout
142
- * @returns {{ root: ProjectedNode, valid: boolean }}
143
- */
144
- export function projectStateTree (stateTree, statefulLayout) {
145
- return {
146
- root: projectNode(stateTree.root, statefulLayout),
147
- valid: stateTree.valid
148
- }
149
- }
150
-
151
378
  /**
152
379
  * Format a projected node as a markdown line for LLM-readable output.
153
380
  * @param {import('../state/types.js').StateNode} node
154
381
  * @param {import('../state/index.js').StatefulLayout} statefulLayout
155
382
  * @param {number} [depth]
383
+ * @param {Record<string, string>} [errorsByPath] - computed on the root node when not given
384
+ * @param {import('./variants-memo.js').VariantsMemo} [variantsMemo] - when given, a variant
385
+ * list already printed for the same schema node is replaced by a pointer back to it
156
386
  * @returns {string}
157
387
  */
158
- export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
388
+ export function projectNodeToMarkdown (node, statefulLayout, depth = 0, errorsByPath = indexErrorsByPath(statefulLayout.stateTree.root), variantsMemo) {
159
389
  const indent = ' '.repeat(depth)
160
390
  const type = compToType[node.layout.comp] || node.layout.comp
161
391
  const layout = /** @type {Record<string, unknown>} */(node.layout)
162
392
 
163
393
  // build metadata tags
164
394
  const meta = [type]
395
+ const error = nodeError(node, errorsByPath)
165
396
  if (node.skeleton.required) meta.push('required')
166
- if (node.options.readOnly) meta.push('readOnly')
167
- if (node.error) meta.push('error')
397
+ if (isReadOnly(node, statefulLayout)) meta.push('readOnly')
398
+ if (error) meta.push('error')
168
399
  if (node.modified) meta.push('modified')
169
400
 
170
- // constraints
401
+ // constraints, from the layout for what the component renders and from the skeleton for
402
+ // what ajv enforces but nothing else would say — a precompiled layout has no raw schema
171
403
  const keys = getConstraintKeys(node.layout.comp)
172
404
  if (keys) {
173
405
  for (const k of keys) {
@@ -175,6 +407,9 @@ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
175
407
  if (v !== undefined && v !== null) meta.push(`${k}=${v}`)
176
408
  }
177
409
  }
410
+ for (const [k, v] of Object.entries(node.skeleton.constraints ?? {})) {
411
+ meta.push(`${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
412
+ }
178
413
 
179
414
  // variants
180
415
  if (node.layout.comp === 'one-of-select' && Array.isArray(layout.oneOfItems)) {
@@ -182,7 +417,16 @@ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
182
417
  if (selected) meta.push(`selected=${selected.key}`)
183
418
  }
184
419
 
185
- if (isItemsLayout(node.layout, statefulLayout.compiledLayout.components)) meta.push('suggestions')
420
+ if (hasSuggestions(node, statefulLayout)) {
421
+ // a closed list is stated, not advertised: the guide tells the agent it must fetch
422
+ // whatever is flagged, so flagging what is already known is what bought the round trip
423
+ const inlined = inlineItems(node)
424
+ if (inlined) meta.push(`values=${inlined}`)
425
+ // and a list that cannot be fetched yet says so here, before the agent spends a call
426
+ // finding out — the answer it would get names no cause it could act on
427
+ else if (suggestionsBlocked(node)) meta.push('suggestions once another field is set')
428
+ else meta.push('suggestions')
429
+ }
186
430
 
187
431
  // array item count
188
432
  if (node.layout.comp === 'list' && Array.isArray(node.data)) {
@@ -195,28 +439,66 @@ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
195
439
  if (typeof layout.label === 'string') line += ` label="${layout.label}"`
196
440
  else if (typeof layout.title === 'string') line += ` title="${layout.title}"`
197
441
 
442
+ const children = visibleChildren(node)
443
+
198
444
  // value for leaf nodes (no children or empty children)
199
- if (!node.children || node.children.length === 0) {
200
- line += ` value=${JSON.stringify(node.data)}`
445
+ if (children.length === 0) {
446
+ line += ` value=${abbreviateValue(node.data)}`
201
447
  }
202
448
 
203
- if (node.error) line += `${node.error}`
449
+ // Help is the guidance a model cannot infer that a negative height means automatic
450
+ // sizing, say. Long help is named rather than printed unless this node is the one that
451
+ // was asked about.
452
+ if (typeof node.layout.help === 'string' && node.layout.help) {
453
+ const help = helpToText(node.layout.help)
454
+ if (help.length <= HELP_MAX_LENGTH || depth === 0) line += ` help="${help}"`
455
+ else if (help) line += ` help=<${help.length} chars — describeState ${path} to read it>`
456
+ }
457
+
458
+ if (error) line += ` — ${error}`
204
459
 
205
460
  const lines = [line]
206
461
 
207
462
  // variants list
208
463
  if (node.layout.comp === 'one-of-select' && Array.isArray(layout.oneOfItems)) {
209
464
  const variants = layout.oneOfItems.filter((item) => !item.header)
210
- for (const v of variants) {
211
- lines.push(`${indent} - variant ${v.key}: ${v.title}`)
465
+ // Which branch is live was only ever implied, by the index in the path of the section
466
+ // printed underneath. That reads as "a branch exists" rather than "this one is
467
+ // active", and an agent that cannot tell the two apart cannot tell a switch that
468
+ // worked from one that did nothing — which is the whole question it asks a variant
469
+ // selector. The activated branch is always this node's first child.
470
+ const activeKey = node.children?.[0]?.key
471
+ const listedAt = variantsMemo?.listedAt(node.skeleton.pointer)
472
+ if (listedAt === undefined) {
473
+ variantsMemo?.record(node.skeleton.pointer, path)
474
+ for (const v of variants) {
475
+ lines.push(`${indent} - variant ${v.key}: ${v.title}${v.key === activeKey ? ' (active)' : ''}`)
476
+ }
477
+ } else {
478
+ // a recursive schema reaches the same union at many paths; the list is a constant,
479
+ // so name where it was given rather than repeat it — but which branch is active is
480
+ // this node's own, so it still has to be said here
481
+ const active = variants.find((v) => v.key === activeKey)
482
+ const activeLabel = active ? ` (variant ${active.key}: ${active.title} active)` : ''
483
+ lines.push(`${indent} - ${variants.length} variants${activeLabel}, the same list already given for ${listedAt} — call describeState on ${path} to see them again`)
212
484
  }
213
485
  }
214
486
 
215
487
  // recurse children
216
- if (node.children) {
217
- for (const child of node.children) {
218
- if (child.layout.comp === 'none') continue
219
- lines.push(projectNodeToMarkdown(child, statefulLayout, depth + 1))
488
+ for (const child of children) {
489
+ lines.push(projectNodeToMarkdown(child, statefulLayout, depth + 1, errorsByPath, variantsMemo))
490
+ }
491
+
492
+ // fields known from the skeleton but not hydrated in the state tree, skipped on a node fed by
493
+ // getItems: its properties are not separate nodes, it is filled from getFieldSuggestions
494
+ if (children.length === 0 && node.skeleton.children?.length &&
495
+ !isValuePickedFromItems(node, statefulLayout)) {
496
+ for (const field of projectDeclaredFields(node, statefulLayout)) {
497
+ const fieldMeta = ['declared']
498
+ if (field.type) fieldMeta.unshift(field.type)
499
+ if (field.required) fieldMeta.push('required')
500
+ if (field.enum) fieldMeta.push(`enum=${JSON.stringify(field.enum)}`)
501
+ lines.push(`${indent} - ${field.path} (${fieldMeta.join(', ')})`)
220
502
  }
221
503
  }
222
504
 
@@ -227,10 +509,11 @@ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
227
509
  * Format a state tree as markdown for LLM-readable output.
228
510
  * @param {import('../state/types.js').StateTree} stateTree
229
511
  * @param {import('../state/index.js').StatefulLayout} statefulLayout
512
+ * @param {import('./variants-memo.js').VariantsMemo} [variantsMemo]
230
513
  * @returns {string}
231
514
  */
232
- export function projectStateTreeToMarkdown (stateTree, statefulLayout) {
233
- const errors = collectErrors(stateTree.root)
515
+ export function projectStateTreeToMarkdown (stateTree, statefulLayout, variantsMemo) {
516
+ const errors = collectErrors(statefulLayout)
234
517
  const validLine = stateTree.valid
235
518
  ? 'valid: true, no errors'
236
519
  : `valid: false, ${errors.length} error(s)`
@@ -246,7 +529,7 @@ export function projectStateTreeToMarkdown (stateTree, statefulLayout) {
246
529
  }
247
530
 
248
531
  lines.push('Fields:')
249
- lines.push(projectNodeToMarkdown(stateTree.root, statefulLayout, 0))
532
+ lines.push(projectNodeToMarkdown(stateTree.root, statefulLayout, 0, undefined, variantsMemo))
250
533
 
251
534
  return lines.join('\n')
252
535
  }
@@ -254,14 +537,34 @@ export function projectStateTreeToMarkdown (stateTree, statefulLayout) {
254
537
  /**
255
538
  * Format a mutation result as concise text for LLM-readable output.
256
539
  * @param {boolean} valid
257
- * @param {Array<{path: string, message: string}>} errors
540
+ * @param {Array<{path: string, message: string}>} errors - errors of the mutated subtree
258
541
  * @param {string} [prefix] - optional prefix line (e.g. field info)
542
+ * @param {number} [otherErrors] - number of errors of the form outside of the mutated subtree
259
543
  * @returns {string}
260
544
  */
261
- export function formatMutationResult (valid, errors, prefix) {
545
+ export function formatMutationResult (valid, errors, prefix, otherErrors) {
262
546
  const lines = []
263
547
  if (prefix) lines.push(prefix)
264
548
 
549
+ // scoped mode, the errors are the ones of the mutated subtree only
550
+ if (otherErrors !== undefined) {
551
+ if (errors.length === 0) lines.push('no error here')
552
+ else {
553
+ lines.push(`${errors.length} error(s) here:`)
554
+ for (const e of errors) {
555
+ lines.push(`- ${e.path}: ${e.message}`)
556
+ }
557
+ }
558
+ if (otherErrors > 0) {
559
+ lines.push(`form has ${otherErrors} other error(s) elsewhere, use describeState to list them`)
560
+ } else if (!valid) {
561
+ lines.push('form is invalid')
562
+ } else {
563
+ lines.push('form is valid')
564
+ }
565
+ return lines.join('\n')
566
+ }
567
+
265
568
  if (valid) {
266
569
  lines.push('valid, no errors')
267
570
  } else {
@@ -278,46 +581,174 @@ export function formatMutationResult (valid, errors, prefix) {
278
581
  }
279
582
 
280
583
  /**
281
- * Format field suggestions as markdown for LLM-readable output.
584
+ * @typedef {{index: number, title: string, key?: string, value?: unknown, valueOmitted?: boolean, valueLength?: number}} ProjectedSuggestion
585
+ */
586
+
587
+ /**
588
+ * Project suggestions for the tools output: anything but a short scalar is identified by
589
+ * its title and key alone, and referred to by index instead of copied around.
282
590
  * @param {Array<{value: unknown, title: string, key?: string}>} items
591
+ * @param {number} [baseIndex] - index of the first item, as the store assigned it
592
+ * @returns {ProjectedSuggestion[]}
593
+ */
594
+ export function projectSuggestions (items, baseIndex = 0) {
595
+ return items.map((item, index) => {
596
+ /** @type {ProjectedSuggestion} */
597
+ const out = { index: baseIndex + index, title: item.title }
598
+ if (item.key !== undefined && item.key !== item.title) out.key = item.key
599
+ const json = JSON.stringify(item.value)
600
+ if (json === undefined) return out
601
+ // Short scalars stay: agents batch those straight into setData rather than spending a
602
+ // round-trip applying an index. Anything else is identified by its title and key, and
603
+ // applied by index — the value itself never has to reach the agent.
604
+ const isScalar = item.value === null || ['string', 'number', 'boolean'].includes(typeof item.value)
605
+ if (isScalar && json.length <= SUGGESTION_VALUE_MAX_LENGTH) {
606
+ out.value = item.value
607
+ } else {
608
+ out.valueOmitted = true
609
+ out.valueLength = json.length
610
+ }
611
+ return out
612
+ })
613
+ }
614
+
615
+ /**
616
+ * Format field suggestions as markdown for LLM-readable output.
617
+ * @param {ProjectedSuggestion[]} suggestions
618
+ * @param {string} [blockedOn] - the expression the list is waiting on, when it has one
283
619
  * @returns {string}
284
620
  */
285
- export function formatSuggestions (items) {
286
- if (items.length === 0) return 'No suggestions available'
287
- const lines = ['Suggestions (use the value with setFieldValue or setData):']
288
- for (const item of items) {
289
- const val = JSON.stringify(item.value)
290
- if (item.key && item.key !== item.title) {
291
- lines.push(`- value=${val} ${item.title} (${item.key})`)
621
+ export function formatSuggestions (suggestions, blockedOn) {
622
+ if (suggestions.length === 0) {
623
+ if (blockedOn) return `No options yet: this field's list comes from \`${blockedOn}\`, and that cannot be resolved until the data it reads is set. Fill that field first, then ask again.`
624
+ return 'No option matched. The list exists but nothing came back for this query try a broader one, or omit the query to see what there is.'
625
+ }
626
+ const lines = [`${suggestions.length} suggestion(s), apply one with setFieldValue and its suggestionIndex (or copy a short value):`]
627
+ for (const suggestion of suggestions) {
628
+ const title = suggestion.key ? `${suggestion.title} (${suggestion.key})` : suggestion.title
629
+ if (suggestion.valueOmitted) {
630
+ lines.push(`- [${suggestion.index}] ${title} — apply with suggestionIndex=${suggestion.index}`)
292
631
  } else {
293
- lines.push(`- value=${val} — ${item.title}`)
632
+ lines.push(`- [${suggestion.index}] ${title} value=${JSON.stringify(suggestion.value)}`)
294
633
  }
295
634
  }
296
635
  return lines.join('\n')
297
636
  }
298
637
 
299
638
  /**
300
- * @param {import('../state/types.js').StateNode} node
639
+ * The data pointer an ajv error applies to.
640
+ *
641
+ * ajv-errors wraps the original error, and a `required` error reports the parent's
642
+ * pointer with the missing key in its params — so the naive instancePath would be the
643
+ * object, not the field.
644
+ * @param {any} error
645
+ * @returns {string}
646
+ */
647
+ function dataPointerOf (error) {
648
+ const original = error?.params?.errors?.[0] ?? error
649
+ if (original?.keyword === 'required' && original.params?.missingProperty) {
650
+ return `${original.instancePath}/${original.params.missingProperty}`
651
+ }
652
+ return original?.instancePath ?? ''
653
+ }
654
+
655
+ /**
656
+ * Whether an error belongs to a branch of a union the form is actually showing.
657
+ *
658
+ * ajv validates every branch of a `oneOf` and reports the failures of all of them, so
659
+ * while the chosen branch is still incomplete the losing branches complain too. Those
660
+ * complaints name properties of a shape that was not chosen: there is no node for them,
661
+ * no way to write them and no way to clear them. The state tree already draws this line —
662
+ * it matches an error to a node by schema pointer, so only the active branch's errors
663
+ * ever land on one — and this keeps the raw list that is appended afterwards to the same
664
+ * line rather than undoing the work.
665
+ *
666
+ * The test is per `oneOf` crossed on the way down, not on the error's path as a whole: an
667
+ * error below an unhydrated list item has no node either, and that one must survive.
668
+ * @param {any} error
669
+ * @param {Set<string>} renderedPointers - skeleton pointers of the nodes the form has built
670
+ * @returns {boolean}
671
+ */
672
+ function isRenderedBranch (error, renderedPointers) {
673
+ for (const schemaPath of [error?.schemaPath, error?.params?.errors?.[0]?.schemaPath]) {
674
+ if (typeof schemaPath !== 'string') continue
675
+ const branches = /\/oneOf\/\d+/g
676
+ let match
677
+ while ((match = branches.exec(schemaPath)) !== null) {
678
+ if (!renderedPointers.has(schemaPath.slice(0, match.index + match[0].length))) return false
679
+ }
680
+ }
681
+ return true
682
+ }
683
+
684
+ /**
685
+ * Errors of the whole form, each named by the location it actually applies to.
686
+ *
687
+ * A node only carries an error while it is hydrated. A list shows its items in summary
688
+ * mode, so nothing below an unedited item exists as a node, and every error under it
689
+ * collapses onto the list — one message, on a path that is not the faulty one. An agent
690
+ * told "/sections must be integer" knows it is wrong and not where, and retries blind.
691
+ *
692
+ * So a node error that is standing in for deeper errors nobody names is replaced by
693
+ * those errors, addressed by data pointer. Errors a hydrated node does name keep their
694
+ * form path, which is what the mutation tools expect.
695
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
301
696
  * @returns {Array<{path: string, message: string}>}
302
697
  */
303
- export function collectErrors (node) {
698
+ export function collectErrors (statefulLayout) {
699
+ /** @type {Array<{fullKey: string, dataPath: string, message: string}>} */
700
+ const nodeErrors = []
701
+ /** @type {Set<string>} */
702
+ const renderedPointers = new Set()
703
+ /** @param {import('../state/types.js').StateNode} node */
704
+ const recurse = (node) => {
705
+ if (node.error) nodeErrors.push({ fullKey: node.fullKey, dataPath: node.dataPath, message: node.error })
706
+ if (node.skeleton?.pointer) renderedPointers.add(node.skeleton.pointer)
707
+ // all children, not visibleChildren: in "menu"/"dialog" list edit modes the two
708
+ // occurrences of an activated item do not carry the same errors, and deduplicating
709
+ // here silently drops them (verified: 2 errors became 0).
710
+ for (const child of node.children ?? []) recurse(child)
711
+ }
712
+ recurse(statefulLayout.stateTree.root)
713
+
714
+ const named = new Set(nodeErrors.map((e) => e.dataPath))
715
+ const unnamed = statefulLayout.validationErrors
716
+ .filter((error) => isRenderedBranch(error, renderedPointers))
717
+ .map((error) => ({ pointer: dataPointerOf(error), message: error.message ?? 'invalid' }))
718
+ .filter((error) => !named.has(error.pointer))
719
+
304
720
  /** @type {Array<{path: string, message: string}>} */
305
721
  const errors = []
306
- collectErrorsRecurse(node, errors)
722
+ for (const nodeError of nodeErrors) {
723
+ const prefix = nodeError.dataPath === '' ? '/' : `${nodeError.dataPath}/`
724
+ const standsInForDeeperErrors = unnamed.some((e) => e.pointer.startsWith(prefix))
725
+ if (!standsInForDeeperErrors) errors.push({ path: nodeError.fullKey, message: nodeError.message })
726
+ }
727
+ for (const error of unnamed) errors.push({ path: error.pointer, message: error.message })
307
728
  return errors
308
729
  }
309
730
 
310
731
  /**
732
+ * Errors of the subtree of a node, and count of the errors of the rest of the form.
733
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
311
734
  * @param {import('../state/types.js').StateNode} node
312
- * @param {Array<{path: string, message: string}>} errors
735
+ * @returns {{ errors: Array<{path: string, message: string}>, otherErrors: number }}
313
736
  */
314
- function collectErrorsRecurse (node, errors) {
315
- if (node.error) {
316
- errors.push({ path: node.fullKey, message: node.error })
317
- }
318
- if (node.children) {
319
- for (const child of node.children) {
320
- collectErrorsRecurse(child, errors)
321
- }
737
+ export function collectScopedErrors (statefulLayout, node) {
738
+ // the path index is used rather than a walk of the subtree: an activated list item is kept
739
+ // twice and the tools resolve to the editable occurrence, which carries no error at all,
740
+ // neither on the item nor on anything below it. Indexing by path merges the two.
741
+ const errorsByPath = indexErrorsByPath(statefulLayout.stateTree.root)
742
+ const prefix = node.fullKey
743
+ const isInScope = (/** @type {string} */path) =>
744
+ prefix === '' || path === prefix || path.startsWith(`${prefix}/`)
745
+
746
+ /** @type {Array<{path: string, message: string}>} */
747
+ const errors = []
748
+ let otherErrors = 0
749
+ for (const [path, message] of Object.entries(errorsByPath)) {
750
+ if (isInScope(path)) errors.push({ path, message })
751
+ else otherErrors++
322
752
  }
753
+ return { errors, otherErrors }
323
754
  }