sygnal 6.2.0 → 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 (76) hide show
  1. package/CHANGELOG.md +61 -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 +164 -3
  11. package/dist/diagnostics.cjs.js.map +1 -1
  12. package/dist/diagnostics.esm.js +164 -3
  13. package/dist/diagnostics.esm.js.map +1 -1
  14. package/dist/guide/agent.md +7 -1
  15. package/dist/guide/ai-cards.md +319 -0
  16. package/dist/guide/ai-chat.md +4 -0
  17. package/dist/guide/ai-compose.md +881 -0
  18. package/dist/guide/ai-decisions.md +16 -0
  19. package/dist/guide/http.md +1 -0
  20. package/dist/index.cjs.js +5695 -347
  21. package/dist/index.cjs.js.map +1 -1
  22. package/dist/index.d.ts +638 -8
  23. package/dist/index.esm.js +5685 -347
  24. package/dist/index.esm.js.map +1 -1
  25. package/dist/sygnal.min.js +1 -1
  26. package/dist/sygnal.min.js.map +1 -1
  27. package/dist/vike/config/package.json +1 -1
  28. package/dist/vike/onRenderClient.cjs.js +3 -2
  29. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  30. package/dist/vike/onRenderClient.mjs +3 -2
  31. package/dist/vike/onRenderClient.mjs.map +1 -1
  32. package/llms.txt +6 -4
  33. package/package.json +2 -1
  34. package/src/ai.d.ts +642 -7
  35. package/src/ai.ts +6 -0
  36. package/src/collection.ts +2 -1
  37. package/src/core/instance.ts +6 -3
  38. package/src/core/markers/lazy.ts +7 -3
  39. package/src/core/markers/portal.ts +5 -2
  40. package/src/core/markers/suspense.ts +3 -2
  41. package/src/core/markers/transition.ts +6 -3
  42. package/src/core/registry.ts +9 -5
  43. package/src/cycle/dom/IsolateModule.ts +25 -22
  44. package/src/extra/ai/chat/behavior.ts +354 -81
  45. package/src/extra/ai/chat/driver.ts +7 -4
  46. package/src/extra/ai/compose/agent.ts +55 -0
  47. package/src/extra/ai/compose/ask.ts +147 -0
  48. package/src/extra/ai/compose/behavior.ts +183 -0
  49. package/src/extra/ai/compose/catalog.ts +211 -0
  50. package/src/extra/ai/compose/composed.ts +61 -0
  51. package/src/extra/ai/compose/decisions.ts +1649 -0
  52. package/src/extra/ai/compose/diag.ts +33 -0
  53. package/src/extra/ai/compose/index.ts +23 -0
  54. package/src/extra/ai/compose/layout.ts +441 -0
  55. package/src/extra/ai/compose/llm.ts +1120 -0
  56. package/src/extra/ai/compose/render.ts +159 -0
  57. package/src/extra/ai/compose/testing.ts +71 -0
  58. package/src/extra/ai/compose/types.ts +159 -0
  59. package/src/extra/ai/decide.ts +97 -1
  60. package/src/extra/ai/index.ts +6 -1
  61. package/src/extra/diagnostics/checks/next.ts +86 -2
  62. package/src/extra/diagnostics/codes.ts +54 -0
  63. package/src/extra/fetchDriver.ts +58 -27
  64. package/src/extra/lens.ts +15 -0
  65. package/src/extra/ssr.ts +3 -3
  66. package/src/extra/testing.ts +2 -0
  67. package/src/extra/widget.ts +3 -3
  68. package/src/index.d.ts +41 -0
  69. package/src/index.ts +8 -0
  70. package/src/lazy.ts +3 -2
  71. package/src/portal.ts +5 -2
  72. package/src/pragma/index.ts +2 -1
  73. package/src/suspense.ts +5 -2
  74. package/src/switchable.ts +2 -1
  75. package/src/transition.ts +5 -2
  76. package/src/vike/onRenderClient.ts +3 -2
@@ -12,7 +12,7 @@ import {messageId} from '../messages';
12
12
  *
13
13
  * - Request (sink value): `{ messages, instructions?, tools?, model?, output?, key?, latest?,
14
14
  * delta?, ok?, error?, tool?, coalesce?, ...transportOptions }`, passed to the transport as is
15
- * (sender-stamped, scope-tagged). `{ abort: key | true }` / `{ abort: true, key }` stop the
15
+ * (sender-stamped, scope-tagged). `{ abort: key | [keys] | true }` / `{ abort: true, key }` stop the
16
16
  * sender's runs under that key (all of them for a bare `abort: true`).
17
17
  * - A `then` / `catch` key is SYG610 (allowed()); a value that isn't a request (no `messages`
18
18
  * array, an unknown `coalesce`, an `output` that isn't a Standard Schema) is SYG679: not sent.
@@ -24,7 +24,8 @@ import {messageId} from '../messages';
24
24
  * `delta` is the text that is new since the last one (D251);
25
25
  * - `tool` `{ key, call: { id, name, input } }` once per completed call;
26
26
  * - `ok` `{ key, message, text, value?, toolCalls, finishReason, usage }` (`value`: the
27
- * validated structured output when the request has `output`, G-604);
27
+ * validated structured output when the request has `output`, G-604; not with `parse: false`,
28
+ * which sends the schema but leaves reading `text` to the caller: PLAN-9 compose.llm());
28
29
  * - `error` `{ key, error, request, issues? }`. A failure with no `error` action is SYG678
29
30
  * (logged, also in production; G-584).
30
31
  * - An aborted run (abort, latest, sender dispose, app dispose, sink complete) delivers nothing:
