sygnal 6.1.1 → 6.3.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 (92) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +3 -1
  3. package/dist/ai.cjs.js +36 -0
  4. package/dist/ai.cjs.js.map +1 -1
  5. package/dist/ai.esm.js +1 -1
  6. package/dist/astro/server.cjs.js +3 -3
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs +3 -3
  9. package/dist/astro/server.mjs.map +1 -1
  10. package/dist/diagnostics.cjs.js +174 -6
  11. package/dist/diagnostics.cjs.js.map +1 -1
  12. package/dist/diagnostics.esm.js +174 -6
  13. package/dist/diagnostics.esm.js.map +1 -1
  14. package/dist/guide/adapters.md +2 -1
  15. package/dist/guide/agent.md +119 -7
  16. package/dist/guide/ai-cards.md +319 -0
  17. package/dist/guide/ai-chat.md +64 -16
  18. package/dist/guide/ai-compose.md +881 -0
  19. package/dist/guide/ai-decisions.md +17 -1
  20. package/dist/guide/behaviors.md +1 -1
  21. package/dist/guide/error-boundaries.md +2 -1
  22. package/dist/guide/forms.md +1 -1
  23. package/dist/guide/http.md +5 -4
  24. package/dist/guide/mcp-apps.md +4 -2
  25. package/dist/guide/persistence.md +1 -1
  26. package/dist/guide/recipes/ai-form-fill.md +1 -1
  27. package/dist/guide/recipes/ai-summarize.md +1 -1
  28. package/dist/guide/recipes/ai-support-inbox.md +1 -1
  29. package/dist/guide/resources.md +11 -10
  30. package/dist/guide/ui/combobox.md +1 -1
  31. package/dist/guide/undo.md +1 -1
  32. package/dist/guide/web-components.md +1 -0
  33. package/dist/guide/webmcp.md +1 -2
  34. package/dist/index.cjs.js +5842 -370
  35. package/dist/index.cjs.js.map +1 -1
  36. package/dist/index.d.ts +679 -13
  37. package/dist/index.esm.js +5832 -370
  38. package/dist/index.esm.js.map +1 -1
  39. package/dist/sygnal.min.js +1 -1
  40. package/dist/sygnal.min.js.map +1 -1
  41. package/dist/vike/config/package.json +1 -1
  42. package/dist/vike/onRenderClient.cjs.js +3 -2
  43. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  44. package/dist/vike/onRenderClient.mjs +3 -2
  45. package/dist/vike/onRenderClient.mjs.map +1 -1
  46. package/llms.txt +8 -6
  47. package/package.json +2 -1
  48. package/src/ai.d.ts +683 -12
  49. package/src/ai.ts +6 -0
  50. package/src/collection.ts +2 -1
  51. package/src/core/instance.ts +6 -3
  52. package/src/core/markers/lazy.ts +7 -3
  53. package/src/core/markers/portal.ts +5 -2
  54. package/src/core/markers/suspense.ts +3 -2
  55. package/src/core/markers/transition.ts +6 -3
  56. package/src/core/registry.ts +9 -5
  57. package/src/cycle/dom/IsolateModule.ts +25 -22
  58. package/src/extra/ai/agent/index.ts +111 -26
  59. package/src/extra/ai/chat/behavior.ts +368 -87
  60. package/src/extra/ai/chat/driver.ts +7 -4
  61. package/src/extra/ai/compose/agent.ts +55 -0
  62. package/src/extra/ai/compose/ask.ts +147 -0
  63. package/src/extra/ai/compose/behavior.ts +183 -0
  64. package/src/extra/ai/compose/catalog.ts +211 -0
  65. package/src/extra/ai/compose/composed.ts +61 -0
  66. package/src/extra/ai/compose/decisions.ts +1649 -0
  67. package/src/extra/ai/compose/diag.ts +33 -0
  68. package/src/extra/ai/compose/index.ts +23 -0
  69. package/src/extra/ai/compose/layout.ts +441 -0
  70. package/src/extra/ai/compose/llm.ts +1120 -0
  71. package/src/extra/ai/compose/render.ts +159 -0
  72. package/src/extra/ai/compose/testing.ts +71 -0
  73. package/src/extra/ai/compose/types.ts +159 -0
  74. package/src/extra/ai/decide.ts +97 -1
  75. package/src/extra/ai/index.ts +6 -1
  76. package/src/extra/ai/webmcp.ts +5 -3
  77. package/src/extra/diagnostics/checks/next.ts +86 -2
  78. package/src/extra/diagnostics/codes.ts +64 -3
  79. package/src/extra/fetchDriver.ts +58 -27
  80. package/src/extra/lens.ts +15 -0
  81. package/src/extra/ssr.ts +3 -3
  82. package/src/extra/testing.ts +2 -0
  83. package/src/extra/widget.ts +3 -3
  84. package/src/index.d.ts +42 -1
  85. package/src/index.ts +8 -0
  86. package/src/lazy.ts +3 -2
  87. package/src/portal.ts +5 -2
  88. package/src/pragma/index.ts +2 -1
  89. package/src/suspense.ts +5 -2
  90. package/src/switchable.ts +2 -1
  91. package/src/transition.ts +5 -2
  92. package/src/vike/onRenderClient.ts +3 -2
