@json-layout/core 2.8.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +4 -2
  2. package/src/compile/index.js +3 -1
  3. package/src/compile/serialize.js +18 -15
  4. package/src/compile/skeleton-node.js +71 -5
  5. package/src/compile/types.ts +1 -0
  6. package/src/compile/utils/resolve-refs.js +5 -8
  7. package/src/compile/utils/x-i18n.js +15 -6
  8. package/src/state/index.js +46 -1
  9. package/src/state/state-node.js +36 -5
  10. package/src/state/types.ts +6 -0
  11. package/src/state/utils/urls.js +2 -0
  12. package/src/utils/json-pointer.js +29 -0
  13. package/src/webmcp/README.md +144 -0
  14. package/src/webmcp/index.js +88 -82
  15. package/src/webmcp/project.js +479 -111
  16. package/src/webmcp/resolve.js +37 -1
  17. package/src/webmcp/schema.js +169 -0
  18. package/src/webmcp/suggestions-store.js +121 -0
  19. package/src/webmcp/tools/describe-state.js +20 -64
  20. package/src/webmcp/tools/edit-array.js +51 -28
  21. package/src/webmcp/tools/fill-form-skill.js +17 -41
  22. package/src/webmcp/tools/get-data.js +66 -13
  23. package/src/webmcp/tools/get-field-suggestions.js +12 -23
  24. package/src/webmcp/tools/set-data.js +79 -28
  25. package/src/webmcp/tools/set-field-value.js +49 -39
  26. package/src/webmcp/variants-memo.js +53 -0
  27. package/types/compile/index.d.ts.map +1 -1
  28. package/types/compile/serialize.d.ts.map +1 -1
  29. package/types/compile/skeleton-node.d.ts +9 -2
  30. package/types/compile/skeleton-node.d.ts.map +1 -1
  31. package/types/compile/types.d.ts +1 -0
  32. package/types/compile/types.d.ts.map +1 -1
  33. package/types/compile/utils/resolve-refs.d.ts.map +1 -1
  34. package/types/compile/utils/x-i18n.d.ts +1 -1
  35. package/types/compile/utils/x-i18n.d.ts.map +1 -1
  36. package/types/state/index.d.ts +23 -0
  37. package/types/state/index.d.ts.map +1 -1
  38. package/types/state/state-node.d.ts.map +1 -1
  39. package/types/state/types.d.ts +2 -0
  40. package/types/state/types.d.ts.map +1 -1
  41. package/types/state/utils/urls.d.ts.map +1 -1
  42. package/types/utils/json-pointer.d.ts +22 -0
  43. package/types/utils/json-pointer.d.ts.map +1 -0
  44. package/types/webmcp/index.d.ts +23 -15
  45. package/types/webmcp/index.d.ts.map +1 -1
  46. package/types/webmcp/project.d.ts +159 -57
  47. package/types/webmcp/project.d.ts.map +1 -1
  48. package/types/webmcp/resolve.d.ts +7 -3
  49. package/types/webmcp/resolve.d.ts.map +1 -1
  50. package/types/webmcp/schema.d.ts +44 -0
  51. package/types/webmcp/schema.d.ts.map +1 -0
  52. package/types/webmcp/suggestions-store.d.ts +82 -0
  53. package/types/webmcp/suggestions-store.d.ts.map +1 -0
  54. package/types/webmcp/tools/describe-state.d.ts +5 -57
  55. package/types/webmcp/tools/describe-state.d.ts.map +1 -1
  56. package/types/webmcp/tools/edit-array.d.ts +8 -37
  57. package/types/webmcp/tools/edit-array.d.ts.map +1 -1
  58. package/types/webmcp/tools/fill-form-skill.d.ts +10 -13
  59. package/types/webmcp/tools/fill-form-skill.d.ts.map +1 -1
  60. package/types/webmcp/tools/get-data.d.ts +17 -15
  61. package/types/webmcp/tools/get-data.d.ts.map +1 -1
  62. package/types/webmcp/tools/get-field-suggestions.d.ts +4 -30
  63. package/types/webmcp/tools/get-field-suggestions.d.ts.map +1 -1
  64. package/types/webmcp/tools/set-data.d.ts +17 -34
  65. package/types/webmcp/tools/set-data.d.ts.map +1 -1
  66. package/types/webmcp/tools/set-field-value.d.ts +20 -57
  67. package/types/webmcp/tools/set-field-value.d.ts.map +1 -1
  68. package/types/webmcp/variants-memo.d.ts +42 -0
  69. package/types/webmcp/variants-memo.d.ts.map +1 -0