@@ -221,7 +222,8 @@ export function makeChatDriver(options: any = {}) {
221
222
  if (!live()) return;
222
223
  flush();
223
224
  let value: any;
224
- const structured = req.output !== undefined && (!toolCalls.length || !!text.trim());
225
+ // `parse: false` (PLAN-9 B-4): the transport still sends the schema, the caller reads `text` (compose.llm()'s lenient reader)
226
+ const structured = req.output !== undefined && req.parse !== false && (!toolCalls.length || !!text.trim());
225
227
  if (structured) {
226
228
  value = await readOutput(req.output, text);
227
229
  if (!live()) return;
@@ -244,8 +246,9 @@ export function makeChatDriver(options: any = {}) {
244
246
  if (sender === undefined) return void dev('SYG677', req);
245
247
  const {abort} = req;
246
248
  if (abort !== undefined && abort !== false) {
249
+ // D469 (PLAN-9 H-2): a list of keys stops each (STOP: the reply and a card being composed)
247
250
  const k = abort === true ? req.key : abort;
248
- return abortWhere(r => r.sender === sender && (k === undefined || r.key === k));
251
+ return abortWhere(r => r.sender === sender && (k === undefined || (Array.isArray(k) ? k.includes(r.key) : r.key === k)));
249
252
  }
250
253
  const why = problem(req);
251
254
  if (why) return void logError('SYG679', req.__emitterName, `makeChatDriver: request not sent: ${why}`, FIX, req);
@@ -0,0 +1,55 @@
1
+ /*
2
+ * PLAN-9 C-2: a composition as an agent action, so a chat assistant (or WebMCP, the command bar)
3
+ * can rearrange the page. The behavior's COMPOSE is an ordinary host action ('page.COMPOSE'), so
4
+ * the A-1 agent layer runs it like any other; these helpers only save the boilerplate:
5
+ *
6
+ * App.uses = { page: compose(catalog, { backend }), assistant: chat({ form: '.ask', prompt: '.prompt' }) }
7
+ * App.agent = {
8
+ * name: 'app',
9
+ * read: (state) => ({ page: compose.summary(state.page) }),
10
+ * actions: { ...compose.action('page') }, // the tool app_page_compose({ prompt, fresh? })
11
+ * }
12
+ *
13
+ * - compose.action(key = 'page', { description?, timeout? }): `{ [key + '.COMPOSE']: { description,
14
+ * input: { prompt, fresh? }, until, timeout } }`. `until` holds the tool result until the
15
+ * composition is over (status neither 'composing' nor 'preview': done, unavailable or error), so
16
+ * the result's `state` (the `read` projection) shows the new page; `timeout` (default 60 s, a
17
+ * model call takes a while) answers `{ ok: true, pending: true }` instead, as for any `until`.
18
+ * - compose.summary(slice): what the `read` projection shows of a composition: `{ status, page:
19
+ * the layout as a tree (toTree), error? }`.
20
+ *
21
+ * With compose.llm() the composition's request goes on the same LLM sink as the chat's, in its own
22
+ * lane (`key: 'compose'` vs the chat's uses key) with its own reply actions ('page.REPLY'), so the
23
+ * two never abort or answer each other; the chat's next request waits for `until`.
24
+ */
25
+ import {jsonSchema} from '../schema/jsonSchema'
26
+ import {toTree} from './layout'
27
+
28
+ const INPUT = {
29
+ type: 'object',
30
+ properties: {
31
+ prompt: {type: 'string', minLength: 1, description: 'What the page should show, or how to change it, in the user\'s words'},
32
+ fresh: {type: 'boolean', description: 'true: start over instead of changing the current page'},
33
+ },
34
+ required: ['prompt'],
35
+ additionalProperties: false,
36
+ }
37
+
38
+ /** the `agent.actions` entry for the composition under `key` (see the header) */
39
+ export function composeAction(key: string = 'page', options: {description?: string; timeout?: number} = {}) {
40
+ const {description = 'Rearrange the page from its prepared parts: say what it should show (it changes the current page unless fresh is true). The result has the new page', timeout = 60000} = options
41
+ return {
42
+ [key + '.COMPOSE']: {
43
+ description,
44
+ input: jsonSchema(INPUT),
45
+ until: (state: any) => { const s = state?.[key]?.status; return s != 'composing' && s != 'preview' },
46
+ timeout,
47
+ },
48
+ }
49
+ }
50
+
51
+ /** a composition as an agent reads it: `{ status, page, error? }` (`page`: the layout as a tree) */
52
+ export function composeSummary(slice: any) {
53
+ if (!slice || typeof slice != 'object') return undefined
54
+ return {status: slice.status, page: toTree(slice.layout), ...(slice.error && {error: slice.error})}
55
+ }
@@ -0,0 +1,147 @@
1
+ /*
2
+ * PLAN-9 H-2 / H-3: cards in the chat (dev-plans/research/ai-compose/chat-client-tools.md, D454,
3
+ * D455, D469). `chat({ ask: { catalog, backend?, instructions?, direct? } })` adds the client tool
4
+ * `ask_user({ need })`; the configured backend composes a card from `need` and the app state (never
5
+ * the conversation), and a result part's answer becomes the call's output `{ part, answer }`.
6
+ *
7
+ * The chat behavior reaches this module through the catalog (`catalog._ask`, set by defineCatalog),
8
+ * so an app with `chat` and no catalog carries none of it (nor compose.llm).
9
+ *
10
+ * The card lives on the tool part: `part.ui = { status, layout, local, ui, answer, pending, decisions? }`:
11
+ * - status: 'composing' (queued, or its backend step is out: `pending` set) | 'open' (answerable) |
12
+ * 'answered' | 'failed' (the part is output-error "could not show a card: …") | 'cancelled'
13
+ * (STOP, SEND);
14
+ * - layout: the card (a decision backend's preview while it composes); ui / local: container state
15
+ * and `local` parts' state per node key, as in a compose slice; answer: `{ node, part, value }`;
16
+ * - pending: the backend's step while its request is out (plain JSON);
17
+ * - decisions: what the backend explains, its last reply's `decisions` (as a compose slice keeps
18
+ * them; absent until a reply carries some).
19
+ * Cards compose one at a time, on the backend's sink, under the key `<use>.ask:<toolCallId>`, with
20
+ * the reply actions `<use>.CARD` / `<use>.CARD_FAILED`.
21
+ *
22
+ * `direct: true` (H-3): the tool's input schema is the layout schema (compose.llm's, without the
23
+ * plan field) and the call's input is the card, read and repaired like an LLM backend's reply: no
24
+ * compose round trip.
25
+ *
26
+ * `askCard(catalog, state, part, { key })` renders a card in the host's view: catalog.render with
27
+ * the call id as scope, frozen unless the card is open, the answer marked, `local` written through
28
+ * a lens to the part.
29
+ */
30
+ import {warn} from '../../diagnostics/legacy'
31
+ import {llm, layoutSchema} from './llm'
32
+ import {cardArgs} from './render'
33
+
34
+ export const ASK = 'ask_user'
35
+
36
+ const DESCRIPTION = 'Ask the user and wait for their answer: a choice, a pick among the app\'s items, a short form or a confirmation. Say in one sentence what you need from them; the app shows a card for it. Prefer this to asking in text when the answer is one of a few options or structured.'
37
+ const DIRECT = 'Ask the user and wait for their answer: a choice, a pick among the app\'s items, a short form or a confirmation. The input is the card, built from the app\'s parts; include a part the user answers with. Prefer this to asking in text when the answer is one of a few options or structured.'
38
+ const NEED = {
39
+ type: 'object',
40
+ properties: {need: {type: 'string', maxLength: 300, description: 'What you need from the user, in one sentence'}},
41
+ required: ['need'],
42
+ additionalProperties: false,
43
+ }
44
+
45
+ /** the card compositions per catalog (composeWith answers their pending step) */
46
+ const cards = /*#__PURE__*/ new WeakMap<object, Array<{backend: any; keys: Set<string>; mine: WeakSet<object>}>>()
47
+ export const cardsOf = (catalog: object) => cards.get(catalog) || []
48
+
49
+ /** the chat's `ask` option, resolved once per chat() call */
50
+ export function askConfig(ask: any, o: any, sink: string) {
51
+ const {catalog, backend, direct, instructions} = ask
52
+ if (!catalog || typeof catalog.render != 'function') throw new Error('[Sygnal] chat({ ask }): `ask.catalog` must be a catalog made by defineCatalog()')
53
+ const results = [...new Set(Object.values<any>(catalog.parts).filter(p => p.result && p.component).map(p => p.component))]
54
+ if (!Object.values<any>(catalog.parts).some(p => p.result)) {
55
+ warn('SYG686', 'chat', 'the ask catalog has no result part (`result: true`), so no card can be answered: every ask_user call waits until STOP or a new message', 'Mark the parts the user answers with `result: true` and send compose.answer(props, value) to PARENT')
56
+ }
57
+ const be = direct ? null : backend ?? llm({sink, ...(o.model && {model: o.model}), ...o.transportOptions})
58
+ // mine: the pending steps this chat() made, so composeWith picks its backend among the catalog's
59
+ const entry = {backend: be, keys: new Set<string>(), mine: new WeakSet<object>()}
60
+ if (be) {
61
+ if (!cards.has(catalog)) cards.set(catalog, [])
62
+ cards.get(catalog)!.push(entry)
63
+ }
64
+ return {catalog, backend: be, direct: !!direct, instructions, results, entry, reader: direct ? llm() : null,
65
+ controls: Object.values<any>(catalog.containers || {}).some(c => typeof c.decl?.render == 'function')}
66
+ }
67
+ export type AskConfig = ReturnType<typeof askConfig>
68
+
69
+ /** the ask_user tool for a request (direct: the layout schema, for the host state of now) */
70
+ export const askTool = (c: AskConfig, state: any) => ({
71
+ description: (c.direct ? DIRECT : DESCRIPTION) + (c.instructions ? ' ' + c.instructions : ''),
72
+ inputSchema: c.direct ? layoutSchema(c.catalog, state, {plan: false}) : NEED,
73
+ })
74
+
75
+ const message = (e: any) => String(e?.message ?? e ?? 'error')
76
+ const ui = (p: any, patch: any) => ({...p, ui: {...p.ui, ...patch}})
77
+
78
+ /** the card can't be shown: the call answers with an error, and the turn goes on */
79
+ export const failed = (p: any, why: string) => ({
80
+ ...ui(p, {status: 'failed', pending: null}),
81
+ state: 'output-error',
82
+ errorText: `could not show a card: ${why}; ask in text instead`,
83
+ })
84
+
85
+ /** an ask_user call as it opens: composing (queued), or (direct) the card itself */
86
+ export function openCard(c: AskConfig, p: any, state: any): any {
87
+ const base = {...p, ui: {status: 'composing', layout: null, local: {}, ui: {}, answer: null, pending: null}}
88
+ if (c.direct) return read(c, base, c.reader!.reply({catalog: c.catalog, state, pending: {plan: false}, reply: {value: p.input}} as any))
89
+ const need = p.input?.need
90
+ return typeof need == 'string' && need.trim() ? base : failed(base, 'the call has no `need` (one sentence saying what you need from the user)')
91
+ }
92
+
93
+ /** a backend result → the card part, and the next request when there is one */
94
+ function read(c: AskConfig, p: any, r: any): any {
95
+ if (!r) return failed(p, 'the backend gave no layout')
96
+ // what the backend explains (its last `decisions`), kept on the card like a compose slice's
97
+ if (r.decisions !== undefined) p = ui(p, {decisions: r.decisions})
98
+ if (r.error != null) return failed(p, message(r.error))
99
+ if (r.request !== undefined) return {part: ui(p, {pending: own(c, r.pending ?? p.ui.pending), ...(r.preview && {layout: r.preview})}), request: r.request}
100
+ if (r.layout) return ui(p, {status: 'open', layout: r.layout, pending: null})
101
+ return failed(p, 'nothing in the catalog fits it')
102
+ }
103
+ const own = (c: AskConfig, pending: any) => { if (pending && typeof pending == 'object') c.entry.mine.add(pending); return pending }
104
+ const names = (k: string, id: string) => ({ok: k + '.CARD', error: k + '.CARD_FAILED', key: `${k}.ask:${id}`}) as any
105
+
106
+ /**
107
+ * G-756: an HTTP reply (compose.decisions()) is the parsed body, with no request key, so a card's
108
+ * request tags its JSON body with the card's key (`askKey`); the chat's CARD step drops a reply
109
+ * tagged for another card (one closed by TOOL_OUTPUT while it composed). A request with its own
110
+ * `parse`, or one for the chat driver (its replies carry the key), is sent as it is.
111
+ */
112
+ const tag = (req: any, key: string) => req && typeof req == 'object' && typeof req.url == 'string' && req.parse === undefined
113
+ ? {...req, parse: async (res: any) => {
114
+ const text = await res.text(), v = text ? JSON.parse(text) : null
115
+ return v && typeof v == 'object' && !Array.isArray(v) ? {...v, askKey: key} : v
116
+ }}
117
+ : req
118
+
119
+ /** start composing a queued card: `{ part, request }`, or the failed part */
120
+ export function startCard(c: AskConfig, p: any, state: any, k: string): any {
121
+ c.entry.keys.add(k)
122
+ try {
123
+ const n = names(k, p.toolCallId)
124
+ const r = c.backend!.start({catalog: c.catalog, state, prompt: String(p.input.need).trim(), previous: null, names: n})
125
+ return {part: ui(p, {pending: own(c, r.pending ?? {})}), request: tag(r.request, n.key)}
126
+ } catch (e) { return failed(p, message(e)) }
127
+ }
128
+
129
+ /** a backend reply for the card in flight: `{ part, request }` (another step), or the card part */
130
+ export function cardReply(c: AskConfig, p: any, reply: any, state: any, k: string): any {
131
+ try {
132
+ const n = names(k, p.toolCallId), r = read(c, p, c.backend!.reply({catalog: c.catalog, state, pending: p.ui.pending, reply, names: n}))
133
+ return r?.request !== undefined ? {...r, request: tag(r.request, n.key)} : r
134
+ } catch (e) { return failed(p, message(e)) }
135
+ }
136
+
137
+ /**
138
+ * askCard(catalog, state, part, { key = 'assistant' }): the card of an ask_user tool part, for
139
+ * the host's view (null while it has no layout)
140
+ */
141
+ export function askCard(catalog: any, state: any, part: any, options: {key?: string} = {}): any {
142
+ const c = cardArgs(state, part, options.key)
143
+ return c && catalog.render(c[0], state, c[1])
144
+ }
145
+
146
+ /** what chat() reads through `catalog._ask` */
147
+ export const askKit = {askConfig, askTool, openCard, startCard, cardReply, failed}
@@ -0,0 +1,183 @@
1
+ /*
2
+ * PLAN-9 C-1: the `compose` behavior (phase1-contract.md, PROPOSAL.md §3).
3
+ *
4
+ * Workspace.uses = { page: compose(catalog, { backend: compose.decisions({ model }), form: '.ask', prompt: '.prompt' }) }
5
+ * view: {catalog.render(state.page, state)}
6
+ *
7
+ * Slice (state.page): { prompt, status, layout, preview, pending, decisions, error, ui, local }
8
+ * - status: 'idle' | 'composing' (a request is out) | 'preview' (a partial layout shows: after a
9
+ * decision step, or while an LLM streams) | 'done' | 'unavailable' (nothing in the catalog fits:
10
+ * the layout is kept) | 'error' (the layout is kept; `error` says why);
11
+ * - layout: the last finished layout; preview: the one being built (rendered instead while set);
12
+ * - pending: the backend's step (plain JSON); decisions: what the backend explains (its last);
13
+ * - ui: container state per node key (CONTROL: `{ key, name, value }` → ui[key][name] = value; the
14
+ * clicks are read only when a container has its own render);
15
+ * local: the state of `local` parts per node key.
16
+ *
17
+ * Actions ('page.X'): COMPOSE (data: a prompt string, `{ prompt, fresh? }`, or the form's submit:
18
+ * the slice's prompt; an edit of the current layout unless `fresh`), SET_LAYOUT (a layout, finished
19
+ * first: restore, undo, the app's own edits; null clears), REMOVE (a node key), CONTROL, DONE (a
20
+ * composition finished: data is the layout; a host entry 'page.DONE' runs after it). Internal:
21
+ * PROMPT (typing), REPLY / DELTA / FAILED (the backend sink's reply actions). A backend reply
22
+ * `{ error }` (not a valid layout even after repair), or a start / reply that throws, is handled as
23
+ * FAILED: status 'error', the layout kept; a delta that throws is ignored.
24
+ *
25
+ * The only sink it declares is `backend.sink`. Requests go in the lane `key` (default 'compose')
26
+ * with `latest`, so a new prompt aborts the old one's step in flight. The reply names are the
27
+ * use's key: `page.REPLY`, `page.DELTA`, `page.FAILED`. Finished layouts and previews are re-keyed
28
+ * against the current layout (fromTree's previous-key reuse), so a part a follow-up doesn't change
29
+ * keeps its node key, and so its instance and state, whatever keys the backend gave it. A reply
30
+ * or delta with `keyed: true` (both backends' edits, B-4) already carries the previous layout's keys
31
+ * and is used as is (re-keying by identity could swap the keys of two copies of one part).
32
+ *
33
+ * The handlers that need the host's whole state (record params read it) are HOST entries; a sink
34
+ * reads it as `props.state` (the pre-action state), and one memo per behavior value makes STATE,
35
+ * the sink and EFFECT of one action compute the same step.
36
+ */
37
+ import {defineBehavior} from '../../behaviors'
38
+ import {ABORT} from '../../../shared'
39
+ import {fail} from '../../diagnostics/legacy'
40
+ import {finish, fromTree, removeNode, toTree} from './layout'
41
+ import type {Backend, Catalog, Layout} from './types'
42
+
43
+ /** the compositions per catalog (composeWith finds the pending one): backend, lane and use keys */
44
+ const uses = /*#__PURE__*/ new WeakMap<object, Array<{backend: Backend; lane: string; keys: Set<string>; mine: WeakSet<object>}>>()
45
+ export const usesOf = (catalog: object) => uses.get(catalog) || []
46
+
47
+ const message = (e: any) => String(e?.message ?? e ?? 'error')
48
+
49
+ /** one step per (host state, data, key): STATE, the sink and EFFECT of one action share it */
50
+ const once = <R>(f: (st: any, d: any, k: string) => R) => {
51
+ let a: any[] = [], r: R
52
+ return (st: any, d: any, k: string): R => a[0] === st && a[1] === d && a[2] === k ? r : (a = [st, d, k], r = f(st, d, k))
53
+ }
54
+
55
+ /** a click on a container control (render's `control()` data attributes) */
56
+ const controlOf = (e: any) => {
57
+ const el = e?.ownerTarget || e?.target?.closest?.('[data-compose-control]') || e?.target
58
+ const ds = el?.dataset || {}
59
+ if (!ds.composeControl || !ds.composeKey) return null
60
+ let value: any = ds.composeValue
61
+ try { value = JSON.parse(value) } catch (_) { /* a plain string */ }
62
+ return {key: ds.composeKey, name: ds.composeControl, value, at: ds.composeAt, scope: ds.composeScope}
63
+ }
64
+
65
+ /**
66
+ * compose(catalog, { backend, form?, prompt?, key? }): the behavior (a `uses` value). Types:
67
+ * src/ai.d.ts.
68
+ */
69
+ export function composeBehavior(catalog: Catalog, options: any = {}): any {
70
+ const {backend, form, prompt: promptSel, key: lane = 'compose'} = options
71
+ if (!backend || typeof backend.sink != 'string' || typeof backend.start != 'function' || typeof backend.reply != 'function') {
72
+ fail('SYG681', 'compose', 'compose(catalog, { backend }) needs a backend', 'Pass compose.decisions({ model }) or compose.llm({ model })')
73
+ }
74
+ if (!catalog || typeof catalog.render != 'function') fail('SYG681', 'compose', 'compose(catalog, …) needs a catalog made by defineCatalog()', 'Pass defineCatalog({ parts })')
75
+ const S: string = backend.sink
76
+ // mine: the pending steps this use made, so composeWith picks its backend among the catalog's uses
77
+ const entry = {backend, lane, keys: new Set<string>(), mine: new WeakSet<object>()}
78
+ const own = (p: any) => { if (p && typeof p == 'object') entry.mine.add(p); return p }
79
+ if (!uses.has(catalog)) uses.set(catalog, [])
80
+ uses.get(catalog)!.push(entry)
81
+ const names = (k: string) => ({ok: k + '.REPLY', error: k + '.FAILED', delta: k + '.DELTA', key: lane})
82
+ const rekey = (l: Layout, prev: Layout | null): Layout => (prev ? fromTree(catalog, toTree(l), prev) : l)
83
+
84
+ const start = once((st, d, k) => {
85
+ const s = st?.[k]
86
+ if (!s) return null
87
+ const raw = typeof d == 'string' ? d : d && typeof d == 'object' && typeof d.prompt == 'string' ? d.prompt : s.prompt
88
+ const prompt = String(raw ?? '').trim()
89
+ if (!prompt) return null
90
+ entry.keys.add(k)
91
+ const base = {...s, prompt, preview: null, decisions: null, error: null}
92
+ try {
93
+ const r = backend.start({catalog, state: st, prompt, previous: d?.fresh ? null : s.layout, names: names(k)})
94
+ return {slice: {...base, status: 'composing', pending: own(r.pending ?? {})}, request: r.request}
95
+ } catch (e) {
96
+ return {slice: {...base, status: 'error', pending: null, error: message(e)}}
97
+ }
98
+ })
99
+
100
+ const reply = once((st, d, k) => {
101
+ const s = st?.[k]
102
+ if (!s?.pending) return null
103
+ let r: any
104
+ try { r = backend.reply({catalog, state: st, pending: s.pending, reply: d, names: names(k)}) || {} } catch (e) {
105
+ return {slice: {...s, status: 'error', preview: null, pending: null, error: message(e)}}
106
+ }
107
+ const decisions = r.decisions !== undefined ? r.decisions : s.decisions
108
+ // a reply that isn't a valid layout (even after the backend's repair): as FAILED
109
+ if (r.error != null) return {slice: {...s, status: 'error', preview: null, pending: null, decisions, error: message(r.error)}}
110
+ if (r.request !== undefined) {
111
+ const preview = r.preview ? (r.keyed ? r.preview : rekey(r.preview, s.layout)) : s.preview
112
+ return {slice: {...s, status: preview ? 'preview' : 'composing', preview, pending: own(r.pending ?? s.pending), decisions}, request: r.request}
113
+ }
114
+ if (r.layout) {
115
+ // `keyed` (B-4 edits): the backend kept the previous keys itself (moves, new rows): not re-keyed
116
+ const layout = r.keyed ? r.layout : rekey(r.layout, s.layout)
117
+ return {slice: {...s, status: 'done', layout, preview: null, pending: null, decisions, error: null}, done: layout}
118
+ }
119
+ if (r.unavailable || r.layout === null) return {slice: {...s, status: 'unavailable', preview: null, pending: null, decisions}}
120
+ return {slice: {...s, status: 'error', preview: null, pending: null, decisions, error: 'the backend gave no layout'}}
121
+ })
122
+
123
+ const host = (step: (st: any, d: any, k: string) => any) =>
124
+ (st: any, d: any, _n: any, _p: any, _o: any, k: string) => { const r = step(st, d, k); return r?.slice ? {...st, [k]: r.slice} : ABORT }
125
+ const sink = (step: (st: any, d: any, k: string) => any) =>
126
+ (_s: any, d: any, _n: any, p: any, _o: any, k: string) => { const r = step(p?.state, d, k); return r?.request !== undefined ? r.request : ABORT }
127
+
128
+ // `prompt` (a selector) must not set the slice's prompt (defineBehavior merges options naming
129
+ // initialState keys): the factory gets the other options
130
+ const {prompt: _selector, ...rest} = options
131
+ const controls = Object.values(catalog.containers || {}).some((c: any) => typeof c.decl?.render == 'function')
132
+ return defineBehavior({
133
+ initialState: {prompt: '', status: 'idle', layout: null, preview: null, pending: null, decisions: null, error: null, ui: {}, local: {}},
134
+ intent: (so: any, _o: any, k: string) => {
135
+ const {DOM} = so
136
+ if (!DOM) return {}
137
+ return {
138
+ ...(promptSel && {PROMPT: DOM.select(promptSel).events('input').map((e: any) => e.target.value)}),
139
+ ...(form && {COMPOSE: DOM.select(form).events('submit', {preventDefault: true}).mapTo(undefined)}),
140
+ // only when a container has its own render (the built-in views render no controls): else
141
+ // the selector never matches (SYG103 in dev)
142
+ ...(controls && {CONTROL: DOM.select('[data-compose-control]').events('click').map(controlOf)
143
+ .filter((c: any) => c && c.scope === undefined && (c.at === undefined || c.at === k))
144
+ .map(({key, name, value}: any) => ({key, name, value}))}),
145
+ }
146
+ },
147
+ model: {
148
+ PROMPT: (s: any, p: any) => typeof p == 'string' && p !== s.prompt ? {...s, prompt: p} : ABORT,
149
+ COMPOSE: {HOST: host(start), [S]: sink(start)},
150
+ REPLY: {
151
+ HOST: host(reply),
152
+ [S]: sink(reply),
153
+ EFFECT: (_s: any, d: any, next: any, p: any, _o: any, k: string) => { const r = reply(p?.state, d, k); if (r?.done) next('DONE', r.done) },
154
+ },
155
+ DELTA: {
156
+ HOST: (st: any, d: any, _n: any, _p: any, _o: any, k: string) => {
157
+ const s = st[k]
158
+ if (!s?.pending || typeof backend.delta != 'function') return ABORT
159
+ let r: any
160
+ try { r = backend.delta({catalog, state: st, pending: s.pending, delta: d}) } catch (_) { return ABORT }
161
+ return r?.preview ? {...st, [k]: {...s, status: 'preview', preview: r.keyed ? r.preview : rekey(r.preview, s.layout)}} : ABORT
162
+ },
163
+ },
164
+ FAILED: (s: any, d: any) => s.pending ? {...s, status: 'error', preview: null, pending: null, error: message(d?.error ?? d)} : ABORT,
165
+ SET_LAYOUT: {
166
+ HOST: (st: any, d: any, _n: any, _p: any, _o: any, k: string) => {
167
+ const layout = d == null ? null : finish(catalog, d, st)
168
+ return {...st, [k]: {...st[k], layout, preview: null, pending: null, error: null, status: layout ? 'done' : 'idle'}}
169
+ },
170
+ },
171
+ REMOVE: {
172
+ HOST: (st: any, key: any, _n: any, _p: any, _o: any, k: string) => {
173
+ const s = st[k], l = s?.layout
174
+ if (!l?.nodes || typeof key != 'string' || !Object.prototype.hasOwnProperty.call(l.nodes, key) || key == l.root) return ABORT
175
+ return {...st, [k]: {...s, layout: finish(catalog, removeNode(l, key), st)}}
176
+ },
177
+ },
178
+ CONTROL: (s: any, c: any) => c && typeof c.key == 'string' && typeof c.name == 'string'
179
+ ? {...s, ui: {...s.ui, [c.key]: {...s.ui?.[c.key], [c.name]: c.value}}} : ABORT,
180
+ DONE: () => ABORT,
181
+ },
182
+ })(rest)
183
+ }
@@ -0,0 +1,211 @@
1
+ /*
2
+ * PLAN-9 K-1: defineCatalog(decl), the declaration both backends compose from (phase1-contract.md,
3
+ * PROPOSAL.md §1). Normalizes and validates the parts and containers (one id namespace; the
4
+ * built-ins `page`, the root, and `row`, side by side: `{ min: 2, max: 4, accepts: 'leaves' }`;
5
+ * `rows: false` (or `containers: { row: false }`) opts out of it), and returns the Catalog: `parts`,
6
+ * `containers`, `get(id)`, `options(state)` (every selectable option, a record part once per
7
+ * record), `records(part, state)`, `finish(layout, state)` (K-3), `render(slice, state, opts)`
8
+ * (K-2), `description` (the page's label, with `containers.page.description`), `categories`
9
+ * (`{ id: description }`, for a category prefilter; parts name one in `category`), `rows`,
10
+ * `guidance`, `limits` (`{ maxParts: 12, maxDepth: 4 }`) and `examples`. Containers carry
11
+ * `description`, `in`, `slots`, `minSlots`, `min` / `max` (children per slot), `accepts: 'leaves'`,
12
+ * `arranged`, `direction` and `params`.
13
+ *
14
+ * Errors: an id that can't be a node key (empty, or with ':', '~', '/'), a part id that is also a
15
+ * container's, a part named `page` / `row`, a malformed param and two record params on a part throw
16
+ * at the call (an authoring mistake). The rest are dev diagnostics (./diag.ts): SYG161 (a
17
+ * container's `render` is a component with a model: it is still called as a view), SYG162 (a part
18
+ * with neither `component` nor `render`: it renders nothing), SYG450 (`in` names an unknown
19
+ * container or slot: that entry is dropped), SYG250 (an unknown companion: dropped) and, when the
20
+ * records are read, SYG252.
21
+ */
22
+ import {createElement as h} from '../../../pragma/index'
23
+ import {dev} from './diag'
24
+ import {finish} from './layout'
25
+ import {renderLayout} from './render'
26
+ import {askKit} from './ask'
27
+ import type {Catalog, Container, Entry, Option, Param, Part} from './types'
28
+
29
+ const BAD_ID = /^$|[:~/]/
30
+ const fail = (msg: string): never => { throw new Error(`[Sygnal] defineCatalog: ${msg}`) }
31
+
32
+ const pageView = ({slots}: any) => h('div', {className: 'compose-page'}, slots.default)
33
+ const rowView = ({slots}: any) => h('div', {className: 'compose-row'}, slots.default)
34
+ /** a container declared without `render`: its slots in order */
35
+ const slotsView = ({slots}: any) => h('div', {className: 'compose-container'}, Object.keys(slots).map(s => slots[s]))
36
+
37
+ function param(part: string, name: string, p: any): Param {
38
+ const where = `part '${part}', param '${name}'`
39
+ if (!p || typeof p != 'object') return fail(`${where} is not an object`)
40
+ if (typeof p.from == 'function') return {kind: 'record', from: p.from, label: p.label}
41
+ if (p.options && typeof p.options == 'object') {
42
+ const keys = Object.keys(p.options)
43
+ if (!keys.length) fail(`${where} has no options`)
44
+ if (p.default !== undefined && !keys.includes(String(p.default))) fail(`${where}: the default '${p.default}' is not one of its options`)
45
+ return {kind: 'options', options: p.options, default: p.default ?? keys[0], label: p.label, ask: p.ask}
46
+ }
47
+ if (p.type == 'number') {
48
+ if (p.default !== undefined && typeof p.default != 'number') fail(`${where}: the default is not a number`)
49
+ return {kind: 'number', min: p.min, max: p.max, default: p.default, bins: p.bins, label: p.label}
50
+ }
51
+ if (p.type == 'text') return {kind: 'text', maxLength: p.maxLength, default: p.default, required: !!p.required, label: p.label}
52
+ if (p.type == 'list') return {kind: 'list', items: p.items, min: p.min, max: p.max, required: !!p.required, default: p.default, label: p.label}
53
+ return fail(`${where} needs \`options\`, \`from\`, or \`type: 'number' | 'text' | 'list'\``)
54
+ }
55
+
56
+ const isComponent = (f: any) => typeof f == 'function' && !!(f.model || f.intent || f.initialState || f.uses)
57
+
58
+ function container(id: string, d: any): Container {
59
+ const slots = d.slots && typeof d.slots == 'object' && Object.keys(d.slots).length ? d.slots : {default: null}
60
+ let render = d.render
61
+ if (isComponent(render)) {
62
+ dev('SYG161', 'defineCatalog', `container '${id}': its render is a component with a model or intent; containers are view functions (a component would own the parts, so the host's CHILD.select and lenses stop reaching them). It is called as a view; its model doesn't run`, 'error')
63
+ }
64
+ if (typeof render != 'function') render = id == 'page' ? pageView : id == 'row' ? rowView : slotsView
65
+ // min / max: children per slot (non-negative integers, min <= max); accepts: 'leaves' (no nested
66
+ // containers). An invalid value is ignored (SYG450, like an invalid `in`)
67
+ const count = (k: string) => {
68
+ const v = d[k]
69
+ if (v == null) return undefined
70
+ if (Number.isInteger(v) && v >= 0) return v
71
+ dev('SYG450', 'defineCatalog', `container '${id}': \`${k}\` must be a non-negative integer (children per slot); it is ignored`)
72
+ }
73
+ let min = count('min'), max = count('max')
74
+ if (min != null && max != null && min > max) {
75
+ dev('SYG450', 'defineCatalog', `container '${id}': \`min\` ${min} is above \`max\` ${max}; both are ignored`)
76
+ min = max = undefined
77
+ }
78
+ let accepts = d.accepts
79
+ if (accepts != null && accepts != 'leaves') {
80
+ dev('SYG450', 'defineCatalog', `container '${id}': \`accepts\` takes only 'leaves'; it is ignored`)
81
+ accepts = undefined
82
+ }
83
+ const params: Record<string, Param> = {}
84
+ for (const k in d.params || {}) {
85
+ const p = params[k] = param(id, k, d.params[k])
86
+ if (p.kind == 'record') fail(`container '${id}', param '${k}': containers take no record params`)
87
+ }
88
+ return {id, kind: 'container', slots, slotNames: Object.keys(slots), minSlots: d.minSlots, min, max, accepts, params,
89
+ arranged: !!d.arranged, direction: d.direction ?? (id == 'row' ? 'row' : undefined), description: d.description, render, in: d.in ?? ['page'], decl: d}
90
+ }
91
+
92
+ function part(id: string, d: any): Part {
93
+ if (!d || typeof d != 'object') return fail(`part '${id}' is not an object`)
94
+ if (typeof d.component != 'function' && typeof d.render != 'function') {
95
+ dev('SYG162', 'defineCatalog', `part '${id}' has neither a component nor a render function; it renders nothing`, 'error')
96
+ }
97
+ const params: Record<string, Param> = {}
98
+ let record: string | undefined
99
+ for (const k in d.params || {}) {
100
+ const p = params[k] = param(id, k, d.params[k])
101
+ if (p.kind == 'record') {
102
+ if (record) fail(`part '${id}' has two record params ('${record}', '${k}'); a part takes at most one`)
103
+ record = k
104
+ }
105
+ }
106
+ const pin = d.pin == 'start' || d.pin == 'end' ? d.pin : undefined
107
+ return {id, kind: 'part', component: typeof d.component == 'function' ? d.component : undefined,
108
+ render: typeof d.component != 'function' && typeof d.render == 'function' ? d.render : undefined,
109
+ props: d.props, description: String(d.description ?? id), state: d.state, local: d.local, hasLocal: 'local' in d,
110
+ params, record, in: d.in ?? ['page'], group: d.group, maxUses: Math.max(1, Number(d.maxUses) || 1),
111
+ required: !!d.required, pin, companions: Array.isArray(d.companions) ? d.companions : [], category: d.category,
112
+ result: !!d.result, decl: d}
113
+ }
114
+
115
+ /** 'container' | 'container:slot' entries → 'container:slot' (SYG450 for an unknown one) */
116
+ function places(e: Entry, containers: Record<string, Container>): string[] {
117
+ const out: string[] = []
118
+ for (const x of Array.isArray(e.in) ? e.in : [e.in]) {
119
+ const [c, s] = String(x).split(':')
120
+ const ct = containers[c]
121
+ if (!ct || c == 'row' || (s !== undefined && !ct.slotNames.includes(s))) {
122
+ dev('SYG450', 'defineCatalog', `'${e.id}': \`in\` names ${!ct ? `an unknown container '${c}'` : c == 'row' ? "'row' (a row is placed where its parts may go)" : `an unknown slot '${s}' of '${c}'`}; that entry is ignored`)
123
+ continue
124
+ }
125
+ for (const n of s === undefined ? ct.slotNames : [s]) if (!out.includes(c + ':' + n)) out.push(c + ':' + n)
126
+ }
127
+ return out.length ? out : ['page:default']
128
+ }
129
+
130
+ /** a record part's records, validated (SYG252: not an array of `{ value }`, or `from` threw) */
131
+ function records(cat: Catalog, p: any, state: any): any[] {
132
+ const part: Part | undefined = typeof p == 'string' ? cat.parts[p] : p
133
+ const rp = part?.record && (part.params[part.record] as any)
134
+ if (!rp) return []
135
+ let list: any
136
+ try { list = rp.from(state) } catch (e: any) {
137
+ dev('SYG252', 'catalog', `part '${part!.id}': the record param's from() threw (${e?.message ?? e}); no records`, 'warn', cat, part!.id + 'threw')
138
+ return []
139
+ }
140
+ const ok = (r: any) => r && typeof r == 'object' && (typeof r.value == 'string' || (typeof r.value == 'number' && isFinite(r.value)))
141
+ if (!Array.isArray(list) || !list.every(ok)) {
142
+ dev('SYG252', 'catalog', `part '${part!.id}': the record param's from() must return an array of { value, name?, aliases?, description? } (value: a string or a number); ${Array.isArray(list) ? 'entries without one are skipped' : 'no records'}`, 'warn', cat, part!.id)
143
+ return Array.isArray(list) ? list.filter(ok) : []
144
+ }
145
+ return list
146
+ }
147
+
148
+ /**
149
+ * defineCatalog({ parts, containers?, guidance?, limits?, examples? }): the normalized, validated
150
+ * catalog. Types: src/ai.d.ts.
151
+ */
152
+ export function defineCatalog(decl: any = {}): any {
153
+ const containers: Record<string, Container> = {}
154
+ const parts: Record<string, Part> = {}
155
+ const dc = decl.containers || {}
156
+ containers.page = container('page', {...dc.page, in: [], slots: {default: null}})
157
+ // the built-in row, unless the catalog opts out: `rows: false` (canonical; `containers: { row: false }` too)
158
+ const rows = decl.rows !== false && dc.row !== false
159
+ if (rows) containers.row = container('row', {direction: 'row', min: 2, max: 4, accepts: 'leaves', ...dc.row, slots: {default: null}})
160
+ for (const id in dc) {
161
+ if (id == 'page' || id == 'row') continue
162
+ if (BAD_ID.test(id)) fail(`the container id '${id}' can't be a node key (no ':', '~' or '/')`)
163
+ containers[id] = container(id, dc[id] || {})
164
+ }
165
+ for (const id in decl.parts || {}) {
166
+ if (BAD_ID.test(id)) fail(`the part id '${id}' can't be a node key (no ':', '~' or '/')`)
167
+ if (containers[id] || id == 'row') fail(`'${id}' is both a part and a container (they share one id namespace${id == 'page' || id == 'row' ? `; '${id}' is built in` : ''})`)
168
+ parts[id] = part(id, decl.parts[id])
169
+ }
170
+ const cat: any = {
171
+ parts,
172
+ containers,
173
+ description: decl.description,
174
+ categories: decl.categories && typeof decl.categories == 'object' ? decl.categories : {},
175
+ rows,
176
+ guidance: decl.guidance,
177
+ limits: {maxParts: 12, maxDepth: 4, ...decl.limits},
178
+ examples: Array.isArray(decl.examples) ? decl.examples : [],
179
+ get: (id: string): Entry | undefined => Object.prototype.hasOwnProperty.call(parts, id) ? parts[id]
180
+ : Object.prototype.hasOwnProperty.call(containers, id) ? containers[id] : undefined,
181
+ records: (p: any, state: any) => records(cat, p, state),
182
+ options: (state: any): Option[] => {
183
+ const out: Option[] = []
184
+ for (const id in parts) {
185
+ const p = parts[id]
186
+ if (!p.record) { out.push({option: id, part: id, description: p.description}); continue }
187
+ for (const r of records(cat, p, state)) {
188
+ out.push({option: `${id}:${r.value}`, part: id, params: {[p.record]: r.value},
189
+ description: `${p.description}: ${r.description ?? r.name ?? r.value}`})
190
+ }
191
+ }
192
+ return out
193
+ },
194
+ finish: (layout: any, state?: any) => finish(cat, layout, state),
195
+ render: (slice: any, state: any, opts?: any) => renderLayout(cat, slice, state, opts),
196
+ }
197
+ for (const id in containers) if (id != 'page') containers[id].in = places(containers[id], containers)
198
+ if (rows) containers.row.in = []
199
+ for (const id in parts) {
200
+ const p = parts[id]
201
+ p.in = places(p, containers)
202
+ p.companions = p.companions.filter(c => {
203
+ if (parts[c] && c != id) return true
204
+ dev('SYG250', 'defineCatalog', `part '${id}': the companion '${c}' is not a part of this catalog; it is ignored`)
205
+ return false
206
+ })
207
+ }
208
+ // H-2: what chat({ ask: { catalog } }) runs on (so an app with chat and no catalog carries none of it)
209
+ Object.defineProperty(cat, '_ask', {value: askKit})
210
+ return cat as Catalog
211
+ }