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
@@ -248,6 +248,21 @@ Ticket.resources = {
248
248
 
249
249
  A `noul` question becomes a `predicate` whose instructions include the criteria. OpenAI may refuse a question: its answer is then `{ type: 'refusal', … }`, so check `answer.type` before reading `choice`. The type `Decision<Q, RefusalAnswer>` includes it.
250
250
 
251
+ ## Large question sets
252
+
253
+ Servers limit what one request may ask: Ollama's `/v1/systemone` takes at most 64 questions, and each question's prompt (the `state` plus every question of the request) must fit the model's context, 8,192 tokens for `nimble`. `decide()` handles it, and your component still gets one reply:
254
+
255
+ - More than `maxQuestions` questions (default 64): the request goes as several requests of about even size, sent in parallel. Their replies arrive as one: `answers` merged by name, `usage` summed, `model` from the first.
256
+ - `maxPromptTokens`: the same split when the estimated prompt of one question passes it. The estimate runs high (on `nimble`, 1.13 to 1.30 times the count Ollama reports), and there is no default.
257
+ - A request the server rejects as too large is halved and sent again, at most 3 times deep. "Too large" means HTTP 400 or 413 whose body mentions tokens, fields, context, a limit, or "too long", "too many" or "too large". Other errors arrive as before. A rejection costs one fast round trip (Ollama answers it in about 10 ms), so Jev's requests, which fit, go out exactly as before.
258
+ - `split: false` sends every request whole and never halves it.
259
+
260
+ ```js
261
+ decide({ model: 'nimble', url: 'http://localhost:11434/v1/systemone', state, questions, maxQuestions: 32 })
262
+ ```
263
+
264
+ The request keeps the whole decision in `json`, and its parts in `batch`, with `combine` and `split`. The [fetch driver](./http.md) reads those three keys, and any request can use them. Under `renderComponent` the fake answers a split request as one: `t.requests('HTTP')` lists it once, and `t.respond('HTTP', answers(questions, picks))` answers all its questions together.
265
+
251
266
  ## Keys stay on the server
252
267
 
253
268
  The browser never holds a provider key ([SYG670](https://sygnal.js.org/reference/errors/#syg670) refuses to send one to another origin). `decide()` posts to `/api/decide` on your own server by default, and that route adds the key. Keep it narrow, as for [chat](./ai-chat.md#shipping-it): fix the model, require a session, and limit the size of `state`, so the route can't be used as a free decision API.
@@ -326,5 +341,6 @@ It reads the actions from the components' [`agent` declarations](./agent.md#the-
326
341
 
327
342
  - [Agents](./agent.md): the `agent` static, the command bar and the in-app assistant
328
343
  - [AI Chat](./ai-chat.md): the chat driver, transports, and shipping an AI SDK route
344
+ - [Composing UI with a Model](./ai-compose.md): a decision model builds a page from your catalog of parts with `compose.decisions()`
329
345
  - [Resources and Caching](./resources.md): the rules a decision resource follows
330
346
  - [Support inbox](./recipes/ai-support-inbox.md): triage, sorting and escalation in a whole inbox
@@ -137,6 +137,7 @@ A request is an object (or a URL string, for a plain GET with no reply actions):
137
137
  | `updates` | — | After a 2xx reply, write it into this component's resources (and their cache entries): `updates: 'item'`, or `{ items: (list, reply) => newList }` ([Writing a reply into the cache](./resources.md#writing-a-reply-into-the-cache)) |
138
138
  | `retry` | driver's `retry` for GET/HEAD (0) | Retry network errors, 408, 429 and 5xx: a count or `{ count, delayMs, maxDelayMs, jitter }` ([Retries](./resources.md#retries)) |
139
139
  | `validate` | — | A Standard Schema the body must pass ([Validation](./resources.md#validation)) |
140
+ | `batch`, `combine`, `split` | — | One reply from several fetches: `batch` sends these requests in parallel instead (each inherits this request's fields; its own `json`, `body`, `parse` win), `combine(bodies, requests)` merges their parsed bodies into the reply, and `split(request, error)` returns the requests to try instead when one fails (at most 3 deep). `decide()` sets them for [large question sets](./ai-decisions.md#large-question-sets); the test fake answers such a request as one |
140
141
 
141
142
  Any other field is yours (an id, the query text): it isn't sent, and it comes back on the failure's `request`. A sink that returns `ABORT`, `null` or `undefined` sends nothing.
142
143
 
@@ -48,10 +48,19 @@ App.model = {
48
48
  STATE: (state) => ({ ...state, status: 'loading' }),
49
49
  MCP: (state) => ({ callTool: 'get_forecast', args: { city: state.city }, ok: 'RESULT' }),
50
50
  },
51
- // tell the model what the user picked: it reads it on its next turn
51
+ // tell the model what the user picked: store it on the server, where the model reads it with a
52
+ // tool, then send it as model context too (see Model context below)
52
53
  PICK: {
53
54
  STATE: (state, date) => ({ ...state, selected: date }),
54
- MCP: (state, date) => ({ updateModelContext: { city: state.city, selectedDay: state.days.find((d) => d.date === date) } }),
55
+ MCP: (state, date) => ({ callTool: 'select_day', args: { city: state.city, day: state.days.find((d) => d.date === date) }, ok: 'PICKED' }),
56
+ },
57
+ PICKED: {
58
+ MCP: (state) => ({
59
+ updateModelContext: {
60
+ content: `The user selected ${state.selected} in the ${state.city} forecast card.`,
61
+ structuredContent: { city: state.city, selectedDay: state.days.find((d) => d.date === state.selected) },
62
+ },
63
+ }),
55
64
  },
56
65
  // post a message to the conversation, as the user
57
66
  ASK: { MCP: (state) => ({ message: `What should I pack for ${state.city} on ${state.selected}?` }) },
@@ -92,7 +101,7 @@ The latest tool input, tool result, host context and `host` are replayed to a co
92
101
  | Value | Does |
93
102
  |---|---|
94
103
  | `{ callTool: 'name', args, ok, error }` | Calls a tool on the MCP server through the host. `ok` gets the `CallToolResult`; `error` gets `{ error, code?, result?, request }` (a JSON-RPC error, or a result with `isError: true`) |
95
- | `{ updateModelContext: value }` | Tells the model something, for its next turn: a string (one text block), content blocks, or an object (`structuredContent`) |
104
+ | `{ updateModelContext: value }` | Tells the model something, for its next turn: a string (one text block), content blocks, an object (`structuredContent`), or `{ content, structuredContent }`. The host decides whether the model sees it: see [Model context](#model-context) |
96
105
  | `{ message: 'text' }` | Posts a message to the conversation as the user (a string or content blocks) |
97
106
  | `{ openLink: 'https://…' }` | Asks the host to open a link |
98
107
  | `{ displayMode: 'fullscreen' }` | Asks for `'inline'`, `'fullscreen'` or `'pip'`; `ok` gets `{ mode }`, the mode the host chose |
@@ -155,6 +164,45 @@ Export.model = {
155
164
 
156
165
  **Theme and size.** Follow `host-context-changed` for the host's `theme` (and its `styles.variables`, CSS custom properties the host suggests), so the view matches the conversation around it. Keep the view compact for `inline` mode; offer full screen with `{ displayMode: 'fullscreen' }` when `availableDisplayModes` includes it.
157
166
 
167
+ ### Model context
168
+
169
+ A view has three ways to tell the model something, and hosts handle them differently:
170
+
171
+ - `{ message }` posts a message to the conversation as the user. The model sees it and answers it, in every host that supports `message`. Use it when the user's click is a question (the starter's **Ask about this day**).
172
+ - `{ updateModelContext }` gives the host context for the model's **next** turn, and the host decides what to do with it. The spec says a host *should* pass it on, not how. Cursor adds it to the next user message; claude.ai keeps it and lets the model fetch it with a built-in tool ("Reading widget context"), and so does Claude Desktop's Code tab (`read_widget_context`, which the host points the model to); Claude Desktop's chat (tested October 2026) accepts it, but its model has no way to read it.
173
+ - **A tool on your server.** The view saves what the user did with a tool only the view may call, and the model reads it with an ordinary tool. This reaches the model in every host, because every host offers the server's tools.
174
+
175
+ The starter does the last two. When the user picks a day, the view calls `select_day`, then sends the same selection as model context:
176
+
177
+ ```js
178
+ // server/server.js
179
+ let selection = null // one per process: the stdio server runs one per host connection
180
+
181
+ // only the view can call it: visibility ['app'] keeps it out of the model's tool list
182
+ server.registerTool('select_day', {
183
+ description: 'Records the day the user selected in the forecast card.',
184
+ inputSchema: { city: z.string(), day: z.object({ date: z.string(), sky: z.string(), high: z.number(), low: z.number() }) },
185
+ _meta: { ui: { resourceUri: VIEW_URI, visibility: ['app'] } },
186
+ }, async ({ city, day }) => {
187
+ selection = { city, day }
188
+ return { content: [{ type: 'text', text: `Selected ${day.date} in ${city}` }], structuredContent: selection }
189
+ })
190
+
191
+ // the model's way to read it; get_forecast's description says to call it
192
+ server.registerTool('get_selected_day', {
193
+ description: 'The day the user selected in the forecast card shown by get_forecast, if any.',
194
+ inputSchema: {},
195
+ }, async () => ({
196
+ content: [{ type: 'text', text: selection ? `The user selected ${selection.day.date} in ${selection.city}.` : 'The user has not selected a day.' }],
197
+ }))
198
+ ```
199
+
200
+ The stdio server runs once per host connection, so one variable holds one user's selection. A server many users share (Streamable HTTP behind a public URL) has to keep it per user or session.
201
+
202
+ Send `updateModelContext` with a text `content`: hosts list the content types they take (`MCP.select('host')` gives `hostCapabilities.updateModelContext`, Claude's is `{ text: {}, image: {} }`), and one that doesn't list `structuredContent` may drop an update that only has it.
203
+
204
+ A host may also keep a tool's view (the `ui://` resource) and the tool list for as long as the server stays connected: Claude Desktop fetches them once per connection. After rebuilding the view or changing a tool, reconnect the server (restart the host, or turn the server off and on) before trying it again.
205
+
158
206
  ## The view's own tools
159
207
 
160
208
  The model sees the server's tools. With `tools: agentTools`, the view also offers its components' [`agent` declarations](./agent.md) to the host as tools of its own, so the model can operate the view while it is shown: select a day, change units, open a detail. Calls go through the same [call rules](./agent.md#the-call-rules) as any agent's.
@@ -263,7 +311,14 @@ it('shows the result, refreshes, and tells the model what the user picked', asyn
263
311
 
264
312
  t.simulateEvent('.day', 'click', { data: { date: 'Tue' } })
265
313
  await t.waitForState((s) => s.selected === 'Tue')
266
- expect(t.requests('MCP').at(-1)).toEqual({ updateModelContext: { city: 'Oslo', selectedDay: days[1] } })
314
+ expect(t.requests('MCP').at(-1)).toEqual({ callTool: 'select_day', args: { city: 'Oslo', day: days[1] }, ok: 'PICKED' })
315
+ await t.respond('MCP', { content: [], structuredContent: { city: 'Oslo', day: days[1] } }, 'PICKED')
316
+ expect(t.requests('MCP').at(-1)).toEqual({
317
+ updateModelContext: {
318
+ content: 'The user selected Tue in the Oslo forecast card.',
319
+ structuredContent: { city: 'Oslo', selectedDay: days[1] },
320
+ },
321
+ })
267
322
  })
268
323
  ```
269
324