@@ -4,6 +4,169 @@
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 an `if` condition stays
99
+ * in the tree as comp "none", so what a write changes is visibility rather than the set
100
+ * of paths — comparing paths alone would report nothing.
101
+ * @param {import('../state/types.js').StateNode} node
102
+ * @param {Map<string, string>} [into]
103
+ * @returns {Map<string, string>}
104
+ */
105
+ export function visibilitySnapshot (node, into = new Map()) {
106
+ if (node.fullKey !== undefined) into.set(node.fullKey, node.layout?.comp)
107
+ for (const child of node.children ?? []) visibilitySnapshot(child, into)
108
+ return into
109
+ }
110
+
111
+ /**
112
+ * What a write turned visible or invisible.
113
+ *
114
+ * Only paths present in both snapshots count. Activating a variant replaces one branch
115
+ * with another, so its nodes are new paths rather than nodes that changed visibility —
116
+ * setFieldValue already lists the activated branch, and counting them here would print
117
+ * the same subtree twice.
118
+ * @param {Map<string, string>} before
119
+ * @param {Map<string, string>} after
120
+ * @returns {{ revealed: string[], hidden: string[] }}
121
+ */
122
+ export function diffVisibility (before, after) {
123
+ /** @type {string[]} */
124
+ const revealed = []
125
+ /** @type {string[]} */
126
+ const hidden = []
127
+ for (const [path, comp] of after) {
128
+ if (!before.has(path)) continue
129
+ const was = before.get(path)
130
+ if (was === 'none' && comp !== 'none') revealed.push(path)
131
+ else if (was !== 'none' && comp === 'none') hidden.push(path)
132
+ }
133
+ return { revealed, hidden }
134
+ }
135
+
136
+ /**
137
+ * @param {{ revealed: string[], hidden: string[] }} diff
138
+ * @returns {string}
139
+ */
140
+ export function formatVisibilityDiff (diff) {
141
+ /**
142
+ * @param {string[]} paths
143
+ * @param {string} what
144
+ * @returns {string}
145
+ */
146
+ const line = (paths, what) => {
147
+ if (!paths.length) return ''
148
+ const shown = paths.slice(0, REVEALED_PATHS_MAX).join(', ')
149
+ const rest = paths.length > REVEALED_PATHS_MAX ? `, and ${paths.length - REVEALED_PATHS_MAX} more` : ''
150
+ return `\n${paths.length} field(s) ${what}: ${shown}${rest}`
151
+ }
152
+ return line(diff.revealed, 'became available') + line(diff.hidden, 'are no longer available')
153
+ }
154
+
155
+ /**
156
+ * Render a value for the agent: in full when it is small enough to be worth reading,
157
+ * otherwise named with its kind and size so the agent knows what is there without paying
158
+ * for it. It can always read a node's own subtree with describeState.
159
+ * @param {unknown} value
160
+ * @returns {string | undefined}
161
+ */
162
+ export function abbreviateValue (value) {
163
+ const json = JSON.stringify(value)
164
+ if (json === undefined || json.length <= DISPLAYED_VALUE_MAX_LENGTH) return json
165
+ if (Array.isArray(value)) return `<array of ${value.length} items, ${json.length} chars — call getData with this path to read it>`
166
+ if (value !== null && typeof value === 'object') return `<object, ${json.length} chars — call getData with this path to read it>`
167
+ return `<${typeof value}, ${json.length} chars>`
168
+ }
169
+
7
170
  const constraintKeys = {
8
171
  'number-field': ['min', 'max', 'step', 'precision'],
9
172
  slider: ['min', 'max', 'step'],
@@ -46,77 +209,131 @@ function getConstraintKeys (comp) {
46
209
  }
47
210
 
48
211
  /**
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
212
+ * A list item rendered as a summary is read-only only because the list did not activate it,
213
+ * the agent should not be told that this item cannot be edited.
214
+ * @param {import('../state/types.js').StateNode} node
215
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
216
+ * @returns {boolean}
66
217
  */
218
+ function isEditableListItemSummary (node, statefulLayout) {
219
+ if (!node.options.summary) return false
220
+ if (node.parentFullKey === null || node.parentFullKey === undefined) return false
221
+ const parent = resolveNode(statefulLayout.stateTree.root, node.parentFullKey)
222
+ if (!parent || parent.layout.comp !== 'list' || parent.options.readOnly) return false
223
+ // a list that does not allow item edition really has read-only items
224
+ const listActions = /** @type {Record<string, unknown>} */(parent.layout).listActions
225
+ if (Array.isArray(listActions) && !listActions.includes('edit')) return false
226
+ return true
227
+ }
67
228
 
68
229
  /**
230
+ * readOnly is inherited by everything below a list item rendered as a summary, so the exemption
231
+ * has to look at the ancestors too: the fields of such an item are writable, and presenting them
232
+ * as read-only makes an agent skip fields it is allowed to fill. Only walked for a readOnly node.
69
233
  * @param {import('../state/types.js').StateNode} node
70
234
  * @param {import('../state/index.js').StatefulLayout} statefulLayout
71
- * @returns {ProjectedNode}
235
+ * @returns {boolean}
72
236
  */
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
237
+ function isReadOnly (node, statefulLayout) {
238
+ if (!node.options.readOnly) return false
239
+ /** @type {import('../state/types.js').StateNode|undefined} */
240
+ let current = node
241
+ while (current) {
242
+ if (isEditableListItemSummary(current, statefulLayout)) return false
243
+ const parentFullKey = current.parentFullKey
244
+ if (parentFullKey === null || parentFullKey === undefined) break
245
+ current = resolveNode(statefulLayout.stateTree.root, parentFullKey)
79
246
  }
247
+ return true
248
+ }
80
249
 
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
250
+ /**
251
+ * Validation errors of the whole state tree indexed by node path.
252
+ * Only the first of the two occurrences of an activated list item captures the errors, so a
253
+ * node reached through the editable occurrence has to look its own error up by path.
254
+ * @param {import('../state/types.js').StateNode} root
255
+ * @returns {Record<string, string>}
256
+ */
257
+ function indexErrorsByPath (root) {
258
+ /** @type {Record<string, string>} */
259
+ const byPath = {}
260
+ /** @param {import('../state/types.js').StateNode} node */
261
+ const recurse = (node) => {
262
+ if (node.error && byPath[node.fullKey] === undefined) byPath[node.fullKey] = node.error
263
+ for (const child of node.children ?? []) recurse(child)
264
+ }
265
+ recurse(root)
266
+ return byPath
267
+ }
87
268
 
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
269
+ /**
270
+ * A node whose value is picked as a whole from getItems (a select or an autocomplete over
271
+ * objects) declares properties that are not nodes of the form: it is filled from
272
+ * getFieldSuggestions, never field by field. A list is itemsBased too, but its items do
273
+ * become real nodes, so its declared fields remain useful.
274
+ * @param {import('../state/types.js').StateNode} node
275
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
276
+ * @returns {boolean}
277
+ */
278
+ function isValuePickedFromItems (node, statefulLayout) {
279
+ if (node.layout.comp === 'list') return false
280
+ return isItemsLayout(node.layout, statefulLayout.compiledLayout.components)
281
+ }
92
282
 
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
- }
283
+ /**
284
+ * Whether this field's options cannot be fetched yet because the request that would
285
+ * produce them cannot be built.
286
+ *
287
+ * The state layer resolves a remote list's URL up front and stores it as `itemsCacheKey`;
288
+ * when the expression THROWS — because it reads a field nobody has filled in — the key is
289
+ * null. That is a different situation from "your query matched nothing" and from "this
290
+ * field has no list", and all three used to arrive as the same four words. The review that
291
+ * prompted this put 45% of the option lists across thirty real applications in this state
292
+ * until some other field is written first, so it is the common case, not an edge.
293
+ * @param {import('../state/types.js').StateNode} node
294
+ * @returns {boolean}
295
+ */
296
+ export function suggestionsBlocked (node) {
297
+ return node.itemsCacheKey === null
298
+ }
103
299
 
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
- }
300
+ /**
301
+ * The expression a blocked list is waiting on, so the answer can say what to go and set.
302
+ * @param {import('../state/types.js').StateNode} node
303
+ * @returns {string|undefined}
304
+ */
305
+ export function suggestionsSource (node) {
306
+ const getItems = /** @type {any} */(node.layout).getItems
307
+ const expr = getItems?.url?.expr ?? getItems?.expr
308
+ return typeof expr === 'string' ? expr : undefined
309
+ }
112
310
 
