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
package/llms.txt CHANGED
@@ -79,7 +79,7 @@ SaveButton.intent = ({ DOM }) => ({ SAVE: DOM.click('.save') })
79
79
  SaveButton.model = {
80
80
  SAVE: {
81
81
  STATE: (state) => ({ ...state, saving: true }),
82
- EVENTS: event('DOC_SAVED', (state) => ({ id: state.id })), // or event('RESET'), event('MODE', 'dark')
82
+ EVENTS: event('DOC_SAVED', (state) => ({ id: state.id })), // or event('RESET'), event('MODE', 'dark'); return ABORT to send nothing
83
83
  },
84
84
  }
85
85
  function Toast({ state }) { // anywhere in the tree: EVENTS is a global bus
@@ -248,15 +248,17 @@ TaskPage.model = { // a page shares the parent's state: no initialState (SYG40
248
248
  - Links are plain `<a href={href(...)}>`, never click handlers: the driver intercepts same-origin left clicks at the document (not modified clicks, `target`, `download`, `rel="external"`, `data-router-ignore`, other origins). `params` and `query` values are strings; `href()` is typed from the route table (an unknown name or a missing param is a type error; SYG130/SYG131 at run time). `instance={state.route.path}` re-creates the page for `/tasks/1` → `/tasks/2`. Pages derive `resources` from `state.route.params`.
249
249
  - ROUTER commands: `{ to, params, query, hash, replace, scroll }`, `{ url }`, `{ back: true }`, `{ forward: true }`, `{ go: n }`, `{ block: 'ACTION' | false }` (also stops back/forward; `proceed` navigates anyway), `{ prefetch: 'task', params }` (calls `makeRouter({ prefetch })`). `route` is reserved for the declaration. A bad command is SYG620. The first declarer (the root) gets each route first, so keep guards there.
250
250
  - Options: `makeRouter({ routes, base: '/app', mode: 'hash', scroll, focus })`. SSR: `router.current(req.url)` in the seeded state; `renderToString(App, { head: list })` + `renderHead(list)`. Vike: `makeRouter({ routes, navigate })` with `navigate` from `'vike/client/router'`, registered in `pages/+drivers.js`. HEAD: a later-mounted component's title wins; `meta`/`link` merge; a model entry can also send `HEAD: (state, data) => ({ title })`. Guides: https://sygnal.js.org/guide/router/, https://sygnal.js.org/guide/head/
251
- ### AI (`sygnal/ai`): chat, agents, decisions
251
+ ### AI (`sygnal/ai`): chat, agents, decisions, composed UI
252
252
  - **A chat conversation** (send, streaming draft, Stop keeping the partial reply, Retry) is the `chat` behavior with no tools; don't hand-roll it: `Chat.uses = { chat: chat({ agent: false, form: '.ask', prompt: '.prompt', stop: '.stop', regenerate: '.retry' }) }` → `state.chat` (`messages`, `prompt` (bind `<input className="prompt" value={state.chat.prompt} />`), `draft`, `status`: `'ready' | 'submitted' | 'streaming' | 'error'`, `error`), the markup in the host's own view (SYG104), `next('chat.SEND', text)` to send from code, a host entry `'chat.DONE'` after a reply; options and slice: agent.md "Options and state". **One-off requests** (a summary, structured `output`, or your own state shape) are a driver request, like HTTP: `SEND: { STATE: (s) => ({ ...s, messages: [...s.messages, { role: 'user', content: s.prompt }], prompt: '' }), LLM: (s) => ({ messages: [...s.messages, { role: 'user', content: s.prompt }], key: 'reply', delta: 'DELTA', ok: 'DONE', error: 'FAILED' }) }` (both sinks see the state before the action: each builds the new message), `DELTA: (s, { text }) => ({ ...s, draft: text })` (all the text so far), `DONE: (s, { message }) => ({ ...s, messages: [...s.messages, message], draft: '' })` (also `text`, `toolCalls`, and `value` for `output: schema`), `FAILED` gets `{ error }`; `{ abort: 'reply' }` stops it (nothing more arrives; `latest` is on: a new request with the same key aborts the old); render with `messageText(m)`. main.js, a local Ollama: `run(App, { LLM: makeChatDriver({ transport: openResponses({ baseURL: 'http://localhost:11434/v1', model: 'llama3.2' }) }) })`; production: `transport: uiMessageStream('/api/chat')` (an AI SDK route on your server; the JSON it POSTs: ai-chat.md, "What uiMessageStream sends"). Tests need no driver: `await t.stream('LLM', ['Hel', 'lo'])` (then `ok`), `t.respond('LLM', text)`, `t.fail('LLM', 429)`, `t.requests('LLM')`.
253
253
  - **`agent` static**: what an LLM may do in the app: `TodoApp.agent = { name: 'todos', description: 'The todo list', read: (state) => ({ todos: state.todos.map(({ id, text, done }) => ({ id, text, done })) }), actions: { ADD: { description: 'Add a todo', input: z.string().min(1) }, CLEAR_DONE: { description: 'Delete the done todos', consequential: true } } }` → tools `todos_add`, `todos_clear_done`, `todos_read`. **Only declared actions are tools, and `read` is all agents see** (a projection: keep ids, leave out secrets; `untrusted: true` when it holds text users typed). Per action: `input` (a Standard Schema with JSON Schema: zod 4.2+, or `jsonSchema({ type: 'string' })`), `consequential` (the user confirms), `when: (state) => bool`, `idempotent`, `until: 'search'` (the call answers once that resource has loaded, with the `read`, not `loading`; or `until: (state) => bool` for a reply action; `timeout` → `{ ok: true, pending: true }`); an entry counts as a trigger (SYG102); a Collection item's `agent` (+ `label: (s) => s.text`) gives one tool per action with an `id` parameter. Data too large for `read` (a long list, one record): `queries: { find: { description, input, run: (state, input) => state.todos.filter(…) } }` → read-only tool `todos_find`, `{ ok: true, result }`, nothing dispatched; `run` is pure and synchronous (async is SYG246: load data with an action + `until`). A reducer refuses with `abort('reason')` (from `'sygnal'`). Tests: `t.tools()`, `await t.callTool('todos_add', { value: 'milk' })` → `{ ok: true, state }` (a consequential tool needs `{ confirm: true }`), `t.agentContext()`.
254
254
  - **Decisions** (typed answers from a decision model; a fetch-driver request, `HTTP: makeFetchDriver()`): `const questions = { topic: choice('What is this ticket about?', { billing: 'Payments, invoices', bug: 'Something is broken' }), urgent: noul('Does it need handling today?'), mood: score('How upset is the customer?', ['calm', 'annoyed', 'angry']) }`, as a resource: `Ticket.resources = { triage: (state) => state.text && decide({ model: 'jev-latest', state: state.text, questions }) }` → `state.triage.data.answers.topic.choice` (`.confidence`: below ~0.6, ask a chat model), `answers.urgent.noul` (0–1), `answers.mood.score`; a click-triggered one is an `HTTP` sink `decide({ …, ok, error })`. Tests: `await t.respond('HTTP', answers(questions, { topic: 'bug', urgent: true }), 'triage')`.
255
- - **Never put a provider API key in browser code**: the browser calls your server (`uiMessageStream('/api/chat')`, `decide()`'s default `/api/decide`), which adds the key, fixes the model and instructions, and rate-limits; an auth header to another origin is refused (SYG670). Strict provider schemas: `openResponses({ baseURL, model, strict: strictSchemas })` (an import; `strict: true` is SYG672). Every request sends the whole conversation (models are stateless); never `fetch` a provider from a component.
256
- - Guides: `node_modules/sygnal/dist/guide/ai-chat.md` (transports, structured `output`, tools, shipping), `ai-decisions.md` (escalation, `decide.openai()`), `agent.md` (the in-app assistant `uses = { assistant: chat({ form: '.ask', prompt: '.prompt', instructions }) }`, `commandBar`, security), `webmcp.md` (`experimentalExposeWebMcp(app)`, `form(schema, { tool: formTool({ name, description }) })`: experimental, outside semver), `mcp-apps.md` (`makeMcpAppDriver()`: the app inside Claude or ChatGPT); online https://sygnal.js.org/guide/ai-chat/ (and `/guide/ai-decisions/`, `/guide/agent/`, `/guide/webmcp/`, `/guide/mcp-apps/`)
255
+ - **A page a model composes from your parts** (a dashboard the user describes in words; the model only picks parts, params and places, never markup): `const catalog = defineCatalog({ parts: { revenue: { component: Kpi, props: { metric: 'revenue' }, description: 'Revenue (KPI card: the total this month)' }, orders: { component: OrdersTable, description: 'Orders (table of recent orders)', params: { status: { default: 'any', options: { any: 'orders of every status', pending: 'pending orders (not paid yet)' } } } }, tasks: { component: Checklist, state: 'tasks', description: "Tasks (the user's checklist)" } } })` (`description` is all a model sees: "Noun (what it shows)", no overlap between parts; params arrive as props; `state` is a key, a lens, or for a record param (`{ from: (state) => records }`) `(node) => byId('customers', node.params.id)` with `byId` from `'sygnal'`; `containers: { tabs: { slots, render: ({ slots, control }) => … } }` are view functions, never components (SYG161)), `Dashboard.uses = { page: compose(catalog, { backend: compose.decisions({ model: 'jev-latest' }), form: '.ask', prompt: '.prompt' }) }` (or `compose.llm()` on the `LLM` sink: same app, same tests) → `state.page` (`status`: `'composing' | 'preview' | 'done' | 'unavailable' | 'error'`, `layout`, `decisions`, `error`), rendered in the host's view as `{catalog.render(state.page, state)}` or `<Composed catalog={catalog} from="page" />`: the parts are the host's children (lenses, `CHILD.select`) keyed by identity, so a follow-up `next('page.COMPOSE', 'Move the orders to the top')` edits the page and keeps the untouched parts' instances (`{ prompt, fresh: true }` starts over; `persist` the slice to keep the page); for an agent: `actions: { ...compose.action('page') }`, `read: (s) => ({ page: compose.summary(s.page) })`. Tests (either backend): `t.simulateAction('page.COMPOSE', 'Pending orders')`, then `await composeWith(t, catalog, [{ part: 'orders', params: { status: 'pending' } }])`.
256
+ - **Cards in a chat** (the assistant asks with your parts; the user clicks the answer): `uses = { assistant: chat({ form: '.ask', prompt: '.prompt', ask: { catalog } }) }` gives the model the tool `ask_user({ need })`; the card composes from the catalog (`compose.llm()` on the chat's sink by default, or `ask: { catalog, backend: compose.decisions({ model: 'jev-latest' }) }`) and lives on the tool part as `part.ui` (`status`: `'composing' | 'open' | 'answered' | 'failed' | 'cancelled'`; persisted with the chat): render it where the view renders a `tool-ask_user` message part, `askCard(catalog, state, part) ?? <p>Composing…</p>`. A part with `result: true` answers it: `PICK: { PARENT: (state, id, next, props) => compose.answer(props, id) }` → the call's output is `{ part, answer }` and the chat goes on (no result part in the catalog: SYG686). Tools the app answers itself: `clientTools: { confirm: { description, inputSchema } }`, listed by `openToolCalls(state.assistant)`, answered with `next('assistant.TOOL_OUTPUT', { toolCallId, output })`. `chat({ model: (state) => state.chatModel })` picks the model per request. Tests: `await t.stream('LLM', [{ toolCall: { id: 'c1', name: 'ask_user', input: { need: 'Pick a plan' } } }])`, then `await composeWith(t, catalog, [{ part: 'plans' }])`.
257
+ - **Never put a provider API key in browser code**: the browser calls your server (`uiMessageStream('/api/chat')`, the default `/api/decide` of `decide()` and `compose.decisions()`), which adds the key, fixes the model and instructions, and rate-limits; an auth header to another origin is refused (SYG670). Strict provider schemas: `openResponses({ baseURL, model, strict: strictSchemas })` (an import; `strict: true` is SYG672). Every request sends the whole conversation (models are stateless); never `fetch` a provider from a component.
258
+ - Guides: `node_modules/sygnal/dist/guide/ai-chat.md` (transports, structured `output`, tools, shipping), `ai-decisions.md` (escalation, `decide.openai()`), `ai-compose.md` (catalogs, containers, edits, `byId`), `ai-cards.md` (`chat({ ask })`, client tools), `agent.md` (the in-app assistant `uses = { assistant: chat({ form: '.ask', prompt: '.prompt', instructions }) }`, `commandBar`, security), `webmcp.md` (`experimentalExposeWebMcp(app)`, `form(schema, { tool: formTool({ name, description }) })`: experimental, outside semver), `mcp-apps.md` (`makeMcpAppDriver()`: the app inside Claude or ChatGPT); online https://sygnal.js.org/guide/ai-chat/ (and `/guide/ai-decisions/`, `/guide/ai-compose/`, `/guide/ai-cards/`, `/guide/agent/`, `/guide/webmcp/`, `/guide/mcp-apps/`)
257
259
 
258
260
  ## 4. API facts
259
- - **Child props**: `<Rating name="food" value={state.food} />` → `function Rating({ state, name, value })`; reducers read `props.name` (4th arg); intent gets the `props$` stream. Reserved: `state` (lens: `"key"` or `{ get, set }`), `children`, `slots`, `context`, `uid` (SYG106; an error in strict mode). Without `state=` a child shares its parent's whole state. A child rendered by tag (`<Stopwatch state="stopwatch" />`) has no `initialState`: its start values go in the parent's (`stopwatch: { ms: 0 }`); its own throws SYG405 and the parent renders its error fallback (a test rendering the child as the root doesn't show it). Test a child alone with `renderComponent(Child, { initialState, context: { lang: 'fr' } })` (the ancestors' context). `uid(name?)` (a view prop, also on reducers' `props`) is a stable per-instance id for `id`/`for`/`aria-*` pairs: `<label for={uid('email')}>`, `<input id={uid('email')} />`.
261
+ - **Child props**: `<Rating name="food" value={state.food} />` → `function Rating({ state, name, value })`; reducers read `props.name` (4th arg); intent gets the `props$` stream. Reserved: `state` (lens: `"key"` or `{ get, set }`), `children`, `slots`, `context`, `uid` (SYG106; an error in strict mode). `key={…}` is a child's identity (not a prop; else its `id` prop, else its place in the view): a new key re-creates it. Without `state=` a child shares its parent's whole state. A child rendered by tag (`<Stopwatch state="stopwatch" />`) has no `initialState`: its start values go in the parent's (`stopwatch: { ms: 0 }`); its own throws SYG405 and the parent renders its error fallback (a test rendering the child as the root doesn't show it). Test a child alone with `renderComponent(Child, { initialState, context: { lang: 'fr' } })` (the ancestors' context). `uid(name?)` (a view prop, also on reducers' `props`) is a stable per-instance id for `id`/`for`/`aria-*` pairs: `<label for={uid('email')}>`, `<input id={uid('email')} />`.
260
262
  - **A sliced child with its own start values**: `Editor.isolatedState = true` lets `<Editor state="draft" />` keep an `initialState`, used only while `state.draft` is `undefined` (an existing slice is kept; SYG425 warns when it lacks keys the `initialState` has). To start it from its `initialState` on every mount: `<Editor state="draft" resetState />`.
261
263
  - **CHILD.select(Comp)** emits exactly what the child's `PARENT` function returned, for every instance (Collection items too). Put an id in the payload.
262
264
  - **Extract a component without changing markup**: snapshot `t.html()` on the unchanged code first; move the elements verbatim into the child (a component adds no wrapper element and no attributes, so the HTML stays identical); props in, `PARENT` out; the parent drops its old selectors and uses `CHILD.select(Child)`; the snapshot must still match. Recipe: https://sygnal.js.org/guide/parent-child/#recipe-extract-a-component-without-changing-the-markup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sygnal",
3
- "version": "6.2.0",
3
+ "version": "6.4.0",
4
4
  "description": "An intuitive framework for building fast and small components or applications based on Cycle.js",
5
5
  "main": "./dist/index.cjs.js",
6
6
  "types": "./dist/index.d.ts",
@@ -138,6 +138,7 @@
138
138
  "test:examples": "node scripts/test-examples.mjs",
139
139
  "test:recipes": "node scripts/test-recipes.mjs",
140
140
  "test:ai-local": "vitest run --testTimeout=600000 ollama.test",
141
+ "test:compose-live": "node --env-file-if-exists=\"${TYPESAFE_ENV_FILE:-$HOME/.config/typesafe/env}\" scripts/compose-live.mjs",
141
142
  "test:types": "npx tsc --project tsconfig.json --noEmit && npx tsc --project type-tests/tsconfig.json",
142
143
  "test:browser": "npm test --prefix browser-tests",
143
144
  "test:watch": "vitest",