@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
@@ -2,6 +2,20 @@
2
2
  * @file Node resolution for webmcp tools
3
3
  */
4
4
 
5
+ /**
6
+ * In "menu" and "dialog" list edit modes the activated item is duplicated at the end
7
+ * of the children, the first occurrence being a read-only summary. Tools should always
8
+ * work on the editable occurrence.
9
+ * @param {import('../state/types.js').StateNode[]} children
10
+ * @param {string | number} key
11
+ * @returns {import('../state/types.js').StateNode|undefined}
12
+ */
13
+ function findChild (children, key) {
14
+ const matches = children.filter((c) => c.key === key)
15
+ if (matches.length <= 1) return matches[0]
16
+ return matches.find((c) => !c.options.summary) ?? matches[0]
17
+ }
18
+
5
19
  /**
6
20
  * Navigate from a root StateNode to a descendant node by path.
7
21
  * @param {import('../state/types.js').StateNode} root
@@ -18,8 +32,30 @@ export function resolveNode (root, path) {
18
32
  for (const segment of segments) {
19
33
  if (!current?.children) return undefined
20
34
  const key = /^\d+$/.test(segment) ? parseInt(segment, 10) : segment
21
- current = current.children.find((c) => c.key === key)
35
+ current = findChild(current.children, key)
22
36
  }
23
37
 
24
38
  return current
25
39
  }
40
+
41
+ /**
42
+ * Children of a node as they should be presented to an agent: hidden nodes are removed
43
+ * and the duplicated activated list item is deduplicated.
44
+ * @param {import('../state/types.js').StateNode} node
45
+ * @returns {import('../state/types.js').StateNode[]}
46
+ */
47
+ export function visibleChildren (node) {
48
+ if (!node.children) return []
49
+ /** @type {import('../state/types.js').StateNode[]} */
50
+ const children = []
51
+ for (const child of node.children) {
52
+ if (child.layout.comp === 'none') continue
53
+ const existingIndex = children.findIndex((c) => c.fullKey === child.fullKey)
54
+ if (existingIndex === -1) {
55
+ children.push(child)
56
+ } else if (children[existingIndex].options.summary && !child.options.summary) {
57
+ children[existingIndex] = child
58
+ }
59
+ }
60
+ return children
61
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @file Sub-schema resolution and skeleton projection for webmcp tools
3
+ * @description Large schemas cannot be serialized as a whole for a LLM agent,
4
+ * these utilities extract the fragment that governs a single node of the form.
5
+ */
6
+
7
+ import { resolvePointerFragment } from '../utils/json-pointer.js'
8
+
9
+ /** @typedef {import('../state/types.js').StateNode} StateNode */
10
+ /** @typedef {import('../state/index.js').StatefulLayout} StatefulLayout */
11
+
12
+ // keys injected in the schema by the compilation step, they are noise for a LLM agent.
13
+ // errorMessage is always written by skeleton-node.js, at least as an empty object; a user
14
+ // defined one survives in the pristine schema, which is preferred and never cleaned.
15
+ const internalSchemaKeys = ['__pointer', 'errorMessage']
16
+
17
+ /**
18
+ * @typedef {{ key: string | number, path: string, type?: string, title?: string, required?: boolean, enum?: unknown[], declared: true }} DeclaredField
19
+ */
20
+
21
+ // keywords whose value maps a name chosen by the user to a sub-schema: their keys are not
22
+ // schema keywords, a property may legitimately be named "errorMessage" or "__pointer"
23
+ const schemaMapKeys = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']
24
+
25
+ /**
26
+ * Deep clone a schema fragment while removing the keys added by the compilation step.
27
+ * @param {unknown} fragment
28
+ * @returns {unknown}
29
+ */
30
+ export function cleanSchemaFragment (fragment) {
31
+ if (Array.isArray(fragment)) return fragment.map(cleanSchemaFragment)
32
+ if (fragment && typeof fragment === 'object') {
33
+ /** @type {Record<string, unknown>} */
34
+ const clean = {}
35
+ for (const [key, value] of Object.entries(fragment)) {
36
+ if (internalSchemaKeys.includes(key)) continue
37
+ clean[key] = schemaMapKeys.includes(key) ? cleanSchemaMap(value) : cleanSchemaFragment(value)
38
+ }
39
+ return clean
40
+ }
41
+ return fragment
42
+ }
43
+
44
+ /**
45
+ * Clean the sub-schemas of a map without filtering the names used as its keys.
46
+ * @param {unknown} map
47
+ * @returns {unknown}
48
+ */
49
+ function cleanSchemaMap (map) {
50
+ if (!map || typeof map !== 'object' || Array.isArray(map)) return cleanSchemaFragment(map)
51
+ /** @type {Record<string, unknown>} */
52
+ const clean = {}
53
+ for (const [name, subSchema] of Object.entries(map)) clean[name] = cleanSchemaFragment(subSchema)
54
+ return clean
55
+ }
56
+
57
+ /**
58
+ * Resolve a schema fragment from a skeleton pointer (ex: "_jl#/properties/filters/items").
59
+ * @param {object} schema - a JSON schema, its $id should match the pointer prefix
60
+ * @param {string} pointer
61
+ * @returns {object|undefined}
62
+ */
63
+ export function resolveSchemaPointer (schema, pointer) {
64
+ const hashIndex = pointer.indexOf('#')
65
+ if (hashIndex === -1) return undefined
66
+ const schemaId = pointer.slice(0, hashIndex)
67
+ const fragment = pointer.slice(hashIndex + 1)
68
+ const schemaObject = /** @type {Record<string, unknown>} */(schema)
69
+ // '_jl' is the default id given by the compilation step to an anonymous schema. A pointer into
70
+ // another schema is refused even when this one has no $id at all: resolving it here would
71
+ // silently return a same-shaped fragment of the wrong document instead of failing over.
72
+ if (schemaId && schemaId !== '_jl' && schemaObject.$id !== schemaId) return undefined
73
+ if (!fragment) return schema
74
+ const resolved = resolvePointerFragment(schema, fragment)
75
+ if (!resolved.found) return undefined
76
+ return resolved.value && typeof resolved.value === 'object' ? /** @type {object} */(resolved.value) : undefined
77
+ }
78
+
79
+ /**
80
+ * Resolve the sub-schema that governs a node of the form.
81
+ * The schema given to the WebMCP instance is preferred (it is pristine),
82
+ * the compiled one is used as a fallback (it is cleaned up before being returned).
83
+ * @param {StateNode} node
84
+ * @param {StatefulLayout} statefulLayout
85
+ * @param {object|null} [originalSchema]
86
+ * @returns {object|undefined}
87
+ */
88
+ export function resolveNodeSchema (node, statefulLayout, originalSchema) {
89
+ const pointers = [node.skeleton.refPointer, node.skeleton.pointer]
90
+ if (originalSchema) {
91
+ for (const pointer of pointers) {
92
+ const fragment = pointer && resolveSchemaPointer(originalSchema, pointer)
93
+ if (fragment) return fragment
94
+ }
95
+ }
96
+ const compiledSchema = statefulLayout.compiledLayout.schema
97
+ if (compiledSchema) {
98
+ for (const pointer of pointers) {
99
+ const fragment = pointer && resolveSchemaPointer(compiledSchema, pointer)
100
+ if (fragment) return /** @type {object} */(cleanSchemaFragment(fragment))
101
+ }
102
+ }
103
+ return undefined
104
+ }
105
+
106
+ /**
107
+ * Component of a node known only from its skeleton, used as a display hint.
108
+ * This is an approximation of the resolution done in state-node.js: a switch is resolved
109
+ * there by evaluating its expressions against the data, which is not available for a node
110
+ * the state tree did not hydrate, so the first case is taken as a representative one.
111
+ * @param {StatefulLayout} statefulLayout
112
+ * @param {string} pointer
113
+ * @returns {string|undefined}
114
+ */
115
+ function getSkeletonComp (statefulLayout, pointer) {
116
+ const normalizedLayout = /** @type {any} */(statefulLayout.compiledLayout.normalizedLayouts[pointer])
117
+ if (!normalizedLayout) return undefined
118
+ if (typeof normalizedLayout.comp === 'string') return normalizedLayout.comp
119
+ if (Array.isArray(normalizedLayout.switch) && typeof normalizedLayout.switch[0]?.comp === 'string') {
120
+ return normalizedLayout.switch[0].comp
121
+ }
122
+ return undefined
123
+ }
124
+
125
+ /**
126
+ * List the fields declared by the skeleton of a node, even when the state tree
127
+ * did not hydrate them yet (a collapsed list item for example).
128
+ * @param {StateNode} node
129
+ * @param {StatefulLayout} statefulLayout
130
+ * @param {object|null} [originalSchema]
131
+ * @returns {DeclaredField[]}
132
+ */
133
+ export function projectDeclaredFields (node, statefulLayout, originalSchema) {
134
+ /** @type {DeclaredField[]} */
135
+ const fields = []
136
+ let childrenKeys = node.skeleton.children ?? []
137
+ let pathPrefix = node.fullKey
138
+ if (childrenKeys.length === 0 && node.skeleton.childrenTrees?.length &&
139
+ (Array.isArray(node.data) || node.layout.comp === 'list')) {
140
+ // an array keeps the skeleton of its items in a separate tree, so the fields it declares
141
+ // are those of an item, addressed through an index
142
+ const itemSkeleton = statefulLayout.compiledLayout.skeletonNodes[node.skeleton.childrenTrees[0]]
143
+ childrenKeys = itemSkeleton?.children ?? []
144
+ pathPrefix = `${node.fullKey}/0`
145
+ }
146
+ for (const childKey of childrenKeys) {
147
+ const childSkeleton = statefulLayout.compiledLayout.skeletonNodes[childKey]
148
+ if (!childSkeleton) continue
149
+ const comp = getSkeletonComp(statefulLayout, childSkeleton.pointer)
150
+ if (comp === 'none') continue
151
+ /** @type {DeclaredField} */
152
+ const field = {
153
+ key: childSkeleton.key,
154
+ path: `${pathPrefix}/${childSkeleton.key}`,
155
+ declared: true
156
+ }
157
+ if (childSkeleton.title) field.title = childSkeleton.title
158
+ if (childSkeleton.required) field.required = true
159
+ const childSchema = /** @type {Record<string, any>|undefined} */(
160
+ (originalSchema && resolveSchemaPointer(originalSchema, childSkeleton.refPointer)) ||
161
+ (statefulLayout.compiledLayout.schema && resolveSchemaPointer(statefulLayout.compiledLayout.schema, childSkeleton.refPointer))
162
+ )
163
+ if (typeof childSchema?.type === 'string') field.type = childSchema.type
164
+ else if (comp) field.type = comp
165
+ if (Array.isArray(childSchema?.enum)) field.enum = childSchema.enum
166
+ fields.push(field)
167
+ }
168
+ return fields
169
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @file Memory of the last suggestions returned for each node path
3
+ * @description Suggestion values can be large objects, they are not returned in full
4
+ * to the agent. They are memorized here so that setFieldValue can reuse the original
5
+ * value from its index.
6
+ *
7
+ * Searches on the same path accumulate rather than replace one another, and indices are
8
+ * absolute across them. Replacing meant a second search silently rebound every index the
9
+ * first had handed out, so an agent applying an index it had been given a moment earlier
10
+ * would set a different value with no error — the worst kind of failure this protocol can
11
+ * produce, since nothing in the transcript looks wrong. A write can change another
12
+ * field's options, so writes drop what they invalidate — see `retainFresh`.
13
+ */
14
+
15
+ /** @typedef {{value: unknown, title: string, key?: string}} SuggestionItem */
16
+
17
+ /**
18
+ * Per WebMCP instance memory of the suggestions, keyed by node path.
19
+ */
20
+ export class SuggestionsStore {
21
+ /**
22
+ * @private
23
+ * @type {Map<string, SuggestionItem[]>}
24
+ */
25
+ _byPath = new Map()
26
+
27
+ /**
28
+ * What the node's items depended on when each path was memorized, so a write can tell
29
+ * whether it invalidated them. See `retainFresh`.
30
+ * @private
31
+ * @type {Map<string, unknown>}
32
+ */
33
+ _cacheKeys = new Map()
34
+
35
+ /**
36
+ * Paths that HAD memorized suggestions until a write dropped them. Kept so that the
37
+ * failure can say which of the two things happened: an agent told to "call
38
+ * getFieldSuggestions first" when it did exactly that, one call ago, cannot tell that
39
+ * the list went stale, and the eval shows it stops trusting suggestionIndex entirely.
40
+ * @private
41
+ * @type {Set<string>}
42
+ */
43
+ _invalidated = new Set()
44
+
45
+ /**
46
+ * Memorize a search's items and return the index its first item was given. Indices are
47
+ * absolute per path, so an index handed out earlier keeps meaning what the agent saw.
48
+ * @param {string} path
49
+ * @param {SuggestionItem[]} items
50
+ * @param {unknown} [cacheKey] - the node's itemsCacheKey, what its options depend on
51
+ * @returns {number} the index of the first of these items
52
+ */
53
+ add (path, items, cacheKey) {
54
+ this._invalidated.delete(path)
55
+ this._cacheKeys.set(path, cacheKey)
56
+ const known = this._byPath.get(path)
57
+ if (!known) {
58
+ this._byPath.set(path, [...items])
59
+ return 0
60
+ }
61
+ const baseIndex = known.length
62
+ known.push(...items)
63
+ return baseIndex
64
+ }
65
+
66
+ /**
67
+ * Drop only the paths a write actually invalidated.
68
+ *
69
+ * Clearing everything on every write was correct but far broader than the hazard it
70
+ * guarded: writing one field cannot change the options of a field whose list does not
71
+ * depend on it, and the eval caught the cost twice on the same case — once recovered in
72
+ * one call, once in three. `isFresh` is given the key recorded at `add` time so the
73
+ * caller can compare it with the node's current `itemsCacheKey`, which is what the state
74
+ * layer itself uses to decide whether to re-fetch: a resolved URL for a remote picker, so
75
+ * the comparison is exact where it matters, and a value that simply differs for an
76
+ * expression-based list, which over-invalidates in the safe direction.
77
+ * @param {(path: string, cacheKey: unknown) => boolean} isFresh
78
+ */
79
+ retainFresh (isFresh) {
80
+ for (const path of [...this._byPath.keys()]) {
81
+ if (isFresh(path, this._cacheKeys.get(path))) continue
82
+ this._byPath.delete(path)
83
+ this._cacheKeys.delete(path)
84
+ this._invalidated.add(path)
85
+ }
86
+ }
87
+
88
+ /**
89
+ * @param {string} path
90
+ * @returns {SuggestionItem[]|undefined}
91
+ */
92
+ get (path) {
93
+ return this._byPath.get(path)
94
+ }
95
+
96
+ /**
97
+ * Get the full original value memorized for a path at a given index.
98
+ * @param {string} path
99
+ * @param {number} index
100
+ * @returns {unknown}
101
+ */
102
+ getValue (path, index) {
103
+ const items = this._byPath.get(path)
104
+ if (!items) {
105
+ if (this._invalidated.has(path)) {
106
+ throw new Error(`the suggestions memorized for path "${path}" were dropped by a write that may have changed this field's options, call getFieldSuggestions on this path again`)
107
+ }
108
+ throw new Error(`no suggestion memorized for path "${path}", call getFieldSuggestions on this path first`)
109
+ }
110
+ if (!Number.isInteger(index) || index < 0 || index >= items.length) {
111
+ throw new Error(`suggestionIndex ${index} out of bounds for path "${path}" (${items.length} suggestion(s) memorized)`)
112
+ }
113
+ return items[index].value
114
+ }
115
+
116
+ clear () {
117
+ for (const path of this._byPath.keys()) this._invalidated.add(path)
118
+ this._byPath.clear()
119
+ this._cacheKeys.clear()
120
+ }
121
+ }
@@ -2,98 +2,54 @@
2
2
  * @file describeState tool
3
3
  */
4
4
 
5
- import { projectStateTree, projectNode, collectErrors, projectNodeToMarkdown, projectStateTreeToMarkdown, formatMutationResult } from '../project.js'
5
+ import { collectScopedErrors, projectNodeToMarkdown, projectStateTreeToMarkdown, formatMutationResult } from '../project.js'
6
6
  import { resolveNode } from '../resolve.js'
7
+ import { VariantsMemo } from '../variants-memo.js'
7
8
 
8
9
  export const inputSchema = {
9
10
  type: 'object',
10
11
  properties: {
11
12
  path: {
12
13
  type: 'string',
13
- description: 'Path to a specific node (e.g. "/address/city"). Omit for full tree.'
14
- }
15
- }
16
- }
17
-
18
- export const outputSchema = {
19
- type: 'object',
20
- properties: {
21
- state: {
22
- type: 'object',
23
- description: 'Projected state tree or single node'
24
- },
25
- valid: {
26
- type: 'boolean'
27
- },
28
- errors: {
29
- type: 'array',
30
- items: {
31
- type: 'object',
32
- properties: {
33
- path: { type: 'string' },
34
- message: { type: 'string' }
35
- }
36
- }
14
+ description: 'Node path as returned by describeState (e.g. "/address/city"). Omit for the whole tree.'
37
15
  }
38
16
  }
39
17
  }
40
18
 
41
19
  /**
42
20
  * @param {string} dataTitle
43
- * @param {"small"|"medium"|"large"} [complexity]
44
21
  * @returns {string}
45
22
  */
46
- export function getDescription (dataTitle, complexity) {
47
- let desc = `Describe the "${dataTitle}" form structure, field types, constraints, and current errors.`
48
- if (complexity === 'large') {
49
- desc += ' Use the "path" parameter to focus on a subtree — avoid calling without a path on large forms.'
50
- }
51
- return desc
52
- }
53
-
54
- /**
55
- * @param {import('../../state/index.js').StatefulLayout} statefulLayout
56
- * @param {{ path?: string }} args
57
- * @returns {{state: ReturnType<typeof projectStateTree>|ReturnType<typeof projectNode>, valid: boolean, errors: Array<{path: string, message: string}>}}
58
- */
59
- export function execute (statefulLayout, args) {
60
- const errors = collectErrors(statefulLayout.stateTree.root)
61
-
62
- if (args.path) {
63
- const node = resolveNode(statefulLayout.stateTree.root, args.path)
64
- if (!node) {
65
- throw new Error(`node not found at path: ${args.path}`)
66
- }
67
- return {
68
- state: projectNode(node, statefulLayout),
69
- valid: statefulLayout.valid,
70
- errors
71
- }
72
- }
73
-
74
- return {
75
- state: projectStateTree(statefulLayout.stateTree, statefulLayout),
76
- valid: statefulLayout.valid,
77
- errors
78
- }
23
+ export function getDescription (dataTitle) {
24
+ return `Describe the "${dataTitle}" form: every field with its path, type, constraints, current value and errors. Pass "path" to describe one subtree instead of the whole form.`
79
25
  }
80
26
 
81
27
  /**
82
28
  * @param {import('../../state/index.js').StatefulLayout} statefulLayout
83
29
  * @param {{ path?: string }} args
30
+ * @param {import('../variants-memo.js').VariantsMemo} [variantsMemo] - updated, not consulted:
31
+ * a read is what the agent asked to see, so every union under it is listed in full, and the
32
+ * memo is what later writes use to avoid repeating those lists
84
33
  * @returns {string}
85
34
  */
86
- export function toMarkdown (statefulLayout, args) {
35
+ export function toMarkdown (statefulLayout, args, variantsMemo) {
36
+ const listed = new VariantsMemo()
37
+
87
38
  if (args.path) {
88
39
  const node = resolveNode(statefulLayout.stateTree.root, args.path)
89
40
  if (!node) {
90
41
  throw new Error(`node not found at path: ${args.path}`)
91
42
  }
92
- const errors = collectErrors(node)
93
- return formatMutationResult(statefulLayout.valid, errors,
94
- projectNodeToMarkdown(node, statefulLayout)
43
+ const { errors, otherErrors } = collectScopedErrors(statefulLayout, node)
44
+ const markdown = formatMutationResult(statefulLayout.valid, errors,
45
+ projectNodeToMarkdown(node, statefulLayout, 0, undefined, listed),
46
+ otherErrors
95
47
  )
48
+ variantsMemo?.merge(listed)
49
+ return markdown
96
50
  }
97
51
 
98
- return projectStateTreeToMarkdown(statefulLayout.stateTree, statefulLayout)
52
+ const markdown = projectStateTreeToMarkdown(statefulLayout.stateTree, statefulLayout, listed)
53
+ variantsMemo?.merge(listed)
54
+ return markdown
99
55
  }
@@ -2,7 +2,7 @@
2
2
  * @file editArray tool
3
3
  */
4
4
 
5
- import { collectErrors } from '../project.js'
5
+ import { collectScopedErrors, projectNodeToMarkdown } from '../project.js'
6
6
  import { resolveNode } from '../resolve.js'
7
7
 
8
8
  export const inputSchema = {
@@ -10,7 +10,7 @@ export const inputSchema = {
10
10
  properties: {
11
11
  path: {
12
12
  type: 'string',
13
- description: 'Path to the array field (e.g. "/items", "/tags")'
13
+ description: 'Node path as returned by describeState (e.g. "/address/city"). Must be an array field.'
14
14
  },
15
15
  action: {
16
16
  type: 'string',
@@ -28,38 +28,23 @@ export const inputSchema = {
28
28
  required: ['path', 'action']
29
29
  }
30
30
 
31
- export const outputSchema = {
32
- type: 'object',
33
- properties: {
34
- valid: { type: 'boolean' },
35
- itemCount: { type: 'number' },
36
- errors: {
37
- type: 'array',
38
- items: {
39
- type: 'object',
40
- properties: {
41
- path: { type: 'string' },
42
- message: { type: 'string' }
43
- }
44
- }
45
- }
46
- }
47
- }
48
-
49
31
  /**
50
32
  * @param {string} dataTitle
51
33
  * @returns {string}
52
34
  */
53
35
  export function getDescription (dataTitle) {
54
- return `Add or remove items in an array field of "${dataTitle}". Use describeState to see current array contents.`
36
+ return `Add or remove items in an array field of "${dataTitle}". Use describeState to see current array contents. When adding an item it is activated for edition and its children fields are returned, they can then be filled with setFieldValue. The returned errors are scoped to this array.`
55
37
  }
56
38
 
57
39
  /**
58
40
  * @param {import('../../state/index.js').StatefulLayout} statefulLayout
59
41
  * @param {{ path: string, action: 'add'|'remove', index?: number, value?: unknown }} args
60
- * @returns {{ valid: boolean, itemCount: number, errors: Array<{path: string, message: string}> }}
42
+ * @param {import('../variants-memo.js').VariantsMemo} [variantsMemo] - a new item of a
43
+ * recursive schema carries the same union as the item that contains it; the memo keeps the
44
+ * response from printing it again
45
+ * @returns {{ valid: boolean, itemCount: number, index: number, itemMarkdown?: string, errors: Array<{path: string, message: string}>, otherErrors: number }}
61
46
  */
62
- export function execute (statefulLayout, args) {
47
+ export function execute (statefulLayout, args, variantsMemo) {
63
48
  const node = resolveNode(statefulLayout.stateTree.root, args.path)
64
49
  if (!node) {
65
50
  throw new Error(`node not found at path: ${args.path}`)
@@ -70,16 +55,24 @@ export function execute (statefulLayout, args) {
70
55
  }
71
56
 
72
57
  const currentData = Array.isArray(node.data) ? [...node.data] : []
58
+ // input() drops the activation when the array shrinks, so it has to be read before
59
+ const activatedBefore = statefulLayout.activatedItems[node.fullKey]
60
+ let index
73
61
 
74
62
  if (args.action === 'add') {
75
- const index = args.index !== undefined ? args.index : currentData.length
63
+ index = args.index !== undefined ? args.index : currentData.length
64
+ // splice() would silently clamp an out of bounds index, but the reported index and the
65
+ // item activated below would then designate an item that does not exist
66
+ if (!Number.isInteger(index) || index < 0 || index > currentData.length) {
67
+ throw new Error(`index ${index} out of bounds (array length: ${currentData.length}, an item can be added at 0 to ${currentData.length})`)
68
+ }
76
69
  currentData.splice(index, 0, args.value !== undefined ? args.value : undefined)
77
70
  } else if (args.action === 'remove') {
78
71
  if (currentData.length === 0) {
79
72
  throw new Error('cannot remove from an empty array')
80
73
  }
81
- const index = args.index !== undefined ? args.index : currentData.length - 1
82
- if (index < 0 || index >= currentData.length) {
74
+ index = args.index !== undefined ? args.index : currentData.length - 1
75
+ if (!Number.isInteger(index) || index < 0 || index >= currentData.length) {
83
76
  throw new Error(`index ${index} out of bounds (array length: ${currentData.length})`)
84
77
  }
85
78
  currentData.splice(index, 1)
@@ -89,9 +82,39 @@ export function execute (statefulLayout, args) {
89
82
 
90
83
  statefulLayout.input(node, currentData)
91
84
 
92
- return {
85
+ // the list components only hydrate the editable children of an item when it is activated,
86
+ // the same activation is applied here so that the agent can fill the new item
87
+ const listEditMode = /** @type {Record<string, unknown>} */(node.layout).listEditMode
88
+ const listNodeAfterInput = resolveNode(statefulLayout.stateTree.root, args.path)
89
+ if (listNodeAfterInput) {
90
+ if (args.action === 'add' && listEditMode !== 'inline') {
91
+ statefulLayout.activateItem(listNodeAfterInput, index)
92
+ } else if (args.action === 'remove' && typeof activatedBefore === 'number' && activatedBefore !== index) {
93
+ // input() dropped the activation, but the item being edited is not the one that was
94
+ // removed: it is restored, shifted down by one when it was after the removed item, so
95
+ // that the agent does not silently lose the item it was filling
96
+ statefulLayout.activateItem(listNodeAfterInput, activatedBefore > index ? activatedBefore - 1 : activatedBefore)
97
+ }
98
+ }
99
+
100
+ const listNode = resolveNode(statefulLayout.stateTree.root, args.path) ?? node
101
+ const { errors, otherErrors } = collectScopedErrors(statefulLayout, listNode)
102
+
103
+ /** @type {{ valid: boolean, itemCount: number, index: number, itemMarkdown?: string, errors: Array<{path: string, message: string}>, otherErrors: number }} */
104
+ const result = {
93
105
  valid: statefulLayout.valid,
94
106
  itemCount: currentData.length,
95
- errors: collectErrors(statefulLayout.stateTree.root)
107
+ index,
108
+ errors,
109
+ otherErrors
110
+ }
111
+
112
+ if (args.action === 'add') {
113
+ const itemNode = resolveNode(statefulLayout.stateTree.root, `${args.path}/${index}`)
114
+ if (itemNode) {
115
+ result.itemMarkdown = projectNodeToMarkdown(itemNode, statefulLayout, 0, undefined, variantsMemo)
116
+ }
96
117
  }
118
+
119
+ return result
97
120
  }
@@ -2,13 +2,6 @@
2
2
  * @file fillFormSkill tool
3
3
  */
4
4
 
5
- export const outputSchema = {
6
- type: 'object',
7
- properties: {
8
- content: { type: 'string' }
9
- }
10
- }
11
-
12
5
  /**
13
6
  * @param {string} dataTitle
14
7
  * @returns {string}
@@ -18,50 +11,33 @@ export function getDescription (dataTitle) {
18
11
  }
19
12
 
20
13
  /**
21
- * Generate skill content with dataTitle injected
14
+ * The one path. There used to be three, chosen by counting normalized layouts and calling
15
+ * the result small, medium or large — a threshold nobody could justify, measuring the
16
+ * schema when the question was about data, and duplicated between here and index.js. Four
17
+ * commits in a row went to fixing contradictions between those branches, and across four
18
+ * recorded baselines the small and medium advice was never taken on a real form: setData
19
+ * was used zero times and getSchema once.
22
20
  * @param {string} dataTitle
23
21
  * @param {string} prefixName
24
- * @param {boolean} hasSchema
25
- * @param {import('../../state/index.js').StatefulLayout} statefulLayout
26
22
  * @returns {string}
27
23
  */
28
- export function generateSkill (dataTitle, prefixName, hasSchema, statefulLayout) {
29
- /** @type {"small" | "medium" | "large"} */
30
- let complexity = 'small'
31
- const nbNormalizedLayouts = Object.keys(statefulLayout.compiledLayout.normalizedLayouts).length
32
- if (nbNormalizedLayouts > 15) complexity = 'medium'
33
- if (nbNormalizedLayouts > 50) complexity = 'large'
34
- let skill = `# JSON ${dataTitle.charAt(0).toUpperCase() + dataTitle.slice(1)} Form-Filling Guide
24
+ export function generateSkill (dataTitle, prefixName) {
25
+ return `# JSON ${dataTitle.charAt(0).toUpperCase() + dataTitle.slice(1)} Form-Filling Guide
35
26
 
36
27
  This guide teaches you how to use tools to fill the data of a form in the user's page.
37
28
 
38
- Always start by getting the current data using ${prefixName}getData.
39
- `
29
+ Start with ${prefixName}describeState. It lists every field with its path, its current value and anything invalid; a value too large to inline is shown as its type and size, with the path to read it. Call it again on a path whenever you need to look at one part of the form.
40
30
 
41
- if (complexity === 'small') {
42
- skill += `
43
- Given the small complexity of this form you should start by reading the full schema definition using ${prefixName}${hasSchema ? 'getSchema' : 'describeState'} and attempt updating the whole data using ${prefixName}setData.
44
- Only use ${prefixName}describeState and iterate with ${prefixName}setFieldValue if you encounter some difficulties with ${prefixName}setData.
45
- `
46
- }
31
+ Then write. If the goal already tells you every value, set them in one call with ${prefixName}setData. Otherwise change one field at a time with ${prefixName}setFieldValue, which is also what you need when a value has to be looked up first or when a field only exists once another has been set. Every write reports whether the form is valid and lists what is wrong, so you rarely need to read anything back.
47
32
 
48
- if (complexity === 'medium') {
49
- skill += `
50
- Given the medium complexity of this form you should start by reading the full schema definition using ${prefixName}${hasSchema ? 'getSchema' : 'describeState'}, if you have a satisfying understanding of the schema you can attempt updating the whole data using ${prefixName}setData at least once.
51
- Then use ${prefixName}describeState and iterate with ${prefixName}setFieldValue.
52
- `
53
- }
33
+ Never invent a value for a field that has a fixed set of accepted ones. ${prefixName}describeState tells you which case you are in: it either states them on the field's line as values=[...], and you write one of those directly, or it marks the field "suggestions", and the list exists only behind a request — then call ${prefixName}getFieldSuggestions, whose description says how to apply what it returns.
54
34
 
55
- if (complexity === 'large') {
56
- skill += `
57
- Given the large complexity of this form you should avoid reading the full schema definition using ${prefixName}${hasSchema ? 'getSchema' : 'describeState'}.
58
- Prefer using ${prefixName}describeState and iterating with ${prefixName}setFieldValue.
59
- `
60
- }
35
+ A field shown as (variant-selector) chooses between shapes rather than between values: ${prefixName}describeState lists its branches under it as "variant N: label", and you switch to one by setting the field to that number with ${prefixName}setFieldValue, which then lists the fields the branch contains.
61
36
 
62
- skill += `
63
- If you encounter getItems definitions in the schema or "suggestions" flags in the state, you must use ${prefixName}getFieldSuggestions to fetch the accepted values, then pass the chosen value directly to ${prefixName}setFieldValue or include it in ${prefixName}setData.
64
- `
37
+ To fill an array, call ${prefixName}editArray with action "add": the new item is activated for edition and the tool returns the fields it contains, then fill them one by one with ${prefixName}setFieldValue.
65
38
 
66
- return skill
39
+ ${prefixName}getData returns the data document itself, whole or one part of it by path, for when you need the values rather than a description of them.
40
+
41
+ The errors returned by ${prefixName}setFieldValue and ${prefixName}editArray are scoped to the node you just modified, other errors of the form are only counted.
42
+ `
67
43
  }