113
- if (node.children) {
114
- out.children = node.children
115
- .filter((c) => c.layout.comp !== 'none')
116
- .map(node => projectNode(node, statefulLayout))
117
- }
311
+ /**
312
+ * Whether this node can actually answer getFieldSuggestions.
313
+ *
314
+ * isItemsLayout only says the component KIND is items-based; it is true of a plain array
315
+ * of strings, which renders as a combobox and has no source of items at all. The state
316
+ * layer asks a stricter question — state-node.js gates prefetching on
317
+ * `layout.items || layout.getItems`, and index.js throws "missing items or getItems
318
+ * parameters" when neither produces any — so announcing the flag on kind alone promises
319
+ * the agent something the tool cannot deliver. The fill-form guide tells agents they MUST
320
+ * call getFieldSuggestions whenever they see the flag, so they obey and hit that error.
321
+ * @param {import('../state/types.js').StateNode} node
322
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
323
+ * @returns {boolean}
324
+ */
325
+ function hasSuggestions (node, statefulLayout) {
326
+ if (!isItemsLayout(node.layout, statefulLayout.compiledLayout.components)) return false
327
+ return !!(node.layout.items ?? node.layout.getItems)
328
+ }
118
329
 
119
- return out
330
+ /**
331
+ * @param {import('../state/types.js').StateNode} node
332
+ * @param {Record<string, string>} [errorsByPath]
333
+ * @returns {string|undefined}
334
+ */
335
+ function nodeError (node, errorsByPath) {
336
+ return node.error ?? errorsByPath?.[node.fullKey]
120
337
  }
