sygnal 6.2.0 → 6.4.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 (91) hide show
  1. package/CHANGELOG.md +112 -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/index.cjs.js +69 -3
  7. package/dist/astro/index.cjs.js.map +1 -1
  8. package/dist/astro/index.mjs +69 -3
  9. package/dist/astro/index.mjs.map +1 -1
  10. package/dist/astro/server.cjs.js +3 -3
  11. package/dist/astro/server.cjs.js.map +1 -1
  12. package/dist/astro/server.mjs +3 -3
  13. package/dist/astro/server.mjs.map +1 -1
  14. package/dist/diagnostics.cjs.js +211 -11
  15. package/dist/diagnostics.cjs.js.map +1 -1
  16. package/dist/diagnostics.esm.js +211 -11
  17. package/dist/diagnostics.esm.js.map +1 -1
  18. package/dist/guide/agent.md +7 -1
  19. package/dist/guide/ai-cards.md +319 -0
  20. package/dist/guide/ai-chat.md +4 -0
  21. package/dist/guide/ai-compose.md +881 -0
  22. package/dist/guide/ai-decisions.md +16 -0
  23. package/dist/guide/http.md +1 -0
  24. package/dist/guide/mcp-apps.md +59 -4
  25. package/dist/index.cjs.js +5704 -350
  26. package/dist/index.cjs.js.map +1 -1
  27. package/dist/index.d.ts +647 -10
  28. package/dist/index.esm.js +5694 -350
  29. package/dist/index.esm.js.map +1 -1
  30. package/dist/sygnal.min.js +1 -1
  31. package/dist/sygnal.min.js.map +1 -1
  32. package/dist/vike/config/package.json +1 -1
  33. package/dist/vike/onRenderClient.cjs.js +3 -2
  34. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  35. package/dist/vike/onRenderClient.mjs +3 -2
  36. package/dist/vike/onRenderClient.mjs.map +1 -1
  37. package/dist/vite/plugin.cjs.js +69 -3
  38. package/dist/vite/plugin.cjs.js.map +1 -1
  39. package/dist/vite/plugin.mjs +69 -3
  40. package/dist/vite/plugin.mjs.map +1 -1
  41. package/llms.txt +7 -5
  42. package/package.json +2 -1
  43. package/src/ai.d.ts +642 -7
  44. package/src/ai.ts +6 -0
  45. package/src/collection.ts +2 -1
  46. package/src/core/instance.ts +6 -3
  47. package/src/core/markers/lazy.ts +7 -3
  48. package/src/core/markers/portal.ts +5 -2
  49. package/src/core/markers/suspense.ts +3 -2
  50. package/src/core/markers/transition.ts +6 -3
  51. package/src/core/registry.ts +9 -5
  52. package/src/cycle/dom/IsolateModule.ts +25 -22
  53. package/src/extra/ai/chat/behavior.ts +354 -81
  54. package/src/extra/ai/chat/driver.ts +7 -4
  55. package/src/extra/ai/compose/agent.ts +55 -0
  56. package/src/extra/ai/compose/ask.ts +147 -0
  57. package/src/extra/ai/compose/behavior.ts +183 -0
  58. package/src/extra/ai/compose/catalog.ts +211 -0
  59. package/src/extra/ai/compose/composed.ts +61 -0
  60. package/src/extra/ai/compose/decisions.ts +1649 -0
  61. package/src/extra/ai/compose/diag.ts +33 -0
  62. package/src/extra/ai/compose/index.ts +23 -0
  63. package/src/extra/ai/compose/layout.ts +441 -0
  64. package/src/extra/ai/compose/llm.ts +1120 -0
  65. package/src/extra/ai/compose/render.ts +159 -0
  66. package/src/extra/ai/compose/testing.ts +71 -0
  67. package/src/extra/ai/compose/types.ts +159 -0
  68. package/src/extra/ai/decide.ts +97 -1
  69. package/src/extra/ai/index.ts +6 -1
  70. package/src/extra/diagnostics/checks/dom.ts +10 -2
  71. package/src/extra/diagnostics/checks/next.ts +103 -3
  72. package/src/extra/diagnostics/checks/persist.ts +4 -2
  73. package/src/extra/diagnostics/checks/wiring.ts +14 -6
  74. package/src/extra/diagnostics/codes.ts +59 -0
  75. package/src/extra/fetchDriver.ts +58 -27
  76. package/src/extra/lens.ts +15 -0
  77. package/src/extra/pwa.ts +3 -1
  78. package/src/extra/reducers.ts +10 -4
  79. package/src/extra/ssr.ts +3 -3
  80. package/src/extra/testing.ts +2 -0
  81. package/src/extra/widget.ts +3 -3
  82. package/src/index.d.ts +50 -2
  83. package/src/index.ts +8 -0
  84. package/src/lazy.ts +3 -2
  85. package/src/portal.ts +5 -2
  86. package/src/pragma/index.ts +2 -1
  87. package/src/suspense.ts +5 -2
  88. package/src/switchable.ts +2 -1
  89. package/src/transition.ts +5 -2
  90. package/src/vike/onRenderClient.ts +3 -2
  91. package/src/vite/plugin.ts +63 -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'
@@ -25,8 +25,10 @@
25
25
  * that matches in one Collection item is fine for all of them.
26
26
  *
27
27
  * SYG103 noise control (conditional rendering is normal): a selector whose
28
- * class/id/tag tokens all appear in the component's view source is assumed
29
- * to be conditionally rendered and is not reported. A selector that does not
28
+ * class/id/tag tokens all appear in the component's view source (or in the
29
+ * words sygnal/vite registered for it: markup drawn by view helpers in its
30
+ * module and its relative imports) is assumed to be conditionally rendered
31
+ * and is not reported. A selector that does not
30
32
  * appear in the view (typo, or an element rendered elsewhere) is reported as
31
33
  * info on the first sweep, and escalated per the rule above.
32
34
  *
@@ -71,12 +73,18 @@ onReset(() => {
71
73
 
72
74
  const unref = (t: any) => { if (t && typeof t.unref === 'function') t.unref(); return t }
73
75
 
76
+ /**
77
+ * The view's source, plus the words sygnal/vite registered for the component (its module's and
78
+ * its relative imports' string literals: markup a view helper draws), when it did
79
+ */
74
80
  function viewSource(component: any): string {
75
81
  const view = component && component.view
76
82
  if (typeof view !== 'function') return ''
77
83
  let src = viewTokens.get(view)
78
84
  if (src === undefined) {
79
85
  try { src = Function.prototype.toString.call(view) } catch (_) { src = '' }
86
+ const registered = (globalThis as any).__SYGNAL_VIEW_TOKENS__?.get?.(view)
87
+ if (typeof registered == 'string') src += '\n' + registered
80
88
  viewTokens.set(view, src as string)
81
89
  }
82
90
  return src as string