@@ -0,0 +1,159 @@
1
+ /*
2
+ * PLAN-9 K-2: catalog.render(slice, state, { scope?, frozen?, answer?, at? }), the composition as
3
+ * vnodes for the host's own view (phase1-contract.md). It renders `slice.preview ?? slice.layout`.
4
+ *
5
+ * - A part is a component vnode, so every composed part is a child instance of the host (CHILD.select,
6
+ * lenses, EVENTS, context and drivers work as for any child). It gets:
7
+ * - its identity: `key` = `scope/nodeKey` (or `nodeKey`); the core keys instances by the vnode key
8
+ * (D470: `key ?? props.id ?? path`), so params, including one named `id`, reach the part as props;
9
+ * - its state: `state: 'key'` as is, a lens as is, `(node, key) => lens` called, `local: init` a lens
10
+ * into `slice.local[nodeKey]` (at the slice's path `at`); each lens is memoized per node (G-752),
11
+ * so a part re-renders only when its own state, props or params change;
12
+ * - its params, defaults filled, as props (or `props(params, state)`; an object `props` is merged
13
+ * under them), and the props `node` (its key), and `scope` / `frozen` / `answer` when given (a
14
+ * result part's answer: `answer.value` when `answer.node` is its key).
15
+ * A part declared with `render` (a view function) is called inline with the same props and
16
+ * `state` read through its lens.
17
+ * - A container calls its view with `{ key, node, params (defaults filled), slots, children, ui, control, scope, frozen }`:
18
+ * `slots` maps each slot to its vnodes, `children` lists `{ key, node, part, slot, vnode }` in
19
+ * order, `ui` is `slice.ui[key]`, and `control(name, value)` returns data attributes to spread on an
20
+ * element; a click on it is the behavior's CONTROL `{ key, name, value }` (ui[key][name] = value).
21
+ * - A frozen render is wrapped in `<fieldset disabled class="compose-frozen">`.
22
+ * - A container view or `props()` that throws: only the composition is replaced (a `.compose-error`
23
+ * element), SYG406. Under `<Composed>` (composed.ts) the throw goes on to the host's `onError`
24
+ * instead, rendered in place of the composition (SYG408). An unknown part or slot is skipped (SYG250, once per catalog and name).
25
+ */
26
+ import {createElement as h} from '../../../pragma/index'
27
+ import {error as logError} from '../../diagnostics/legacy'
28
+ import {dev} from './diag'
29
+ import type {Catalog, Part, RenderOptions} from './types'
30
+
31
+ const getIn = (o: any, path: any[]) => path.reduce((x, k) => x?.[k], o)
32
+ const setIn = (o: any, path: any[], v: any): any => {
33
+ const [k, ...rest] = path
34
+ const base = Array.isArray(o) ? o.slice() : {...o}
35
+ base[k] = rest.length ? setIn(o?.[k] ?? {}, rest, v) : v
36
+ return base
37
+ }
38
+
39
+ /** the memoized lenses (G-752), per catalog: (at, scope, node key, part, params) → lens */
40
+ const memo = /*#__PURE__*/ new WeakMap<object, Map<string, any>>()
41
+ const MAX = 2000
42
+
43
+ function lensOf(cat: Catalog, part: Part, node: any, key: string, at: any[] | null, scope?: string): any {
44
+ if (!part.hasLocal && typeof part.state != 'function') return part.state
45
+ let m = memo.get(cat)
46
+ if (!m) memo.set(cat, m = new Map())
47
+ const id = JSON.stringify([at, scope ?? null, key, part.id, part.hasLocal ? 0 : node.params ?? null])
48
+ let l = m.get(id)
49
+ if (l !== undefined) return l
50
+ if (part.hasLocal) {
51
+ const init = part.local, path = at ? [...at, 'local', key] : null
52
+ l = {
53
+ get: (st: any) => (path ? getIn(st, path) : undefined) ?? init,
54
+ set: (st: any, v: any) => (path ? setIn(st, path, v) : st),
55
+ }
56
+ } else l = (part.state as any)(node, key)
57
+ if (m.size > MAX) m.clear()
58
+ m.set(id, l)
59
+ return l
60
+ }
61
+
62
+ /** a part's params with the defaults filled in */
63
+ function paramsOf(part: {params: Record<string, any>}, node: any): Record<string, any> {
64
+ const out: any = {}
65
+ for (const k in part.params) {
66
+ const v = node.params?.[k] ?? (part.params[k] as any).default
67
+ if (v !== undefined) out[k] = v
68
+ }
69
+ for (const k in node.params) if (!(k in out)) out[k] = node.params[k]
70
+ return out
71
+ }
72
+
73
+ /** the slice's path in the host state: `at`, else the host key whose value is the slice */
74
+ function pathOf(slice: any, state: any, at: RenderOptions['at']): any[] | null {
75
+ if (Array.isArray(at)) return at
76
+ if (typeof at == 'string') return [at]
77
+ if (state && typeof state == 'object') for (const k in state) if (state[k] === slice) return [k]
78
+ return null
79
+ }
80
+
81
+ /** `contain` false (the `<Composed>` marker): a throw reaches the caller, which renders the host's onError (SYG408) */
82
+ export function renderLayout(cat: Catalog, slice: any, state: any, opts: RenderOptions = {}, contain = true): any {
83
+ const layout = slice?.preview ?? slice?.layout
84
+ if (!layout || typeof layout != 'object' || !layout.nodes) return null
85
+ const {scope, frozen, answer} = opts
86
+ const at = pathOf(slice, state, opts.at), atStr = at ? at.join('.') : undefined
87
+ const nodes = layout.nodes, seen = new Set<string>()
88
+ const extra: any = {}
89
+ if (scope !== undefined) extra.scope = scope
90
+ if (frozen) extra.frozen = true
91
+
92
+ const nodeOf = (key: string): any => {
93
+ const n = nodes[key]
94
+ if (!n || seen.has(key)) return null
95
+ seen.add(key)
96
+ const e: any = cat.get(n.part)
97
+ if (!e) { dev('SYG250', 'catalog.render', `the layout names an unknown part '${n.part}' (node '${key}'); it is not rendered`, 'warn', cat, 'render:' + n.part); return null }
98
+ if (e.kind == 'container') {
99
+ const slots: Record<string, any[]> = {}, children: any[] = []
100
+ for (const s of e.slotNames) slots[s] = []
101
+ for (const s in n.slots) {
102
+ if (!slots[s]) { dev('SYG250', 'catalog.render', `node '${key}' (${e.id}) has no slot '${s}'; its children are not rendered`, 'warn', cat, `render:${e.id}:${s}`); continue }
103
+ for (const k of n.slots[s] || []) {
104
+ const vnode = nodeOf(k)
105
+ if (vnode == null) continue
106
+ slots[s].push(vnode)
107
+ children.push({key: k, node: nodes[k], part: cat.get(nodes[k].part), slot: s, vnode})
108
+ }
109
+ }
110
+ const control = (name: string, value?: any) => ({
111
+ 'data-compose-control': name, 'data-compose-key': key, 'data-compose-value': JSON.stringify(value ?? null),
112
+ ...(atStr !== undefined && {'data-compose-at': atStr}), ...(scope !== undefined && {'data-compose-scope': scope}),
113
+ })
114
+ return e.render({key, node: n, params: paramsOf(e, n), slots, children, ui: slice.ui?.[key] || {}, control, scope, frozen: !!frozen})
115
+ }
116
+ const part = e as Part
117
+ // H-2: a result part answers a card (chat's askCard gives the call id as scope); elsewhere its answer reaches no one
118
+ if (part.result && scope === undefined) dev('SYG160', 'catalog.render', `the result part '${part.id}' is rendered outside a card (no scope), so its answer reaches no tool call`, 'warn', cat, 'result:' + part.id)
119
+ const params = paramsOf(part, n)
120
+ const props = typeof part.props == 'function' ? part.props(params, state) : {...part.props, ...params}
121
+ const lens = lensOf(cat, part, n, key, at, scope)
122
+ const own = {...props, ...extra, node: key, ...(answer && answer.node === key && {answer: answer.value})}
123
+ if (part.render) return part.render({...own, state: lens === undefined ? state : typeof lens == 'string' ? state?.[lens] : lens.get(state)})
124
+ if (!part.component) return null
125
+ const id = scope !== undefined ? `${scope}/${key}` : key
126
+ // identity from the vnode key (D470: key ?? props.id ?? path), so a param named `id` reaches the part
127
+ return h(part.component, {...own, ...(lens !== undefined && {state: lens}), key: id})
128
+ }
129
+
130
+ let out: any
131
+ try { out = nodeOf(layout.root || 'page') } catch (err) {
132
+ if (!contain) throw err
133
+ logError('SYG406', 'catalog.render', 'a container view or a part\'s props() threw; the composition is replaced by an empty .compose-error element', 'Check the container\'s render and the part\'s props for missing data', err)
134
+ return h('div', {className: 'compose-error'})
135
+ }
136
+ return frozen ? h('fieldset', {disabled: true, className: 'compose-frozen'}, out) : out
137
+ }
138
+
139
+ /**
140
+ * H-2: an `ask_user` tool part's card as `[slice, options]` for catalog.render (askCard and
141
+ * `<Composed card={part} />`): the call id as scope, frozen unless the card is open, the answer
142
+ * marked, `at` the part's `ui` in the chat `k` (so `local` parts write to it). Null without a layout.
143
+ */
144
+ export function cardArgs(state: any, part: any, k = 'assistant'): [any, RenderOptions] | null {
145
+ const u = part?.ui
146
+ if (!u?.layout) return null
147
+ const msgs = state?.[k]?.messages || []
148
+ let at: any[] | undefined
149
+ for (let i = msgs.length - 1; i >= 0 && !at; i--) {
150
+ const j = (msgs[i].parts || []).findIndex((x: any) => x.toolCallId === part.toolCallId && x.ui)
151
+ if (j >= 0) at = [k, 'messages', i, 'parts', j, 'ui']
152
+ }
153
+ return [u, {
154
+ scope: part.toolCallId,
155
+ frozen: part.state !== 'input-available' || u.status !== 'open',
156
+ answer: u.answer ? {node: u.answer.node, value: u.answer.value} : null,
157
+ ...(at && {at}),
158
+ }]
159
+ }
@@ -0,0 +1,71 @@
1
+ /*
2
+ * PLAN-9 T-1: composeWith(t, catalog, tree, { stream? }), a test fixture for a whole composition,
3
+ * the same for every backend:
4
+ *
5
+ * t.simulateAction('page.COMPOSE', 'Revenue and churn side by side')
6
+ * await composeWith(t, catalog, [{ part: 'row', children: [{ part: 'revenue' }, { part: 'churn' }] }])
7
+ *
8
+ * It finds the composition of `catalog` that is waiting for a reply (a `compose` use whose slice
9
+ * has `pending`) and answers each of its steps with the backend's `fake()` (the reply body that
10
+ * leads to `tree`) on the backend's sink, targeted by the use's reply action (`page.REPLY`):
11
+ * `t.respond`, or, with `stream: n` (true: 3) and a text body, `t.stream` in n chunks (an LLM
12
+ * backend's JSON). It resolves once no step is pending.
13
+ *
14
+ * H-2: a card of `chat({ ask: { catalog } })` being composed (an `ask_user` part whose `ui` has
15
+ * `pending`) is answered the same way, targeted by the chat's CARD action ('assistant.CARD'). A
16
+ * compose use waiting goes first.
17
+ */
18
+ import {usesOf} from './behavior'
19
+ import {cardsOf} from './ask'
20
+ import type {Tree} from './types'
21
+
22
+ const err = (m: string) => new Error(`[Sygnal] composeWith: ${m}`)
23
+
24
+ /** a step waiting for a reply: its backend, the reply action, the pending step, and how to find the next one */
25
+ type Cur = {backend: any; target: string; pending: any; next: () => Cur | undefined} | undefined
26
+
27
+ export async function composeWith(t: any, catalog: any, tree: Tree, options: {stream?: number | boolean} = {}): Promise<void> {
28
+ const entries = usesOf(catalog), cards = cardsOf(catalog)
29
+ if (!entries.length && !cards.length) throw err('no compose behavior uses this catalog (compose(catalog, { backend }) in a component\'s uses, or chat({ ask: { catalog } }))')
30
+ const use = (e: (typeof entries)[number], k: string, s: any): Cur => ({backend: e.backend, target: k + '.REPLY', pending: s.pending, next: () => {
31
+ const n = t.state?.[k]
32
+ return n?.pending != null && n.pending !== s.pending ? use(e, k, n) : undefined
33
+ }})
34
+ // the card being composed in a chat's last message (cards compose one at a time)
35
+ const cardOf = (backend: any, k: string, id?: string): Cur => {
36
+ const msgs = t.state?.[k]?.messages, m = Array.isArray(msgs) ? msgs[msgs.length - 1] : null
37
+ const p = m?.role == 'assistant' && (m.parts || []).find((x: any) => x.ui?.status == 'composing' && x.ui.pending != null && x.state == 'input-available' && (id === undefined || x.toolCallId === id))
38
+ if (!p) return
39
+ return {backend, target: k + '.CARD', pending: p.ui.pending, next: () => { const n = cardOf(backend, k, p.toolCallId); return n && n.pending !== p.ui.pending ? n : undefined }}
40
+ }
41
+ // the use that made the pending step (several hosts may share the catalog and the key)
42
+ const find = (): Cur => {
43
+ let any: Cur
44
+ for (const e of entries) for (const k of e.keys) {
45
+ const s = t.state?.[k]
46
+ if (s?.pending == null) continue
47
+ if (typeof s.pending == 'object' && e.mine.has(s.pending)) return use(e, k, s)
48
+ any ||= use(e, k, s)
49
+ }
50
+ if (any) return any
51
+ for (const c of cards) for (const k of c.keys) {
52
+ const x = cardOf(c.backend, k)
53
+ if (x && c.mine.has(x.pending)) return x
54
+ any ||= x
55
+ }
56
+ return any
57
+ }
58
+ let cur: Cur = find()
59
+ if (!cur) { await t.settle(); cur = find() }
60
+ if (!cur) throw err('no composition is waiting for a reply (send COMPOSE first, or let the chat model call ask_user)')
61
+ for (let step = 0; cur && step < 16; step++) {
62
+ const {backend, target, pending} = cur
63
+ const sink: string = backend.sink
64
+ const body = backend.fake({catalog, state: t.state, pending, target: tree})
65
+ if (options.stream && typeof body == 'string') {
66
+ const n = Math.max(1, options.stream === true ? 3 : Number(options.stream) || 1), size = Math.ceil(body.length / n) || 1
67
+ for (let i = 0; i < n; i++) await t.stream(sink, [body.slice(i * size, (i + 1) * size)], target, {end: i == n - 1})
68
+ } else await t.respond(sink, body, target)
69
+ cur = cur.next()
70
+ }
71
+ }
@@ -0,0 +1,159 @@
1
+ /*
2
+ * PLAN-9 (phase1-contract.md): the internal shapes shared by the compose modules (catalog, layout,
3
+ * render, the behavior, the backends). Public, inferred types: src/ai.d.ts (`// ---- PLAN-9
4
+ * compose`). Type-only: this module emits nothing.
5
+ */
6
+
7
+ /**
8
+ * a param declaration, normalized: the canonical form every module reads (layout's checks, render,
9
+ * both backends). `kind` replaces the declaration's discriminator (`options` → 'options',
10
+ * `type: 'number' | 'text' | 'list'` → that kind (`type` itself is not kept), `from` → 'record');
11
+ * only the fields listed here are kept. An options param always has a `default` (its first option when none is
12
+ * given); `required` is a boolean on text and list params.
13
+ *
14
+ * options: { kind: 'options', options: { value: description }, default, label?, ask? }
15
+ * number: { kind: 'number', min?, max?, default?, bins?: { number: description }, label? }
16
+ * text: { kind: 'text', maxLength?, default?, required, label? }
17
+ * list: { kind: 'list', items?: { maxLength? }, min?, max?, required, default?, label? }
18
+ * record: { kind: 'record', from: (state) => [{ value, name?, aliases?, description? }], label? }
19
+ */
20
+ export type Param =
21
+ | {kind: 'options'; options: Record<string, string>; default?: any; label?: string; ask?: string}
22
+ | {kind: 'number'; min?: number; max?: number; default?: number; bins?: Record<string, string>; label?: string}
23
+ | {kind: 'text'; maxLength?: number; default?: string; required?: boolean; label?: string}
24
+ | {kind: 'list'; items?: {maxLength?: number}; min?: number; max?: number; required?: boolean; default?: string[]; label?: string}
25
+ | {kind: 'record'; from: (state: any) => any[]; label?: string; default?: undefined}
26
+
27
+ /** a catalog part, normalized */
28
+ export interface Part {
29
+ id: string
30
+ kind: 'part'
31
+ component?: any
32
+ render?: (props: any) => any
33
+ props?: Record<string, any> | ((params: Record<string, any>, state: any) => Record<string, any>)
34
+ description: string
35
+ state?: string | {get: Function; set?: Function} | ((node: Node, key: string) => any)
36
+ /** `local: init`: state kept at slice.local[nodeKey] */
37
+ local?: any
38
+ hasLocal: boolean
39
+ params: Record<string, Param>
40
+ /** the record param's name (at most one per part) */
41
+ record?: string
42
+ /** the places it may go: 'container:slot' entries (the root is 'page:default') */
43
+ in: string[]
44
+ group?: string
45
+ maxUses: number
46
+ required: boolean
47
+ pin?: 'start' | 'end'
48
+ companions: string[]
49
+ category?: string
50
+ result: boolean
51
+ /** everything the declaration had, for backends that read extra fields */
52
+ decl: Record<string, any>
53
+ }
54
+
55
+ /** a catalog container (a view function with named slots), normalized */
56
+ export interface Container {
57
+ id: string
58
+ kind: 'container'
59
+ slots: Record<string, string | null>
60
+ slotNames: string[]
61
+ minSlots?: number
62
+ /** children per slot: fewer than `min` unwraps it, more than `max` overflow (a row splits) */
63
+ min?: number
64
+ max?: number
65
+ /** 'leaves': only parts in its slots (nested containers go after it) */
66
+ accepts?: 'leaves'
67
+ params: Record<string, Param>
68
+ arranged: boolean
69
+ direction?: 'row' | 'column'
70
+ description?: string
71
+ render: (a: any) => any
72
+ /** where the container itself may go ('container:slot' entries) */
73
+ in: string[]
74
+ decl: Record<string, any>
75
+ }
76
+
77
+ export type Entry = Part | Container
78
+
79
+ /** one node of a layout */
80
+ export interface Node {
81
+ part: string
82
+ params?: Record<string, any>
83
+ slots?: Record<string, string[]>
84
+ }
85
+
86
+ /** the layout: a node map with identity keys */
87
+ export interface Layout {
88
+ root: 'page'
89
+ nodes: Record<string, Node>
90
+ }
91
+
92
+ /** the nested form a model writes and tests use */
93
+ export type TreeNode = {part: string; params?: Record<string, any>; children?: Tree | Record<string, Tree>}
94
+ export type Tree = TreeNode[]
95
+
96
+ export interface Option {option: string; part: string; params?: Record<string, any>; description: string}
97
+
98
+ export interface Catalog {
99
+ parts: Record<string, Part>
100
+ containers: Record<string, Container>
101
+ get(id: string): Entry | undefined
102
+ options(state: any): Option[]
103
+ /** a record part's records (validated `from`: SYG252) */
104
+ records(part: Part | string, state: any): Array<{value: any; name?: string; aliases?: string[]; description?: string}>
105
+ finish(layout: Layout, state?: any): Layout
106
+ /** the page's label (B: `containers.page.description ?? catalog.description`) */
107
+ description?: string
108
+ categories: Record<string, string>
109
+ /** false: the catalog opted out of the built-in row */
110
+ rows: boolean
111
+ guidance?: string
112
+ /**
113
+ * maxParts: leaves on the page (finish drops the last non-required ones). maxDepth: how deep a
114
+ * CONTAINER may sit, counting the page as 0 and its children as 1: a container at depth d > maxDepth
115
+ * moves to the page (finish; structure/composer.mjs's rule). Both backends follow it: decisions'
116
+ * reading, and llm's schema and repair (a container is offered at depth d only when d <= maxDepth).
117
+ * Leaves may sit one level below the deepest container. (The research llm-composer.mjs counted
118
+ * leaves instead: its maxDepth m is this one's m - 1.)
119
+ */
120
+ limits: {maxParts: number; maxDepth: number}
121
+ examples: any[]
122
+ render(slice: any, state: any, opts?: RenderOptions): any
123
+ }
124
+
125
+ export interface RenderOptions {
126
+ scope?: string
127
+ frozen?: boolean
128
+ answer?: {node: string; value: any} | null
129
+ /** the slice's path in the host state (default: found by identity among the host's keys) */
130
+ at?: string | Array<string | number>
131
+ }
132
+
133
+ /** the reply action names a backend's requests carry */
134
+ export interface Names {ok: string; error: string; delta: string; key: string}
135
+
136
+ /** a composition backend (phase1-contract.md: A defines it, B and C implement it) */
137
+ export interface Backend {
138
+ sink: string
139
+ start(a: {catalog: Catalog; state: any; prompt: string; previous: Layout | null; names: Names}): {request: any; pending: any}
140
+ reply(a: {catalog: Catalog; state: any; pending: any; reply: any; names: Names}):
141
+ {layout?: Layout | null; unavailable?: true; preview?: Layout; request?: any; pending?: any; decisions?: unknown; error?: string; keyed?: boolean}
142
+ delta?(a: {catalog: Catalog; state: any; pending: any; delta: any}): {preview?: Layout; keyed?: boolean}
143
+ fake(a: {catalog: Catalog; state: any; pending: any; target: Tree}): unknown
144
+ }
145
+
146
+ export type Status = 'idle' | 'composing' | 'preview' | 'done' | 'unavailable' | 'error'
147
+
148
+ /** the compose behavior's slice */
149
+ export interface ComposeSlice {
150
+ prompt: string
151
+ status: Status
152
+ layout: Layout | null
153
+ preview: Layout | null
154
+ pending: any
155
+ decisions: unknown
156
+ error: string | null
157
+ ui: Record<string, Record<string, any>>
158
+ local: Record<string, any>
159
+ }
@@ -44,6 +44,102 @@ function dictionary({url = '/api/decide', model, state, questions, images, ...re
44
44
  return {url, method: 'POST', json, ...rest};
45
45
  }
46
46
 
47
+ // ---------- D472 / G-757: large question sets ----------
48
+ /**
49
+ * A conservative estimate of the prompt tokens of a request's question (Ollama /v1/systemone: each
50
+ * question's prompt holds the state and EVERY question of the request, instructions and options).
51
+ * Measured on nimble over compose.decisions()'s requests: 1.13–1.30 × the count Ollama reports
52
+ * (ASCII ~2.6 characters a token; any other character counted as one).
53
+ */
54
+ const tokensOf = (x: any) => {
55
+ const t = text(x ?? '');
56
+ let other = 0;
57
+ for (let i = 0; i < t.length; i++) if (t.charCodeAt(i) > 127) other++;
58
+ return (t.length - other) / 2.5 + other;
59
+ };
60
+ const questionTokens = (name: string, q: any) => {
61
+ const c = q?.criteria, opts: any[] = Array.isArray(c) ? c.map(x => [x, null]) : Object.entries(c && typeof c == 'object' ? c : {});
62
+ return 12 + 4 * opts.length + tokensOf(name) + tokensOf(q?.instructions) + opts.reduce((n, [k, v]) => n + tokensOf(k) + (v == null ? 0 : tokensOf(v)), 0);
63
+ };
64
+ /** the estimated prompt tokens of one question of a request with this state and these questions */
65
+ export const promptTokens = (state: any, questions: any) => Math.ceil(60 + tokensOf(state ?? '') +
66
+ Object.keys(questions || {}).reduce((n, k) => n + questionTokens(k, questions[k]), 0));
67
+
68
+ /**
69
+ * The questions in parts within `maxQuestions` and `maxPromptTokens` (the estimate): one part when
70
+ * they fit; else the fewest parts of about even size (by the estimate), in question order. A
71
+ * question too large on its own goes alone.
72
+ */
73
+ export function splitQuestions(state: any, questions: any, maxQuestions = Infinity, maxPromptTokens = Infinity): string[][] {
74
+ const all = Object.keys(questions || {});
75
+ if (all.length <= maxQuestions && (maxPromptTokens === Infinity || promptTokens(state, questions) <= maxPromptTokens)) return [all];
76
+ const room = maxPromptTokens - promptTokens(state, {});
77
+ const cost = all.map(n => questionTokens(n, questions[n]));
78
+ const total = cost.reduce((s, x) => s + x, 0);
79
+ const fits = (a: number, b: number) => b - a == 1 || (b - a <= maxQuestions && cost.slice(a, b).reduce((s, x) => s + x, 0) <= room);
80
+ for (let k = Math.max(2, Math.ceil(all.length / maxQuestions), Math.ceil(total / Math.max(1, room))); k < all.length; k++) {
81
+ // k parts: a cut where the running estimate passes each j/k of the total, or every ceil(n/k) questions
82
+ const cuts = [0];
83
+ for (let i = 0, acc = 0; i < all.length - 1; i++) {
84
+ acc += cost[i];
85
+ if (cuts.length < k && (acc >= total * cuts.length / k || i + 1 - cuts[cuts.length - 1] >= Math.ceil(all.length / k))) cuts.push(i + 1);
86
+ }
87
+ cuts.push(all.length);
88
+ if (cuts.every((c, j) => !j || fits(cuts[j - 1], c))) return cuts.slice(1).map((c, j) => all.slice(cuts[j], c));
89
+ }
90
+ return all.map(n => [n]);
91
+ }
92
+
93
+ const pick = (questions: any, names: string[]) => Object.fromEntries(names.map(n => [n, questions[n]]));
94
+ const sum = (a: any, b: any): any => {
95
+ if (typeof a == 'number' && typeof b == 'number') return a + b;
96
+ if (a && b && typeof a == 'object' && typeof b == 'object' && !Array.isArray(a)) {
97
+ const out: any = {...a};
98
+ for (const k of Object.keys(b)) out[k] = k in a ? sum(a[k], b[k]) : b[k];
99
+ return out;
100
+ }
101
+ return a ?? b;
102
+ };
103
+ /** the replies of a split decision as one: `answers` merged by name, `usage` summed, the rest (`model`) of the first */
104
+ export const combineDecisions = (bodies: any[]) => bodies.length == 1 ? bodies[0] : bodies.reduce((a, b) => ({
105
+ ...b, ...a,
106
+ answers: {...a?.answers, ...b?.answers},
107
+ ...((a?.usage || b?.usage) && {usage: sum(a?.usage, b?.usage)}),
108
+ }));
109
+ /**
110
+ * The reactive split: a request a server rejects as too large (HTTP 400 or 413 whose body mentions
111
+ * tokens, fields, context, a limit, or "too long / many / large") is sent again as two halves of its
112
+ * questions (the fetch driver: at most 3 times deep). Anything else: null (the error stands).
113
+ */
114
+ const TOO_LARGE = /token|field|context|limit|exceed|too (long|many|large)/i;
115
+ export const splitDecision = (req: any, error: any) => {
116
+ if (error?.status != 400 && error?.status != 413) return null;
117
+ if (!TOO_LARGE.test(text(error.body ?? ''))) return null;
118
+ const qs = req?.json?.questions;
119
+ const n = Array.isArray(qs) ? qs.length : Object.keys(qs || {}).length;
120
+ if (n < 2) return null;
121
+ const half = (a: number, b: number) => ({...req, json: {...req.json, questions: Array.isArray(qs) ? qs.slice(a, b) : pick(qs, Object.keys(qs).slice(a, b))}});
122
+ return [half(0, n >> 1), half(n >> 1, n)];
123
+ };
124
+ /**
125
+ * A decision request over the limits becomes a fetch-driver batch (sub-requests in parallel, one
126
+ * merged reply); every request can be halved when a server rejects it as too large. `split: false`:
127
+ * neither. `json` stays the whole request (what tests and `t.requests` see).
128
+ */
129
+ const DEFAULT_MAX_QUESTIONS = 64;
130
+ function splitting(build: (o: any) => any) {
131
+ return ({maxQuestions = DEFAULT_MAX_QUESTIONS, maxPromptTokens = Infinity, split = true, ...o}: any = {}) => {
132
+ const req = build(o);
133
+ if (split === false) return req;
134
+ const parts = splitQuestions(o.state, o.questions, Math.max(1, maxQuestions), maxPromptTokens);
135
+ const sub = (names: string[]) => {
136
+ const {url, method, json, parse} = build({...o, questions: pick(o.questions, names)});
137
+ return parse ? {url, method, json, parse} : {url, method, json};
138
+ };
139
+ return {...req, ...(parts.length > 1 && {batch: parts.map(sub)}), combine: combineDecisions, split: splitDecision};
140
+ };
141
+ }
142
+
47
143
  const text = (x: any) => typeof x == 'string' ? x : JSON.stringify(x);
48
144
  // the dictionary form takes base64 files; OpenAI takes data URLs (the type from the magic bytes)
49
145
  const dataUrl = (b64: string) => b64.startsWith('data:') ? b64
@@ -101,4 +197,4 @@ function openai({url = '/api/decide', model, state, questions, images, ...rest}:
101
197
  }
102
198
 
103
199
  /** the dictionary-form request (TypeSafe, Ollama, OpenRouter, AI Gateway); `decide.openai()`: the same as an OpenAI Decisions request */
104
- export const decide = /*#__PURE__*/ Object.assign(dictionary, {openai})
200
+ export const decide = /*#__PURE__*/ Object.assign(splitting(dictionary), {openai: splitting(openai)})
@@ -20,7 +20,7 @@ export {outputJsonSchema} from './chat/output'
20
20
  // A-2 (2-W): WebMCP (experimental, D241)
21
21
  export {experimentalExposeWebMcp} from './webmcp'
22
22
  // L-3 (2-C): the chat behavior
23
- export {chat} from './chat/behavior'
23
+ export {chat, openToolCalls} from './chat/behavior'
24
24
  // L-2 (2-T): transports, wave 1 (D248), each its own module (tree-shaken), and the Open Responses
25
25
  // event encoder (D273)
26
26
  export {openResponses} from './transports/openResponses'
@@ -40,3 +40,8 @@ export {fromAISDK} from './transports/fromAISDK'
40
40
  export {strictSchemas} from './schema/strict'
41
41
  // X-1 (3-X): MCP Apps, the view side
42
42
  export {makeMcpAppDriver} from './mcpApp'
43
+ // PLAN-9 (A): catalogs (K-1), render (K-2), layout utilities (K-3), the compose behavior (C-1),
44
+ // composeWith (T-1)
45
+ export {defineCatalog, compose, composeWith, fromTree, toTree, removeNode, askCard} from './compose/index'
46
+ // PLAN-9 M-1: the <Composed> marker
47
+ export {Composed} from './compose/index'
@@ -109,21 +109,23 @@ export function experimentalExposeWebMcp(app: any, options: ExposeWebMcpOptions
109
109
  for (const k of ['items', 'prefixItems', 'anyOf', 'allOf', 'oneOf', 'additionalProperties', 'not']) if (s[k] && typeof s[k] == 'object') o[k] = params(s[k], tool, path)
110
110
  return o
111
111
  }
112
- /** a result within 1,500 characters of JSON: `state` and long fields are cut first */
112
+ /** a result within 1,500 characters of JSON: `state`, a query's `result` and long fields are cut first */
113
113
  const fit = (r: any, tool: string): any => {
114
114
  if (!r || typeof r != 'object') r = {ok: false, error: 'no result'}
115
115
  let s = JSON.stringify(r)
116
116
  if (s.length <= OUT) return r
117
- dev('SYG242', tool + 'out', `a result of ${tool} is ${s.length} characters of JSON, over the budget of ${OUT}; its state is cut (make the agent.read projection smaller)`)
118
- const {state, issues, keys, ...rest} = r
117
+ dev('SYG242', tool + 'out', `a result of ${tool} is ${s.length} characters of JSON, over the budget of ${OUT}; its ${'result' in r ? 'result' : 'state'} is cut (make ${'result' in r ? "the query's result" : 'the agent.read projection'} smaller)`)
118
+ const {state, result, issues, keys, ...rest} = r
119
119
  const o: any = {...rest, truncated: true}
120
120
  if (typeof o.error == 'string') o.error = cut(o.error, 400)
121
121
  if (issues) o.issues = issues.slice(0, 3)
122
122
  for (let room = OUT; room > 20; room = room / 2 | 0) {
123
123
  if (state !== undefined) o.state = cut(JSON.stringify(state), room)
124
+ if (result !== undefined) o.result = cut(JSON.stringify(result), room)
124
125
  if (JSON.stringify(o).length <= OUT) return o
125
126
  }
126
127
  delete o.state
128
+ delete o.result
127
129
  delete o.issues
128
130
  return o
129
131
  }
@@ -17,7 +17,8 @@
17
17
  * a Collection's viewTransitionName that isn't an identifier, the same name twice on the
18
18
  * page after a patch, G-460: on rendered elements), SYG612 (D173: a form
19
19
  * 6.0 removed, met at runtime; once per form and component, with a link to the migration
20
- * guide).
20
+ * guide), SYG453 (PLAN-9, D470: two children with one identity share one instance), SYG426
21
+ * (PLAN-9, D471: a lowercase <collection> / <switchable> tag, deprecated in 6.3.0).
21
22
  *