121
338
 
122
339
  /**
@@ -132,42 +349,36 @@ export function projectFieldResult (node, statefulLayout) {
132
349
  type: compToType[node.layout.comp] || node.layout.comp,
133
350
  data: node.data
134
351
  }
135
- if (node.error) out.error = node.error
352
+ const error = nodeError(node, indexErrorsByPath(statefulLayout.stateTree.root))
353
+ if (error) out.error = error
136
354
  return out
137
355
  }
138
356
 
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
357
  /**
152
358
  * Format a projected node as a markdown line for LLM-readable output.
153
359
  * @param {import('../state/types.js').StateNode} node
154
360
  * @param {import('../state/index.js').StatefulLayout} statefulLayout
155
361
  * @param {number} [depth]
362
+ * @param {Record<string, string>} [errorsByPath] - computed on the root node when not given
363
+ * @param {import('./variants-memo.js').VariantsMemo} [variantsMemo] - when given, a variant
364
+ * list already printed for the same schema node is replaced by a pointer back to it
156
365
  * @returns {string}
157
366
  */
158
- export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
367
+ export function projectNodeToMarkdown (node, statefulLayout, depth = 0, errorsByPath = indexErrorsByPath(statefulLayout.stateTree.root), variantsMemo) {
159
368
  const indent = ' '.repeat(depth)
160
369
  const type = compToType[node.layout.comp] || node.layout.comp
161
370
  const layout = /** @type {Record<string, unknown>} */(node.layout)
162
371
 
163
372
  // build metadata tags
164
373
  const meta = [type]
374
+ const error = nodeError(node, errorsByPath)
165
375
  if (node.skeleton.required) meta.push('required')
166
- if (node.options.readOnly) meta.push('readOnly')
167
- if (node.error) meta.push('error')
376
+ if (isReadOnly(node, statefulLayout)) meta.push('readOnly')
377
+ if (error) meta.push('error')
168
378
  if (node.modified) meta.push('modified')
169
379
 
170
- // constraints
380
+ // constraints, from the layout for what the component renders and from the skeleton for
381
+ // what ajv enforces but nothing else would say — a precompiled layout has no raw schema
171
382
  const keys = getConstraintKeys(node.layout.comp)
172
383
  if (keys) {
173
384
  for (const k of keys) {
@@ -175,6 +386,9 @@ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
175
386
  if (v !== undefined && v !== null) meta.push(`${k}=${v}`)
176
387
  }
177
388
  }
389
+ for (const [k, v] of Object.entries(node.skeleton.constraints ?? {})) {
390
+ meta.push(`${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
391
+ }
178
392
 
179
393
  // variants
180
394
  if (node.layout.comp === 'one-of-select' && Array.isArray(layout.oneOfItems)) {
@@ -182,7 +396,16 @@ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
182
396
  if (selected) meta.push(`selected=${selected.key}`)
183
397
  }
184
398
 
185
- if (isItemsLayout(node.layout, statefulLayout.compiledLayout.components)) meta.push('suggestions')
399
+ if (hasSuggestions(node, statefulLayout)) {
400
+ // a closed list is stated, not advertised: the guide tells the agent it must fetch
401
+ // whatever is flagged, so flagging what is already known is what bought the round trip
402
+ const inlined = inlineItems(node)
403
+ if (inlined) meta.push(`values=${inlined}`)
404
+ // and a list that cannot be fetched yet says so here, before the agent spends a call
405
+ // finding out — the answer it would get names no cause it could act on
406
+ else if (suggestionsBlocked(node)) meta.push('suggestions once another field is set')
407
+ else meta.push('suggestions')
408
+ }
186
409
 
187
410
  // array item count
188
411
  if (node.layout.comp === 'list' && Array.isArray(node.data)) {
@@ -195,28 +418,57 @@ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
195
418
  if (typeof layout.label === 'string') line += ` label="${layout.label}"`
196
419
  else if (typeof layout.title === 'string') line += ` title="${layout.title}"`
197
420
 
421
+ const children = visibleChildren(node)
422
+
198
423
  // value for leaf nodes (no children or empty children)
199
- if (!node.children || node.children.length === 0) {
200
- line += ` value=${JSON.stringify(node.data)}`
424
+ if (children.length === 0) {
425
+ line += ` value=${abbreviateValue(node.data)}`
426
+ }
427
+
428
+ // Help is the guidance a model cannot infer — that a negative height means automatic
429
+ // sizing, say. Long help is named rather than printed unless this node is the one that
430
+ // was asked about.
431
+ if (typeof node.layout.help === 'string' && node.layout.help) {
432
+ const help = helpToText(node.layout.help)
433
+ if (help.length <= HELP_MAX_LENGTH || depth === 0) line += ` help="${help}"`
434
+ else if (help) line += ` help=<${help.length} chars — describeState ${path} to read it>`
201
435
  }
202
436
 
203
- if (node.error) line += ` — ${node.error}`
437
+ if (error) line += ` — ${error}`
204
438
 
205
439
  const lines = [line]
206
440
 
207
441
  // variants list
208
442
  if (node.layout.comp === 'one-of-select' && Array.isArray(layout.oneOfItems)) {
209
443
  const variants = layout.oneOfItems.filter((item) => !item.header)
210
- for (const v of variants) {
211
- lines.push(`${indent} - variant ${v.key}: ${v.title}`)
444
+ const listedAt = variantsMemo?.listedAt(node.skeleton.pointer)
445
+ if (listedAt === undefined) {
446
+ variantsMemo?.record(node.skeleton.pointer, path)
447
+ for (const v of variants) {
448
+ lines.push(`${indent} - variant ${v.key}: ${v.title}`)
449
+ }
450
+ } else {
451
+ // a recursive schema reaches the same union at many paths; the list is a constant,
452
+ // so name where it was given rather than repeat it
453
+ lines.push(`${indent} - ${variants.length} variants, the same list already given for ${listedAt} — call describeState on ${path} to see them again`)
212
454
  }
213
455
  }
214
456
 
215
457
  // 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))
458
+ for (const child of children) {
459
+ lines.push(projectNodeToMarkdown(child, statefulLayout, depth + 1, errorsByPath, variantsMemo))
460
+ }
461
+
462
+ // fields known from the skeleton but not hydrated in the state tree, skipped on a node fed by
463
+ // getItems: its properties are not separate nodes, it is filled from getFieldSuggestions
464
+ if (children.length === 0 && node.skeleton.children?.length &&
465
+ !isValuePickedFromItems(node, statefulLayout)) {
466
+ for (const field of projectDeclaredFields(node, statefulLayout)) {
467
+ const fieldMeta = ['declared']
468
+ if (field.type) fieldMeta.unshift(field.type)
469
+ if (field.required) fieldMeta.push('required')
470
+ if (field.enum) fieldMeta.push(`enum=${JSON.stringify(field.enum)}`)
471
+ lines.push(`${indent} - ${field.path} (${fieldMeta.join(', ')})`)
220
472
  }
221
473
  }
222
474
 
@@ -227,10 +479,11 @@ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
227
479
  * Format a state tree as markdown for LLM-readable output.
228
480
  * @param {import('../state/types.js').StateTree} stateTree
229
481
  * @param {import('../state/index.js').StatefulLayout} statefulLayout
482
+ * @param {import('./variants-memo.js').VariantsMemo} [variantsMemo]
230
483
  * @returns {string}
231
484
  */
232
- export function projectStateTreeToMarkdown (stateTree, statefulLayout) {
233
- const errors = collectErrors(stateTree.root)
485
+ export function projectStateTreeToMarkdown (stateTree, statefulLayout, variantsMemo) {
486
+ const errors = collectErrors(statefulLayout)
234
487
  const validLine = stateTree.valid
235
488
  ? 'valid: true, no errors'
236
489
  : `valid: false, ${errors.length} error(s)`
@@ -246,7 +499,7 @@ export function projectStateTreeToMarkdown (stateTree, statefulLayout) {
246
499
  }
247
500
 
248
501
  lines.push('Fields:')
249
- lines.push(projectNodeToMarkdown(stateTree.root, statefulLayout, 0))
502
+ lines.push(projectNodeToMarkdown(stateTree.root, statefulLayout, 0, undefined, variantsMemo))
250
503
 
251
504
  return lines.join('\n')
252
505
  }
@@ -254,14 +507,34 @@ export function projectStateTreeToMarkdown (stateTree, statefulLayout) {
254
507
  /**
255
508
  * Format a mutation result as concise text for LLM-readable output.
256
509
  * @param {boolean} valid
257
- * @param {Array<{path: string, message: string}>} errors
510
+ * @param {Array<{path: string, message: string}>} errors - errors of the mutated subtree
258
511
  * @param {string} [prefix] - optional prefix line (e.g. field info)
512
+ * @param {number} [otherErrors] - number of errors of the form outside of the mutated subtree
259
513
  * @returns {string}
260
514
  */
261
- export function formatMutationResult (valid, errors, prefix) {
515
+ export function formatMutationResult (valid, errors, prefix, otherErrors) {
262
516
  const lines = []
263
517
  if (prefix) lines.push(prefix)
264
518
 
519
+ // scoped mode, the errors are the ones of the mutated subtree only
520
+ if (otherErrors !== undefined) {
521
+ if (errors.length === 0) lines.push('no error here')
522
+ else {
523
+ lines.push(`${errors.length} error(s) here:`)
524
+ for (const e of errors) {
525
+ lines.push(`- ${e.path}: ${e.message}`)
526
+ }
527
+ }
528
+ if (otherErrors > 0) {
529
+ lines.push(`form has ${otherErrors} other error(s) elsewhere, use describeState to list them`)
530
+ } else if (!valid) {
531
+ lines.push('form is invalid')
532
+ } else {
533
+ lines.push('form is valid')
534
+ }
535
+ return lines.join('\n')
536
+ }
537
+
265
538
  if (valid) {
266
539
  lines.push('valid, no errors')
267
540
  } else {
@@ -278,46 +551,141 @@ export function formatMutationResult (valid, errors, prefix) {
278
551
  }
279
552
 
280
553
  /**
281
- * Format field suggestions as markdown for LLM-readable output.
554
+ * @typedef {{index: number, title: string, key?: string, value?: unknown, valueOmitted?: boolean, valueLength?: number}} ProjectedSuggestion
555
+ */
556
+
557
+ /**
558
+ * Project suggestions for the tools output: anything but a short scalar is identified by
559
+ * its title and key alone, and referred to by index instead of copied around.
282
560
  * @param {Array<{value: unknown, title: string, key?: string}>} items
561
+ * @param {number} [baseIndex] - index of the first item, as the store assigned it
562
+ * @returns {ProjectedSuggestion[]}
563
+ */
564
+ export function projectSuggestions (items, baseIndex = 0) {
565
+ return items.map((item, index) => {
566
+ /** @type {ProjectedSuggestion} */
567
+ const out = { index: baseIndex + index, title: item.title }
568
+ if (item.key !== undefined && item.key !== item.title) out.key = item.key
569
+ const json = JSON.stringify(item.value)
570
+ if (json === undefined) return out
571
+ // Short scalars stay: agents batch those straight into setData rather than spending a
572
+ // round-trip applying an index. Anything else is identified by its title and key, and
573
+ // applied by index — the value itself never has to reach the agent.
574
+ const isScalar = item.value === null || ['string', 'number', 'boolean'].includes(typeof item.value)
575
+ if (isScalar && json.length <= SUGGESTION_VALUE_MAX_LENGTH) {
576
+ out.value = item.value
577
+ } else {
578
+ out.valueOmitted = true
579
+ out.valueLength = json.length
580
+ }
581
+ return out
582
+ })
583
+ }
584
+
585
+ /**
586
+ * Format field suggestions as markdown for LLM-readable output.
587
+ * @param {ProjectedSuggestion[]} suggestions
588
+ * @param {string} [blockedOn] - the expression the list is waiting on, when it has one
283
589
  * @returns {string}
284
590
  */
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})`)
591
+ export function formatSuggestions (suggestions, blockedOn) {
592
+ if (suggestions.length === 0) {
593
+ 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.`
594
+ 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.'
595
+ }
596
+ const lines = [`${suggestions.length} suggestion(s), apply one with setFieldValue and its suggestionIndex (or copy a short value):`]
597
+ for (const suggestion of suggestions) {
598
+ const title = suggestion.key ? `${suggestion.title} (${suggestion.key})` : suggestion.title
599
+ if (suggestion.valueOmitted) {
600
+ lines.push(`- [${suggestion.index}] ${title} — apply with suggestionIndex=${suggestion.index}`)
292
601
  } else {
293
- lines.push(`- value=${val} — ${item.title}`)
602
+ lines.push(`- [${suggestion.index}] ${title} value=${JSON.stringify(suggestion.value)}`)
294
603
  }
295
604
  }
296
605
  return lines.join('\n')
297
606
  }
298
607
 
299
608
  /**
300
- * @param {import('../state/types.js').StateNode} node
609
+ * The data pointer an ajv error applies to.
610
+ *
611
+ * ajv-errors wraps the original error, and a `required` error reports the parent's
612
+ * pointer with the missing key in its params — so the naive instancePath would be the
613
+ * object, not the field.
614
+ * @param {any} error
615
+ * @returns {string}
616
+ */
617
+ function dataPointerOf (error) {
618
+ const original = error?.params?.errors?.[0] ?? error
619
+ if (original?.keyword === 'required' && original.params?.missingProperty) {
620
+ return `${original.instancePath}/${original.params.missingProperty}`
621
+ }
622
+ return original?.instancePath ?? ''
623
+ }
624
+
625
+ /**
626
+ * Errors of the whole form, each named by the location it actually applies to.
627
+ *
628
+ * A node only carries an error while it is hydrated. A list shows its items in summary
629
+ * mode, so nothing below an unedited item exists as a node, and every error under it
630
+ * collapses onto the list — one message, on a path that is not the faulty one. An agent
631
+ * told "/sections must be integer" knows it is wrong and not where, and retries blind.
632
+ *
633
+ * So a node error that is standing in for deeper errors nobody names is replaced by
634
+ * those errors, addressed by data pointer. Errors a hydrated node does name keep their
635
+ * form path, which is what the mutation tools expect.
636
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
301
637
  * @returns {Array<{path: string, message: string}>}
302
638
  */
303
- export function collectErrors (node) {
639
+ export function collectErrors (statefulLayout) {
640
+ /** @type {Array<{fullKey: string, dataPath: string, message: string}>} */
641
+ const nodeErrors = []
642
+ /** @param {import('../state/types.js').StateNode} node */
643
+ const recurse = (node) => {
644
+ if (node.error) nodeErrors.push({ fullKey: node.fullKey, dataPath: node.dataPath, message: node.error })
645
+ // all children, not visibleChildren: in "menu"/"dialog" list edit modes the two
646
+ // occurrences of an activated item do not carry the same errors, and deduplicating
647
+ // here silently drops them (verified: 2 errors became 0).
648
+ for (const child of node.children ?? []) recurse(child)
649
+ }
650
+ recurse(statefulLayout.stateTree.root)
651
+
652
+ const named = new Set(nodeErrors.map((e) => e.dataPath))
653
+ const unnamed = statefulLayout.validationErrors
654
+ .map((error) => ({ pointer: dataPointerOf(error), message: error.message ?? 'invalid' }))
655
+ .filter((error) => !named.has(error.pointer))
656
+
304
657
  /** @type {Array<{path: string, message: string}>} */
305
658
  const errors = []
306
- collectErrorsRecurse(node, errors)
659
+ for (const nodeError of nodeErrors) {
660
+ const prefix = nodeError.dataPath === '' ? '/' : `${nodeError.dataPath}/`
661
+ const standsInForDeeperErrors = unnamed.some((e) => e.pointer.startsWith(prefix))
662
+ if (!standsInForDeeperErrors) errors.push({ path: nodeError.fullKey, message: nodeError.message })
663
+ }
664
+ for (const error of unnamed) errors.push({ path: error.pointer, message: error.message })
307
665
  return errors
308
666
  }
309
667
 
310
668
  /**
669
+ * Errors of the subtree of a node, and count of the errors of the rest of the form.
670
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
311
671
  * @param {import('../state/types.js').StateNode} node
312
- * @param {Array<{path: string, message: string}>} errors
672
+ * @returns {{ errors: Array<{path: string, message: string}>, otherErrors: number }}
313
673
  */
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
- }
674
+ export function collectScopedErrors (statefulLayout, node) {
675
+ // the path index is used rather than a walk of the subtree: an activated list item is kept
676
+ // twice and the tools resolve to the editable occurrence, which carries no error at all,
677
+ // neither on the item nor on anything below it. Indexing by path merges the two.
678
+ const errorsByPath = indexErrorsByPath(statefulLayout.stateTree.root)
679
+ const prefix = node.fullKey
680
+ const isInScope = (/** @type {string} */path) =>
681
+ prefix === '' || path === prefix || path.startsWith(`${prefix}/`)
682
+
683
+ /** @type {Array<{path: string, message: string}>} */
684
+ const errors = []
685
+ let otherErrors = 0
686
+ for (const [path, message] of Object.entries(errorsByPath)) {
687
+ if (isInScope(path)) errors.push({ path, message })
688
+ else otherErrors++
322
689
  }
690
+ return { errors, otherErrors }
323
691
  }