@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.
- package/package.json +4 -2
- package/src/compile/index.js +3 -1
- package/src/compile/serialize.js +18 -15
- package/src/compile/skeleton-node.js +71 -5
- package/src/compile/types.ts +1 -0
- package/src/compile/utils/resolve-refs.js +5 -8
- package/src/compile/utils/x-i18n.js +15 -6
- package/src/state/index.js +46 -1
- package/src/state/state-node.js +36 -5
- package/src/state/types.ts +6 -0
- package/src/state/utils/urls.js +2 -0
- package/src/utils/json-pointer.js +29 -0
- package/src/webmcp/README.md +144 -0
- package/src/webmcp/index.js +88 -82
- package/src/webmcp/project.js +479 -111
- package/src/webmcp/resolve.js +37 -1
- package/src/webmcp/schema.js +169 -0
- package/src/webmcp/suggestions-store.js +121 -0
- package/src/webmcp/tools/describe-state.js +20 -64
- package/src/webmcp/tools/edit-array.js +51 -28
- package/src/webmcp/tools/fill-form-skill.js +17 -41
- package/src/webmcp/tools/get-data.js +66 -13
- package/src/webmcp/tools/get-field-suggestions.js +12 -23
- package/src/webmcp/tools/set-data.js +79 -28
- package/src/webmcp/tools/set-field-value.js +49 -39
- package/src/webmcp/variants-memo.js +53 -0
- package/types/compile/index.d.ts.map +1 -1
- package/types/compile/serialize.d.ts.map +1 -1
- package/types/compile/skeleton-node.d.ts +9 -2
- package/types/compile/skeleton-node.d.ts.map +1 -1
- package/types/compile/types.d.ts +1 -0
- package/types/compile/types.d.ts.map +1 -1
- package/types/compile/utils/resolve-refs.d.ts.map +1 -1
- package/types/compile/utils/x-i18n.d.ts +1 -1
- package/types/compile/utils/x-i18n.d.ts.map +1 -1
- package/types/state/index.d.ts +23 -0
- package/types/state/index.d.ts.map +1 -1
- package/types/state/state-node.d.ts.map +1 -1
- package/types/state/types.d.ts +2 -0
- package/types/state/types.d.ts.map +1 -1
- package/types/state/utils/urls.d.ts.map +1 -1
- package/types/utils/json-pointer.d.ts +22 -0
- package/types/utils/json-pointer.d.ts.map +1 -0
- package/types/webmcp/index.d.ts +23 -15
- package/types/webmcp/index.d.ts.map +1 -1
- package/types/webmcp/project.d.ts +159 -57
- package/types/webmcp/project.d.ts.map +1 -1
- package/types/webmcp/resolve.d.ts +7 -3
- package/types/webmcp/resolve.d.ts.map +1 -1
- package/types/webmcp/schema.d.ts +44 -0
- package/types/webmcp/schema.d.ts.map +1 -0
- package/types/webmcp/suggestions-store.d.ts +82 -0
- package/types/webmcp/suggestions-store.d.ts.map +1 -0
- package/types/webmcp/tools/describe-state.d.ts +5 -57
- package/types/webmcp/tools/describe-state.d.ts.map +1 -1
- package/types/webmcp/tools/edit-array.d.ts +8 -37
- package/types/webmcp/tools/edit-array.d.ts.map +1 -1
- package/types/webmcp/tools/fill-form-skill.d.ts +10 -13
- package/types/webmcp/tools/fill-form-skill.d.ts.map +1 -1
- package/types/webmcp/tools/get-data.d.ts +17 -15
- package/types/webmcp/tools/get-data.d.ts.map +1 -1
- package/types/webmcp/tools/get-field-suggestions.d.ts +4 -30
- package/types/webmcp/tools/get-field-suggestions.d.ts.map +1 -1
- package/types/webmcp/tools/set-data.d.ts +17 -34
- package/types/webmcp/tools/set-data.d.ts.map +1 -1
- package/types/webmcp/tools/set-field-value.d.ts +20 -57
- package/types/webmcp/tools/set-field-value.d.ts.map +1 -1
- package/types/webmcp/variants-memo.d.ts +42 -0
- package/types/webmcp/variants-memo.d.ts.map +1 -0
package/src/webmcp/project.js
CHANGED
|
@@ -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
|
+
* `'` 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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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 {
|
|
235
|
+
* @returns {boolean}
|
|
72
236
|
*/
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
167
|
-
if (
|
|
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 (
|
|
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 (
|
|
200
|
-
line += ` value=${
|
|
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 (
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
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
|
-
*
|
|
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 (
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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(`-
|
|
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
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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
|
-
* @
|
|
672
|
+
* @returns {{ errors: Array<{path: string, message: string}>, otherErrors: number }}
|
|
313
673
|
*/
|
|
314
|
-
function
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
}
|