22
23
  * Only while diagnostics are on (the checks' own rule); production builds never load this.
23
24
  */
@@ -94,6 +95,85 @@ function removedTags(f: any, v: any, depth = 0): void {
94
95
  if (Array.isArray(kids)) for (const k of kids) removedTags(f, k, depth + 1)
95
96
  }
96
97
 
98
+ /**
99
+ * PLAN-9 SYG453 (D470): two children of one view with the same identity (the same component and
100
+ * `key`, else `id`; `sel::key` in the core) are one instance, rendered in both places. The core
101
+ * doesn't look (0 production bytes): in the owner's injected vnode that instance's output, a
102
+ * scoped root (`data.isolate` in the real DOM driver, a `___<scope>` class in the mock DOM), is
103
+ * the same object twice. Its name and key come from the child whose DOM source has that scope.
104
+ */
105
+ const scopeOfVnode = (n: any): string | undefined => {
106
+ const iso = n.data?.isolate
107
+ if (Array.isArray(iso)) return iso.length ? iso[iso.length - 1]?.scope : undefined
108
+ const m = typeof n.sel == 'string' && /\.___([^.]+)/.exec(n.sel)
109
+ return m ? m[1] : undefined
110
+ }
111
+ const scopeOfSource = (dom: any): string | undefined => {
112
+ const ns = dom?.namespace
113
+ if (Array.isArray(ns)) return ns[ns.length - 1]?.scope
114
+ const p = dom?._path
115
+ const m = Array.isArray(p) && /^\.___(.+)$/.exec(String(p[p.length - 1]))
116
+ return m ? m[1] : undefined
117
+ }
118
+ function sharedIdentity(iv: any, v: any): void {
119
+ const seen = new Set<any>(), dup = new Set<string>()
120
+ const walk = (n: any, d: number): void => {
121
+ if (!n || typeof n != 'object' || d > 300) return
122
+ const sc = scopeOfVnode(n)
123
+ if (sc !== undefined) {
124
+ if (seen.has(n)) { dup.add(sc); return }
125
+ seen.add(n)
126
+ }
127
+ const kids = n.children
128
+ if (Array.isArray(kids)) for (const k of kids) walk(k, d + 1)
129
+ }
130
+ walk(v, 0)
131
+ if (!dup.size) return
132
+ const owner = iv.name
133
+ for (const sc of dup) {
134
+ let child: any
135
+ try { child = iv.children().find((c: any) => scopeOfSource(c.sources?.DOM) === sc) } catch (_) { /* ignore */ }
136
+ const name = child ? child.name : 'a child component'
137
+ // the identity's own part of the child's uid (the owner's uid + '-' + its key, id or path)
138
+ const key = child && typeof child.uid == 'string' && child.uid.startsWith(iv.uid + '-') ? child.uid.slice(iv.uid.length + 1) : undefined
139
+ if (!once(`SYG453:${owner}:${name}:${key ?? sc}`)) continue
140
+ devReport('SYG453', {
141
+ component: owner,
142
+ message: `${owner} renders ${child ? `<${name}>` : name} in two places with the same identity${key !== undefined ? ` (key or id ${JSON.stringify(key)})` : ''}, so both places show one instance: it has one state, and the second tag's props replace the first's`,
143
+ fix: `Give each <${child ? name : 'Child'}> a different key (or id) among its parent's children, e.g. key={item.id}; a key on a component is its identity`,
144
+ data: {child: child ? name : undefined, key},
145
+ })
146
+ }
147
+ }
148
+
149
+ /**
150
+ * PLAN-9 SYG426 (D471): the lowercase host tags, `<collection>` / `<switchable>` (JSX string tags,
151
+ * or `h('collection')`), deprecated in 6.3.0; the next major removes them (and registers the hosts
152
+ * on first use, as Portal). The core doesn't look (0 production bytes): the instance's template
153
+ * keeps a host marker after reconcile (only `pres` markers are rewritten), and the imported
154
+ * components' markers carry their function in `data.m`, a string tag's don't. The template is
155
+ * the raw instance's (`iv.i`: the InstanceView's own field, a dev entry of the same build reads it;
156
+ * the vnode onRender gets has the hosts' output injected in their place). Once per tag.
157
+ */
158
+ const HOST_TAGS: Record<string, string> = {collection: 'Collection', switchable: 'Switchable'}
159
+ function lowercaseTags(iv: any): void {
160
+ const raw = iv.i
161
+ if (!raw?.kids?.size || !raw.tmpl) return
162
+ const walk = (n: any, d: number): void => {
163
+ if (!n || typeof n != 'object' || n.$p || d > 300) return
164
+ const name = typeof n.sel == 'string' && !n.data?.m && HOST_TAGS[n.sel]
165
+ if (name && once(`SYG426:${n.sel}`)) devReport('SYG426', {
166
+ component: iv.name,
167
+ message: `${iv.name} renders the lowercase <${n.sel}> tag, which is deprecated since Sygnal 6.3.0 and will be removed in the next major (it will then render an unknown <${n.sel}> element)`,
168
+ fix: `import { ${name} } from 'sygnal' and write <${name} …> (sygnal-check --fix does it)`,
169
+ data: {tag: n.sel, component: name},
170
+ })
171
+ const kids = n.children
172
+ if (Array.isArray(kids)) for (const k of kids) walk(k, d + 1)
173
+ }
174
+ walk(raw.tmpl, 0)
175
+ }
176
+
97
177
  /** the instance fields the checks read from today's Component instances */
98
178
  class Facade {
99
179
  _so: any
@@ -311,7 +391,11 @@ export function nextHooks(_api: any): any {
311
391
  onRender(iv: any, vnode: any) {
312
392
  const f = fac(iv)
313
393
  H.onRender(f, vnode)
314
- if (on()) removedTags(f, vnode)
394
+ if (!on()) return
395
+ removedTags(f, vnode)
396
+ try { lowercaseTags(iv) } catch (_) { /* a check never breaks the app */ }
397
+ // SYG453: only a view with children can render one twice
398
+ if (iv.children().length) try { sharedIdentity(iv, vnode) } catch (_) { /* a check never breaks the app */ }
315
399
  },
316
400
  onReducer(iv: any, type: string, prev: any, next: any) {
317
401
  H.onReducer(fac(iv), type, prev, next, 'STATE')