sygnal 5.4.0 → 6.1.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.
- package/CHANGELOG.md +716 -0
- package/README.md +24 -9
- package/dist/ai.cjs.js +115 -0
- package/dist/ai.cjs.js.map +1 -0
- package/dist/ai.esm.js +2 -0
- package/dist/ai.esm.js.map +1 -0
- package/dist/astro/client.cjs.js +16 -9
- package/dist/astro/client.cjs.js.map +1 -1
- package/dist/astro/client.mjs +16 -9
- package/dist/astro/client.mjs.map +1 -1
- package/dist/astro/index.cjs.js +958 -92
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +958 -93
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +3396 -165
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3396 -165
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js +1840 -0
- package/dist/devtools.cjs.js.map +1 -0
- package/dist/devtools.esm.js +1827 -0
- package/dist/devtools.esm.js.map +1 -0
- package/dist/diagnostics.cjs.js +3190 -296
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +3190 -296
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/element.cjs.js +230 -0
- package/dist/element.cjs.js.map +1 -0
- package/dist/element.esm.js +228 -0
- package/dist/element.esm.js.map +1 -0
- package/dist/guide/accessibility.md +310 -0
- package/dist/guide/adapters.md +221 -0
- package/dist/guide/agent.md +588 -0
- package/dist/guide/ai-chat.md +693 -0
- package/dist/guide/ai-decisions.md +330 -0
- package/dist/guide/behaviors.md +312 -0
- package/dist/guide/browser-sources.md +203 -0
- package/dist/guide/drag-and-drop.md +342 -0
- package/dist/guide/element-commands.md +306 -0
- package/dist/guide/error-boundaries.md +169 -0
- package/dist/guide/forms-reference.md +409 -0
- package/dist/guide/forms.md +251 -0
- package/dist/guide/http.md +301 -0
- package/dist/guide/inputs.md +173 -0
- package/dist/guide/mcp-apps.md +272 -0
- package/dist/guide/persistence.md +245 -0
- package/dist/guide/recipes/ai-form-fill.md +163 -0
- package/dist/guide/recipes/ai-summarize.md +155 -0
- package/dist/guide/recipes/ai-support-inbox.md +190 -0
- package/dist/guide/recipes/carousel.md +155 -0
- package/dist/guide/recipes/charts.md +177 -0
- package/dist/guide/recipes/code-editor.md +135 -0
- package/dist/guide/recipes/data-grid.md +155 -0
- package/dist/guide/recipes/data-table.md +196 -0
- package/dist/guide/recipes/i18n.md +222 -0
- package/dist/guide/recipes/icons.md +115 -0
- package/dist/guide/recipes/overview.md +43 -0
- package/dist/guide/recipes/rich-text.md +154 -0
- package/dist/guide/resources.md +471 -0
- package/dist/guide/ssr.md +255 -0
- package/dist/guide/timers.md +178 -0
- package/dist/guide/ui/accordion.md +93 -0
- package/dist/guide/ui/combobox.md +144 -0
- package/dist/guide/ui/dialog.md +124 -0
- package/dist/guide/ui/disclosure.md +60 -0
- package/dist/guide/ui/menu.md +125 -0
- package/dist/guide/ui/overview.md +166 -0
- package/dist/guide/ui/popover.md +101 -0
- package/dist/guide/ui/select.md +114 -0
- package/dist/guide/ui/tabs.md +161 -0
- package/dist/guide/ui/toaster.md +152 -0
- package/dist/guide/ui/tooltip.md +103 -0
- package/dist/guide/undo.md +178 -0
- package/dist/guide/virtual-collections.md +183 -0
- package/dist/guide/web-components.md +179 -0
- package/dist/guide/webmcp.md +226 -0
- package/dist/guide/widgets.md +217 -0
- package/dist/index.cjs.js +19288 -7408
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +4466 -552
- package/dist/index.esm.js +19274 -7461
- package/dist/index.esm.js.map +1 -1
- package/dist/jsx-dev-runtime.cjs.js +26 -334
- package/dist/jsx-dev-runtime.cjs.js.map +1 -1
- package/dist/jsx-dev-runtime.esm.js +19 -327
- package/dist/jsx-dev-runtime.esm.js.map +1 -1
- package/dist/jsx-runtime.cjs.js +26 -334
- package/dist/jsx-runtime.cjs.js.map +1 -1
- package/dist/jsx-runtime.esm.js +19 -327
- package/dist/jsx-runtime.esm.js.map +1 -1
- package/dist/jsx.cjs.js +10 -314
- package/dist/jsx.cjs.js.map +1 -1
- package/dist/jsx.esm.js +8 -315
- package/dist/jsx.esm.js.map +1 -1
- package/dist/react.cjs.js +117 -0
- package/dist/react.cjs.js.map +1 -0
- package/dist/react.esm.js +115 -0
- package/dist/react.esm.js.map +1 -0
- package/dist/shims/globalthis.cjs +20 -0
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/ui-combobox.cjs.js +169 -0
- package/dist/ui-combobox.cjs.js.map +1 -0
- package/dist/ui-combobox.esm.js +148 -0
- package/dist/ui-combobox.esm.js.map +1 -0
- package/dist/ui-menu.cjs.js +87 -0
- package/dist/ui-menu.cjs.js.map +1 -0
- package/dist/ui-menu.esm.js +66 -0
- package/dist/ui-menu.esm.js.map +1 -0
- package/dist/ui-select.cjs.js +104 -0
- package/dist/ui-select.cjs.js.map +1 -0
- package/dist/ui-select.esm.js +83 -0
- package/dist/ui-select.esm.js.map +1 -0
- package/dist/ui.cjs.js +738 -0
- package/dist/ui.cjs.js.map +1 -0
- package/dist/ui.esm.js +727 -0
- package/dist/ui.esm.js.map +1 -0
- package/dist/vike/ClientOnly.cjs.js +4 -14
- package/dist/vike/ClientOnly.cjs.js.map +1 -1
- package/dist/vike/ClientOnly.mjs +4 -14
- package/dist/vike/ClientOnly.mjs.map +1 -1
- package/dist/vike/{+config.js → config/+config.js} +9 -1
- package/dist/vike/config/+config.js.map +1 -0
- package/dist/vike/config/package.json +6 -0
- package/dist/vike/onRenderClient.cjs.js +165 -95
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +165 -95
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/dist/vike/onRenderHtml.cjs.js +59 -24
- package/dist/vike/onRenderHtml.cjs.js.map +1 -1
- package/dist/vike/onRenderHtml.mjs +60 -25
- package/dist/vike/onRenderHtml.mjs.map +1 -1
- package/dist/vite/plugin.cjs.js +919 -89
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +919 -90
- package/dist/vite/plugin.mjs.map +1 -1
- package/dist/zag.cjs.js +408 -0
- package/dist/zag.cjs.js.map +1 -0
- package/dist/zag.esm.js +405 -0
- package/dist/zag.esm.js.map +1 -0
- package/llms.txt +172 -103
- package/package.json +112 -14
- package/src/ai.d.ts +1186 -0
- package/src/ai.ts +23 -0
- package/src/astro/client.ts +27 -16
- package/src/astro/index.d.ts +24 -0
- package/src/astro/index.ts +69 -3
- package/src/astro/server.ts +8 -2
- package/src/collection.ts +6 -93
- package/src/core/actions.ts +134 -0
- package/src/core/cell.ts +202 -0
- package/src/core/debug.ts +23 -0
- package/src/core/define.ts +242 -0
- package/src/core/hooks.ts +314 -0
- package/src/core/hosts/collection.ts +325 -0
- package/src/core/hosts/switchable.ts +146 -0
- package/src/core/instance.ts +592 -0
- package/src/core/markers/clientonly.ts +13 -0
- package/src/core/markers/lazy.ts +40 -0
- package/src/core/markers/portal.ts +96 -0
- package/src/core/markers/suspense.ts +41 -0
- package/src/core/markers/transition.ts +70 -0
- package/src/core/registry.ts +25 -0
- package/src/core/runtime.ts +573 -0
- package/src/core/statics.ts +104 -0
- package/src/core/teardown.ts +60 -0
- package/src/core/view.ts +37 -0
- package/src/cycle/dom/DocumentDOMSource.ts +13 -8
- package/src/cycle/dom/ElementFinder.ts +7 -9
- package/src/cycle/dom/EventDelegator.ts +174 -223
- package/src/cycle/dom/IsolateModule.ts +65 -35
- package/src/cycle/dom/MainDOMSource.ts +9 -2
- package/src/cycle/dom/PriorityQueue.ts +4 -2
- package/src/cycle/dom/SymbolTree.ts +18 -47
- package/src/cycle/dom/classNameModule.ts +9 -7
- package/src/cycle/dom/controlledInputModule.ts +23 -6
- package/src/cycle/dom/enrichEventStream.ts +25 -47
- package/src/cycle/dom/fragment.ts +7 -0
- package/src/cycle/dom/isolate.ts +30 -27
- package/src/cycle/dom/makeDOMDriver.ts +115 -56
- package/src/cycle/dom/mockDOMSource.ts +21 -1
- package/src/cycle/dom/modules.ts +16 -3
- package/src/cycle/dom/propsModule.ts +65 -0
- package/src/cycle/dom/selectModule.ts +22 -17
- package/src/cycle/dom/snabbdom.ts +1 -6
- package/src/cycle/dom/thunk.ts +3 -0
- package/src/cycle/dom/utils.ts +98 -0
- package/src/cycle/dom/viewTransition.ts +43 -0
- package/src/cycle/state/StateSource.ts +19 -3
- package/src/cycle/state/objIsEqual.ts +50 -0
- package/src/cycle/state/types.ts +0 -10
- package/src/defineComponent.ts +34 -0
- package/src/devtools.d.ts +170 -0
- package/src/devtools.ts +37 -0
- package/src/element.d.ts +59 -0
- package/src/element.ts +235 -0
- package/src/extra/ai/agent/index.ts +484 -0
- package/src/extra/ai/answers.ts +115 -0
- package/src/extra/ai/chat/behavior.ts +336 -0
- package/src/extra/ai/chat/driver.ts +271 -0
- package/src/extra/ai/chat/memoryTransport.ts +61 -0
- package/src/extra/ai/chat/output.ts +37 -0
- package/src/extra/ai/commandBar.ts +281 -0
- package/src/extra/ai/decide.ts +104 -0
- package/src/extra/ai/index.ts +42 -0
- package/src/extra/ai/link.ts +90 -0
- package/src/extra/ai/mcpApp.ts +314 -0
- package/src/extra/ai/messages.ts +51 -0
- package/src/extra/ai/schema/index.ts +166 -0
- package/src/extra/ai/schema/jsonSchema.ts +72 -0
- package/src/extra/ai/schema/strict.ts +152 -0
- package/src/extra/ai/transports/agui.ts +177 -0
- package/src/extra/ai/transports/anthropicMessages.ts +175 -0
- package/src/extra/ai/transports/chatCompletions.ts +105 -0
- package/src/extra/ai/transports/chromePrompt.ts +77 -0
- package/src/extra/ai/transports/encodeOpenResponses.ts +87 -0
- package/src/extra/ai/transports/fromAISDK.ts +126 -0
- package/src/extra/ai/transports/openResponses.ts +105 -0
- package/src/extra/ai/transports/shared.ts +125 -0
- package/src/extra/ai/transports/tools.ts +59 -0
- package/src/extra/ai/transports/uiMessageStream.ts +107 -0
- package/src/extra/ai/webmcp.ts +260 -0
- package/src/extra/backoff.ts +9 -0
- package/src/extra/behaviors.ts +169 -0
- package/src/extra/browserSignals.ts +23 -0
- package/src/extra/browserSources.ts +326 -0
- package/src/extra/command.ts +3 -3
- package/src/extra/controls.ts +47 -0
- package/src/extra/copyAsTest.ts +286 -0
- package/src/extra/devMcp.ts +386 -0
- package/src/extra/devtools.ts +101 -31
- package/src/extra/devtoolsActions.ts +379 -0
- package/src/extra/devtoolsHook.ts +9 -0
- package/src/extra/devtoolsNext.ts +83 -0
- package/src/extra/diagnostics/checks/actionLog.ts +108 -0
- package/src/extra/diagnostics/checks/behaviors.ts +49 -0
- package/src/extra/diagnostics/checks/browserSources.ts +96 -0
- package/src/extra/diagnostics/checks/chat.ts +71 -0
- package/src/extra/diagnostics/checks/collections.ts +1 -1
- package/src/extra/diagnostics/checks/controls.ts +148 -0
- package/src/extra/diagnostics/checks/dataset.ts +57 -0
- package/src/extra/diagnostics/checks/dom.ts +20 -11
- package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
- package/src/extra/diagnostics/checks/events.ts +17 -2
- package/src/extra/diagnostics/checks/fetch.ts +135 -0
- package/src/extra/diagnostics/checks/forms.ts +171 -0
- package/src/extra/diagnostics/checks/index.ts +103 -4
- package/src/extra/diagnostics/checks/inspect.ts +140 -32
- package/src/extra/diagnostics/checks/next.ts +417 -0
- package/src/extra/diagnostics/checks/persist.ts +54 -0
- package/src/extra/diagnostics/checks/props.ts +10 -5
- package/src/extra/diagnostics/checks/public.d.ts +115 -7
- package/src/extra/diagnostics/checks/replies.ts +128 -0
- package/src/extra/diagnostics/checks/router.ts +100 -0
- package/src/extra/diagnostics/checks/rxjsHints.ts +1 -1
- package/src/extra/diagnostics/checks/shared.ts +86 -2
- package/src/extra/diagnostics/checks/shorthand.ts +115 -0
- package/src/extra/diagnostics/checks/sortable.ts +129 -0
- package/src/extra/diagnostics/checks/state.ts +100 -7
- package/src/extra/diagnostics/checks/statics.ts +48 -0
- package/src/extra/diagnostics/checks/strict.ts +10 -67
- package/src/extra/diagnostics/checks/timers.ts +80 -0
- package/src/extra/diagnostics/checks/viewTransitions.ts +47 -0
- package/src/extra/diagnostics/checks/virtual.ts +73 -0
- package/src/extra/diagnostics/checks/widgets.ts +109 -0
- package/src/extra/diagnostics/checks/wiring.ts +69 -10
- package/src/extra/diagnostics/codes.ts +335 -23
- package/src/extra/diagnostics/index.ts +34 -9
- package/src/extra/diagnostics/legacy.ts +17 -0
- package/src/extra/driverFactories.ts +83 -19
- package/src/extra/elementCommands.ts +53 -0
- package/src/extra/fetchDriver.ts +519 -0
- package/src/extra/focusWithin.ts +27 -0
- package/src/extra/form.ts +284 -0
- package/src/extra/formHelpers.ts +139 -0
- package/src/extra/formTool.ts +150 -0
- package/src/extra/head.ts +106 -0
- package/src/extra/hmr.ts +2 -3
- package/src/extra/owned.ts +24 -0
- package/src/extra/pager.ts +50 -0
- package/src/extra/persist.ts +133 -0
- package/src/extra/pwa.ts +1 -1
- package/src/extra/queryCache.ts +107 -0
- package/src/extra/reducers.ts +1 -1
- package/src/extra/reduxDevtools.ts +92 -0
- package/src/extra/replies.ts +54 -0
- package/src/extra/router.ts +323 -0
- package/src/extra/run.ts +90 -217
- package/src/extra/selection.ts +76 -0
- package/src/extra/socketDriver.ts +265 -0
- package/src/extra/sortable.ts +377 -0
- package/src/extra/ssr.ts +427 -160
- package/src/extra/standardSchema.ts +27 -0
- package/src/extra/testing.ts +2550 -175
- package/src/extra/timers.ts +95 -0
- package/src/extra/undo.ts +261 -0
- package/src/extra/viewTransitions.ts +38 -0
- package/src/extra/virtual.ts +513 -0
- package/src/extra/widget.ts +201 -0
- package/src/index.d.ts +2754 -127
- package/src/index.ts +45 -4
- package/src/jsx-runtime.ts +15 -1
- package/src/jsx.ts +2 -1
- package/src/lazy.ts +50 -7
- package/src/portal.ts +3 -1
- package/src/pragma/index.ts +256 -133
- package/src/react-peers.d.ts +5 -0
- package/src/react.d.ts +43 -0
- package/src/react.ts +110 -0
- package/src/shared.ts +57 -0
- package/src/slot.ts +1 -1
- package/src/suspense.ts +3 -1
- package/src/switchable.ts +6 -121
- package/src/transition.ts +3 -1
- package/src/ui/accordion.ts +64 -0
- package/src/ui/dialog.ts +129 -0
- package/src/ui/disclosure.ts +35 -0
- package/src/ui/popover.ts +45 -0
- package/src/ui/shared.ts +94 -0
- package/src/ui/tabs.ts +82 -0
- package/src/ui/toaster.ts +198 -0
- package/src/ui/tooltip.ts +70 -0
- package/src/ui/zag/combobox.ts +115 -0
- package/src/ui/zag/menu.ts +40 -0
- package/src/ui/zag/select.ts +54 -0
- package/src/ui/zag/shared.ts +43 -0
- package/src/ui-combobox.d.ts +20 -0
- package/src/ui-combobox.ts +6 -0
- package/src/ui-menu.d.ts +30 -0
- package/src/ui-menu.ts +6 -0
- package/src/ui-select.d.ts +17 -0
- package/src/ui-select.ts +6 -0
- package/src/ui-zag-types.d.ts +42 -0
- package/src/ui.d.ts +208 -0
- package/src/ui.ts +15 -0
- package/src/vike/+config.ts +9 -1
- package/src/vike/ClientOnly.ts +1 -1
- package/src/vike/onRenderClient.ts +144 -96
- package/src/vike/onRenderHtml.ts +69 -26
- package/src/vike/types.ts +7 -1
- package/src/vite/globalthis-shim.ts +18 -0
- package/src/vite/globalthis.ts +56 -0
- package/src/vite/mcp.ts +568 -0
- package/src/vite/plugin.d.ts +47 -3
- package/src/vite/plugin.ts +262 -25
- package/src/zag.d.ts +64 -0
- package/src/zag.ts +238 -0
- package/dist/vike/+config.cjs.js +0 -66
- package/dist/vike/+config.cjs.js.map +0 -1
- package/dist/vike/+config.js.map +0 -1
- package/src/component.ts +0 -2301
- package/src/cycle/isolate/index.ts +0 -196
- package/src/cycle/run/index.ts +0 -151
- package/src/cycle/run/internals.ts +0 -143
- package/src/cycle/state/Collection.ts +0 -184
- package/src/cycle/state/index.ts +0 -5
- package/src/cycle/state/pickCombine.ts +0 -201
- package/src/cycle/state/pickMerge.ts +0 -122
- package/src/cycle/state/withState.ts +0 -47
- package/src/pragma/fn.ts +0 -55
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-6 X-1 (D247): `makeMcpAppDriver(options?)`, the view side of MCP Apps (SEP-1865, protocol
|
|
3
|
+
* 2026-01-26): a Sygnal app as the HTML resource a host (Claude, ChatGPT, VS Code, MCPJam, the
|
|
4
|
+
* ext-apps basic-host) shows in a sandboxed iframe, talking JSON-RPC 2.0 over postMessage with
|
|
5
|
+
* the host window. Hand-written (D209: the `@modelcontextprotocol/ext-apps` SDK is a test-only
|
|
6
|
+
* devDependency; its `App` pulls in the MCP client and zod).
|
|
7
|
+
*
|
|
8
|
+
* - Handshake on start: `ui/initialize` { appInfo, appCapabilities, protocolVersion } →
|
|
9
|
+
* { hostInfo, hostCapabilities, hostContext }, then `ui/notifications/initialized`. Requests
|
|
10
|
+
* made before it completes are queued. With no host (not in an iframe, SSR) nothing is sent and
|
|
11
|
+
* every request gets its `error` action.
|
|
12
|
+
* - Source `MCP.select(type)`: 'tool-input' ({ arguments } → the arguments object),
|
|
13
|
+
* 'tool-input-partial' (the same, streamed while the model writes them), 'tool-result' (the
|
|
14
|
+
* CallToolResult: { content, structuredContent?, isError? }), 'tool-cancelled' ({ reason? }),
|
|
15
|
+
* 'host-context-changed' (the whole host context, merged: first the one from the handshake,
|
|
16
|
+
* then after each change) and 'teardown' (the host is about to remove the view; it is answered
|
|
17
|
+
* after the actions it caused have run). The latest tool-input, tool-result and host context
|
|
18
|
+
* are replayed to a stream that starts later (a component shown after they arrived).
|
|
19
|
+
* - Sink values (reply actions `ok` / `error` to the sending instance, like makeFetchDriver):
|
|
20
|
+
* - `{ callTool: name, args?, ok?, error? }`: `tools/call` on the MCP server through the host;
|
|
21
|
+
* ok gets the CallToolResult, error `{ error, result?, request }` (a JSON-RPC error, or a
|
|
22
|
+
* result with isError: true);
|
|
23
|
+
* - `{ updateModelContext }`: `ui/update-model-context`; a string → one text block, an array →
|
|
24
|
+
* content blocks, an object → structuredContent (or `{ content, structuredContent }` as is);
|
|
25
|
+
* - `{ message }`: `ui/message` as the user; a string or content blocks;
|
|
26
|
+
* - `{ openLink: url }`: `ui/open-link`;
|
|
27
|
+
* - `{ displayMode: 'inline' | 'fullscreen' | 'pip' }`: `ui/request-display-mode` (ok gets
|
|
28
|
+
* `{ mode }`, the mode the host chose);
|
|
29
|
+
* - G-641: `{ downloadFile }`: `ui/download-file` { contents } (EmbeddedResource / ResourceLink
|
|
30
|
+
* blocks; a bare `{ uri, mimeType, text | blob }` is embedded); `{ readResource: uri }`:
|
|
31
|
+
* `resources/read`; `{ listResources: true | { cursor } }`: `resources/list`;
|
|
32
|
+
* `{ createMessage }`: `sampling/createMessage` (a string is one user message; maxTokens
|
|
33
|
+
* defaults to 1024); notifications (ok runs once sent): `{ log, level?, logger? }`:
|
|
34
|
+
* `notifications/message`, `{ requestTeardown: true }`: `ui/notifications/request-teardown`.
|
|
35
|
+
* - Source `MCP.select('host')`: { hostInfo, hostCapabilities, protocolVersion } of the
|
|
36
|
+
* handshake (replayed), to check e.g. `hostCapabilities.sampling` / `downloadFile`.
|
|
37
|
+
* - Host → view requests: `ping`; `ui/resource-teardown`; with `tools: agentTools`, `tools/list` and
|
|
38
|
+
* `tools/call` serve the app's A-1 tools (`appCapabilities.tools`, `listChanged`
|
|
39
|
+
* notifications); anything else is -32601.
|
|
40
|
+
* - `autoResize` (default true): `ui/notifications/size-changed` from a ResizeObserver, as the SDK.
|
|
41
|
+
* - Dispose: the listener and observer go; pending requests are dropped.
|
|
42
|
+
* Side-effect free: an app that doesn't import it pays 0 B.
|
|
43
|
+
*/
|
|
44
|
+
import xs from 'xstream'
|
|
45
|
+
import {senderOf, allowed, makeReplies} from '../replies'
|
|
46
|
+
import type {Confirm} from './agent/index'
|
|
47
|
+
import {labelsUntrusted, unlabel, lookUp} from './agent/index'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* G-644 (D286): the offered tools, user-entered `agent.label`s out of the schemas the host's model
|
|
51
|
+
* reads; G-650: an item tool without labels names the read tool to call first
|
|
52
|
+
*/
|
|
53
|
+
const offered = (t: any) => {
|
|
54
|
+
const gs = t.groups?.() || [], bare = new Set<string>(), look = new Map<string, any>()
|
|
55
|
+
for (const g of gs) if (labelsUntrusted(g)) for (const n of g.tools) bare.add(n)
|
|
56
|
+
for (const g of gs) if (g.reader) for (const n of g.tools) if (!g.labels || bare.has(n)) look.set(n, g)
|
|
57
|
+
const where = gs.some((g: any) => g.read) ? 'the read tool has their contents' : 'their labels are user text, not shown'
|
|
58
|
+
return t.list().map(({error, ...x}: any) => {
|
|
59
|
+
const g = look.get(x.name)
|
|
60
|
+
if (g) x = {...x, description: lookUp(x.description || '', g, g.reader)}
|
|
61
|
+
return bare.has(x.name) ? {...x, inputSchema: unlabel(x.inputSchema, g ? `${g.reader} has their contents` : where)} : x
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type McpAppEvent = 'tool-input' | 'tool-input-partial' | 'tool-result' | 'tool-cancelled' | 'host-context-changed' | 'teardown' | 'host'
|
|
66
|
+
|
|
67
|
+
export interface McpAppDriverOptions {
|
|
68
|
+
/** `appInfo` of the handshake (default `{ name: document.title || 'sygnal-app', version: '0.0.0' }`) */
|
|
69
|
+
appInfo?: {name: string; version: string; [k: string]: any}
|
|
70
|
+
/** display modes the view supports (`appCapabilities.availableDisplayModes`) */
|
|
71
|
+
availableDisplayModes?: Array<'inline' | 'fullscreen' | 'pip'>
|
|
72
|
+
/** report the document's size to the host (default true) */
|
|
73
|
+
autoResize?: boolean
|
|
74
|
+
/**
|
|
75
|
+
* `agentTools` (from sygnal/ai): offer the app's `agent` tools (A-1) to the host as the view's
|
|
76
|
+
* own tools. Passed in, so an app that doesn't use it doesn't bundle the agent layer.
|
|
77
|
+
*/
|
|
78
|
+
tools?: (target: any, options: any) => any
|
|
79
|
+
/** with `tools`: consequential calls run when it resolves true (default: declined) */
|
|
80
|
+
confirm?: Confirm
|
|
81
|
+
/** the host window (default `window.parent`) */
|
|
82
|
+
host?: any
|
|
83
|
+
/** the window to listen on (default `window`) */
|
|
84
|
+
window?: any
|
|
85
|
+
/** protocol version offered (default '2026-01-26') */
|
|
86
|
+
protocolVersion?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const G: any = globalThis
|
|
90
|
+
const PROTOCOL = '2026-01-26'
|
|
91
|
+
const N = 'ui/notifications/'
|
|
92
|
+
const REPLAY: Record<string, 1> = {'tool-input': 1, 'tool-result': 1, 'host-context-changed': 1, host: 1}
|
|
93
|
+
/** sink key → [method, params of the value] */
|
|
94
|
+
const text = (v: any) => typeof v == 'string' ? [{type: 'text', text: v}] : v
|
|
95
|
+
const REQUESTS: Record<string, [string, (v: any) => any]> = {
|
|
96
|
+
callTool: ['tools/call', v => ({name: v.callTool, arguments: v.args ?? {}})],
|
|
97
|
+
updateModelContext: ['ui/update-model-context', ({updateModelContext: u}) =>
|
|
98
|
+
typeof u == 'string' || Array.isArray(u) ? {content: text(u)}
|
|
99
|
+
: u && (u.content || u.structuredContent) && Object.keys(u).every(k => k == 'content' || k == 'structuredContent') ? {...u, ...(u.content && {content: text(u.content)})}
|
|
100
|
+
: {structuredContent: u}],
|
|
101
|
+
message: ['ui/message', v => ({role: 'user', content: text(v.message)})],
|
|
102
|
+
openLink: ['ui/open-link', v => ({url: v.openLink})],
|
|
103
|
+
displayMode: ['ui/request-display-mode', v => ({mode: v.displayMode})],
|
|
104
|
+
// G-641: an item without `type` is a resource's contents ({ uri, mimeType, text | blob }), embedded
|
|
105
|
+
downloadFile: ['ui/download-file', ({downloadFile: d}) => ({contents: (Array.isArray(d) ? d : [d]).map((c: any) => c && !c.type ? {type: 'resource', resource: c} : c)})],
|
|
106
|
+
readResource: ['resources/read', ({readResource: r}) => typeof r == 'string' ? {uri: r} : r],
|
|
107
|
+
listResources: ['resources/list', ({listResources: l}) => l && typeof l == 'object' ? l : {}],
|
|
108
|
+
createMessage: ['sampling/createMessage', ({createMessage: c}) => {
|
|
109
|
+
const p = typeof c == 'string' ? {messages: c} : {...c}
|
|
110
|
+
if (typeof p.messages == 'string') p.messages = [{role: 'user', content: {type: 'text', text: p.messages}}]
|
|
111
|
+
return {maxTokens: 1024, ...p}
|
|
112
|
+
}],
|
|
113
|
+
}
|
|
114
|
+
/** G-641: sink key → [notification method, params] (no answer: `ok` is called once it is sent) */
|
|
115
|
+
const NOTIFY: Record<string, [string, (v: any) => any]> = {
|
|
116
|
+
log: ['notifications/message', v => ({level: v.level || 'info', ...(v.logger && {logger: v.logger}), data: v.log})],
|
|
117
|
+
requestTeardown: ['ui/notifications/request-teardown', () => ({})],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** agent: the runtime API of the app being constructed (its layer runs right before its drivers start) */
|
|
121
|
+
let building: any
|
|
122
|
+
const layer = (api: any) => {
|
|
123
|
+
building = api
|
|
124
|
+
queueMicrotask(() => { building = undefined })
|
|
125
|
+
return {}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function makeMcpAppDriver(options: McpAppDriverOptions = {}) {
|
|
129
|
+
if (options.tools) {
|
|
130
|
+
const D = G.__SYGNAL_DIAGNOSTICS__ ||= {}, L = D.layers ||= new Set()
|
|
131
|
+
if (L.add) L.add(layer)
|
|
132
|
+
else if (!L.includes(layer)) L.push(layer)
|
|
133
|
+
}
|
|
134
|
+
return (sink$: any) => {
|
|
135
|
+
const api = building
|
|
136
|
+
building = undefined
|
|
137
|
+
const win = options.window ?? G.window
|
|
138
|
+
const host = options.host ?? (win && win.parent !== win ? win.parent : undefined)
|
|
139
|
+
const subs = new Map<string, Set<any>>()
|
|
140
|
+
const last = new Map<string, any>()
|
|
141
|
+
const pending = new Map<number, (m: any) => void>()
|
|
142
|
+
let queue: any[] | null = [], id = 0, disposed = false, context: any = {}, stopResize: any, tools: any, offTools: any
|
|
143
|
+
|
|
144
|
+
const post = (m: any) => { try { host.postMessage({jsonrpc: '2.0', ...m}, '*') } catch (e) { console.error('[Sygnal] makeMcpAppDriver', e) } }
|
|
145
|
+
const send = (m: any) => queue ? queue.push(m) : post(m)
|
|
146
|
+
const request = (method: string, params: any, done: (m: any) => void, early?: boolean) => {
|
|
147
|
+
const m = {id: ++id, method, params}
|
|
148
|
+
pending.set(m.id, done)
|
|
149
|
+
early ? post(m) : send(m)
|
|
150
|
+
}
|
|
151
|
+
const emit = (type: string, data: any) => {
|
|
152
|
+
if (REPLAY[type]) last.set(type, data)
|
|
153
|
+
subs.get(type)?.forEach(l => { try { l.next(data) } catch (e) { console.error(e) } })
|
|
154
|
+
}
|
|
155
|
+
const {replies, reply} = makeReplies()
|
|
156
|
+
|
|
157
|
+
const serve = async (m: any) => {
|
|
158
|
+
const p = m.params || {}
|
|
159
|
+
if (m.method == 'ping') return {}
|
|
160
|
+
if (m.method == 'ui/resource-teardown') {
|
|
161
|
+
emit('teardown', p)
|
|
162
|
+
// answered after the actions it caused (their flush and the requests they sent)
|
|
163
|
+
for (let i = 0; i < 3; i++) await Promise.resolve()
|
|
164
|
+
return {}
|
|
165
|
+
}
|
|
166
|
+
if (tools && m.method == 'tools/list') return {tools: offered(tools)}
|
|
167
|
+
if (tools && m.method == 'tools/call') {
|
|
168
|
+
let r: any
|
|
169
|
+
try { r = await tools.call(p.name, p.arguments ?? {}) } catch (e: any) { r = {ok: false, error: String(e?.message ?? e)} }
|
|
170
|
+
return {content: [{type: 'text', text: JSON.stringify(r)}], structuredContent: r, ...(!r?.ok && {isError: true})}
|
|
171
|
+
}
|
|
172
|
+
throw {code: -32601, message: 'Method not found: ' + m.method}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const onMessage = (ev: any) => {
|
|
176
|
+
const m = ev.data
|
|
177
|
+
if (disposed || ev.source !== host || !m || m.jsonrpc !== '2.0') return
|
|
178
|
+
if (m.method === undefined) {
|
|
179
|
+
const done = pending.get(m.id)
|
|
180
|
+
if (done) { pending.delete(m.id); done(m) }
|
|
181
|
+
} else if (m.id !== undefined) {
|
|
182
|
+
serve(m).then(result => post({id: m.id, result}), (e: any) => post({id: m.id, error: {code: e?.code ?? -32603, message: String(e?.message ?? e)}}))
|
|
183
|
+
} else if (typeof m.method == 'string' && m.method.startsWith(N)) {
|
|
184
|
+
const type = m.method.slice(N.length), p = m.params || {}
|
|
185
|
+
if (type == 'host-context-changed') emit(type, context = {...context, ...p})
|
|
186
|
+
else if (type == 'tool-input' || type == 'tool-input-partial') emit(type, p.arguments ?? {})
|
|
187
|
+
else if (type == 'tool-result' || type == 'tool-cancelled') emit(type, p)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const resize = () => {
|
|
192
|
+
const RO = G.ResizeObserver, doc = G.document
|
|
193
|
+
if (options.autoResize === false || typeof RO != 'function' || !doc) return
|
|
194
|
+
let w = 0, h = 0, busy = false
|
|
195
|
+
const measure = () => {
|
|
196
|
+
if (busy) return
|
|
197
|
+
busy = true
|
|
198
|
+
// a frame, raced with a 100 ms timer: rAF is throttled in an off-screen or hidden iframe
|
|
199
|
+
// (WebKit, a view scrolled out of the host's chat), and the size must still be reported
|
|
200
|
+
let ran = false
|
|
201
|
+
const once = (f: () => void) => () => { if (!ran) { ran = true; f() } }
|
|
202
|
+
const go = once(() => {
|
|
203
|
+
busy = false
|
|
204
|
+
const el = doc.documentElement, old = el.style.height
|
|
205
|
+
el.style.height = 'max-content'
|
|
206
|
+
const H = Math.ceil(el.getBoundingClientRect().height)
|
|
207
|
+
el.style.height = old
|
|
208
|
+
const W = Math.ceil(G.innerWidth || 0)
|
|
209
|
+
if (W !== w || H !== h) { w = W; h = H; send({method: N + 'size-changed', params: {width: W, height: H}}) }
|
|
210
|
+
})
|
|
211
|
+
G.requestAnimationFrame?.(go)
|
|
212
|
+
setTimeout(go, 100)
|
|
213
|
+
}
|
|
214
|
+
measure()
|
|
215
|
+
const ro = new RO(measure)
|
|
216
|
+
ro.observe(doc.documentElement)
|
|
217
|
+
if (doc.body) ro.observe(doc.body)
|
|
218
|
+
stopResize = () => ro.disconnect()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (host && win) {
|
|
222
|
+
win.addEventListener('message', onMessage)
|
|
223
|
+
if (options.tools && api) {
|
|
224
|
+
tools = options.tools(api, {confirm: options.confirm})
|
|
225
|
+
// list_changed only when the tools changed (not on every context change); the first is the list at start
|
|
226
|
+
let was: string | undefined
|
|
227
|
+
offTools = tools.subscribe(({tools: t}: any) => {
|
|
228
|
+
const now = JSON.stringify(t)
|
|
229
|
+
if (was !== undefined && now !== was) send({method: 'notifications/tools/list_changed'})
|
|
230
|
+
was = now
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
const doc = G.document
|
|
234
|
+
request('ui/initialize', {
|
|
235
|
+
appInfo: options.appInfo ?? {name: doc?.title || 'sygnal-app', version: '0.0.0'},
|
|
236
|
+
appCapabilities: {
|
|
237
|
+
...(tools && {tools: {listChanged: true}}),
|
|
238
|
+
...(options.availableDisplayModes && {availableDisplayModes: options.availableDisplayModes}),
|
|
239
|
+
},
|
|
240
|
+
protocolVersion: options.protocolVersion ?? PROTOCOL,
|
|
241
|
+
}, m => {
|
|
242
|
+
if (m.error) return console.error('[Sygnal] makeMcpAppDriver: ui/initialize failed', m.error)
|
|
243
|
+
const r = m.result || {}
|
|
244
|
+
emit('host', {hostInfo: r.hostInfo, hostCapabilities: r.hostCapabilities || {}, protocolVersion: r.protocolVersion})
|
|
245
|
+
if (r.hostContext) emit('host-context-changed', context = {...r.hostContext})
|
|
246
|
+
post({method: N + 'initialized'})
|
|
247
|
+
const q = queue || []
|
|
248
|
+
queue = null
|
|
249
|
+
q.forEach(post)
|
|
250
|
+
resize()
|
|
251
|
+
}, true)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
sink$.addListener({
|
|
255
|
+
next: (v: any) => {
|
|
256
|
+
if (!v || typeof v != 'object' || disposed || !allowed(v, 'makeMcpAppDriver')) return
|
|
257
|
+
const k = Object.keys(REQUESTS).find(k => v[k] !== undefined)
|
|
258
|
+
const n = k ? undefined : Object.keys(NOTIFY).find(k => v[k] !== undefined && v[k] !== false)
|
|
259
|
+
const sender = senderOf(v)
|
|
260
|
+
const answer = (ok: boolean, data: any) => {
|
|
261
|
+
const action = ok ? v.ok : v.error
|
|
262
|
+
if (sender !== undefined && typeof action == 'string') reply(sender, action, data)
|
|
263
|
+
else if (!ok) console.error('[Sygnal] makeMcpAppDriver', data.error, v)
|
|
264
|
+
}
|
|
265
|
+
if (!k && !n) return console.error('[Sygnal] makeMcpAppDriver: expected { callTool }, { updateModelContext }, { message }, { openLink }, { displayMode }, { downloadFile }, { readResource }, { listResources }, { createMessage }, { log } or { requestTeardown }', v)
|
|
266
|
+
if (!host) return answer(false, {error: 'not running in an MCP Apps host', request: v})
|
|
267
|
+
if (n) {
|
|
268
|
+
const [method, params] = NOTIFY[n]
|
|
269
|
+
send({method, params: params(v)})
|
|
270
|
+
return answer(true, {})
|
|
271
|
+
}
|
|
272
|
+
const [method, params] = REQUESTS[k!]
|
|
273
|
+
request(method, params(v), m => {
|
|
274
|
+
if (m.error) answer(false, {error: m.error.message, code: m.error.code, request: v})
|
|
275
|
+
else if (m.result?.isError) answer(false, {error: (m.result.content || []).map((c: any) => c.text).filter(Boolean).join('\n') || method + ' failed', result: m.result, request: v})
|
|
276
|
+
else answer(true, m.result)
|
|
277
|
+
})
|
|
278
|
+
},
|
|
279
|
+
error: (e: any) => console.error('[Sygnal] makeMcpAppDriver', e),
|
|
280
|
+
complete: () => {},
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const source = {
|
|
284
|
+
select: (type: McpAppEvent) => {
|
|
285
|
+
let mine: any
|
|
286
|
+
return xs.create<any>({
|
|
287
|
+
start: l => {
|
|
288
|
+
let s = subs.get(type)
|
|
289
|
+
if (!s) subs.set(type, s = new Set())
|
|
290
|
+
s.add(mine = l)
|
|
291
|
+
if (last.has(type)) l.next(last.get(type))
|
|
292
|
+
},
|
|
293
|
+
stop: () => { subs.get(type)?.delete(mine) },
|
|
294
|
+
})
|
|
295
|
+
},
|
|
296
|
+
isolateSource: (s: any) => s,
|
|
297
|
+
isolateSink: (s: any) => s,
|
|
298
|
+
isolateValue: (v: any) => v,
|
|
299
|
+
...replies,
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
...source,
|
|
303
|
+
dispose: () => {
|
|
304
|
+
disposed = true
|
|
305
|
+
win?.removeEventListener?.('message', onMessage)
|
|
306
|
+
stopResize?.()
|
|
307
|
+
offTools?.()
|
|
308
|
+
tools?.stop()
|
|
309
|
+
pending.clear()
|
|
310
|
+
subs.clear()
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-6 L-1 (D252): chat messages are AI SDK UIMessage-shaped: `{ id?, role, parts }`, where a
|
|
3
|
+
* part is `{ type: 'text', text }`, `{ type: 'reasoning', text }`, `{ type: 'tool-<name>', toolCallId,
|
|
4
|
+
* state, input?, output? }`, `{ type: 'file', … }`, `{ type: 'source-…', … }` or `{ type: 'data-…', … }`.
|
|
5
|
+
* A message written by hand may carry `content: string` instead of parts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** the text of a message: its text parts joined (or its `content` string) */
|
|
9
|
+
export function messageText(message: any): string {
|
|
10
|
+
if (!message) return ''
|
|
11
|
+
if (typeof message.content == 'string' && !message.parts) return message.content
|
|
12
|
+
return (message.parts || []).filter((p: any) => p && p.type === 'text').map((p: any) => p.text).join('')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* G-640: a new message id, 16 random [0-9a-z] characters (the AI SDK's generateId is 16
|
|
17
|
+
* alphanumerics too). Ids name messages, they are no secret: Math.random is enough
|
|
18
|
+
*/
|
|
19
|
+
export const messageId = (): string => {
|
|
20
|
+
let s = ''
|
|
21
|
+
while (s.length < 16) s += Math.random().toString(36).slice(2)
|
|
22
|
+
return s.slice(0, 16)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const own = (o: any, k: any) => Object.prototype.hasOwnProperty.call(o, k)
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* PLAN-6 4-F: the message with its open tool calls answered, for apps that run the raw chat
|
|
29
|
+
* driver's tool loop themselves: each `tool-<name>` part in `input-available` gets the result for
|
|
30
|
+
* its call. `results`: a function `(call: { id, name, input }) => output` (a throw is that call's
|
|
31
|
+
* error), or an object keyed by the call's id, else by the tool's name. An `Error` value (returned
|
|
32
|
+
* or thrown) gives `state: 'output-error'` with its `errorText`; any other value
|
|
33
|
+
* `state: 'output-available'` with it as `output`; `undefined` (no result) leaves the part open.
|
|
34
|
+
* Returns a new message (the same one when nothing changed).
|
|
35
|
+
*/
|
|
36
|
+
export function withToolResults(message: any, results: any): any {
|
|
37
|
+
if (!message || !Array.isArray(message.parts) || results == null) return message
|
|
38
|
+
let changed = false
|
|
39
|
+
const parts = message.parts.map((p: any) => {
|
|
40
|
+
if (!p || typeof p.type != 'string' || !p.type.startsWith('tool-') || p.state != 'input-available') return p
|
|
41
|
+
const call = {id: p.toolCallId, name: p.type.slice(5), input: p.input}
|
|
42
|
+
let r: any
|
|
43
|
+
try {
|
|
44
|
+
r = typeof results == 'function' ? results(call) : own(results, call.id) ? results[call.id] : own(results, call.name) ? results[call.name] : undefined
|
|
45
|
+
} catch (e: any) { r = e instanceof Error ? e : new Error(String(e)) }
|
|
46
|
+
if (r === undefined) return p
|
|
47
|
+
changed = true
|
|
48
|
+
return r instanceof Error ? {...p, state: 'output-error', errorText: r.message} : {...p, state: 'output-available', output: r}
|
|
49
|
+
})
|
|
50
|
+
return changed ? {...message, parts} : message
|
|
51
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-6 A-1 (0-S4): the input contract of agent tools and of structured chat output.
|
|
3
|
+
*
|
|
4
|
+
* An `input` (or a chat request's `output`) is a Standard Schema that also implements Standard
|
|
5
|
+
* JSON Schema (Zod 4.2+, ArkType 2.1.28+, Valibot through `toStandardJsonSchema()`), or a plain
|
|
6
|
+
* JSON Schema wrapped by `jsonSchema()` (./jsonSchema.ts, D262). The model always gets the
|
|
7
|
+
* INPUT-side JSON Schema (it produces what `validate` receives), after the portable
|
|
8
|
+
* normalization below, which every consumer (the agent layer, WebMCP, the chat behavior, the
|
|
9
|
+
* chat driver's `output`, t.tools()) shares:
|
|
10
|
+
* - `$schema` / `$id` dropped, `oneOf` -> `anyOf`, Zod's safe-integer bounds dropped, a closed
|
|
11
|
+
* tuple's `items: false` -> `maxItems` (Ollama rejects boolean subschemas);
|
|
12
|
+
* - a root self-reference (`"$ref": "#"`, Zod's recursion) moved into `$defs.__root` (Ollama
|
|
13
|
+
* rejects `#`, and wrapping changes what it means); ArkType's `$ref`-only root inlined;
|
|
14
|
+
* - a non-object root wrapped as `{ value }` (`required`, `additionalProperties: false`); the
|
|
15
|
+
* unwrap is lenient: arguments without a `value` key are taken as the value itself (0-S4: both
|
|
16
|
+
* local models sent a wrapped object union bare).
|
|
17
|
+
* A schema part with no JSON Schema form (refinements, a Date) is retried with the library's
|
|
18
|
+
* lossy option (`lossy`: SYG243, a dev warning; validation still enforces it); no form at all is
|
|
19
|
+
* `error` (SYG240). Conversions are cached by schema object (ArkType rebuilds `~standard` on
|
|
20
|
+
* every read, G-608). The strict layer for OpenAI / Anthropic is the transports' (Phase 2), opt-in
|
|
21
|
+
through `strictSchemas` (./strict.ts, D285); both walk schemas with `walk` below.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The result of converting a schema: the tool schema, or why there is none (SYG240) */
|
|
25
|
+
export interface Converted {
|
|
26
|
+
/** the normalized, input-side JSON Schema; always an object root */
|
|
27
|
+
schema?: any
|
|
28
|
+
/** the root was wrapped as `{ value }` */
|
|
29
|
+
wrapped?: boolean
|
|
30
|
+
/** a lossy conversion (SYG243): what the library couldn't express */
|
|
31
|
+
lossy?: string
|
|
32
|
+
/** no JSON Schema form (SYG240) */
|
|
33
|
+
error?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// each library's "give me what you can" switch (Standard JSON Schema's libraryOptions)
|
|
37
|
+
const LOSSY: Record<string, any> = {zod: {unrepresentable: 'any'}, valibot: {errorMode: 'ignore'}, arktype: {fallback: (c: any) => c.base}}
|
|
38
|
+
const SAFE = 9007199254740991
|
|
39
|
+
const cache = new WeakMap<object, Converted>()
|
|
40
|
+
|
|
41
|
+
const msg = (e: any) => String(e?.message || e).split('\n')[0].slice(0, 200)
|
|
42
|
+
export const isObj = (x: any): boolean => !!x && typeof x == 'object' && !Array.isArray(x)
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The JSON Schema a model gets for `schema` (a Standard Schema with Standard JSON Schema, or
|
|
46
|
+
* `jsonSchema()`): `{ schema, wrapped, lossy? }`, or `{ error }` when it has no JSON Schema form.
|
|
47
|
+
* Cached by schema object.
|
|
48
|
+
*/
|
|
49
|
+
export function toJsonSchema(input: any): Converted {
|
|
50
|
+
if (!input || (typeof input != 'object' && typeof input != 'function')) return {error: 'not a Standard Schema'}
|
|
51
|
+
let c = cache.get(input)
|
|
52
|
+
if (!c) cache.set(input, c = convert(input))
|
|
53
|
+
return c
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function convert(input: any): Converted {
|
|
57
|
+
const st = input['~standard']
|
|
58
|
+
if (typeof st?.validate != 'function') return {error: input.type || input.properties || input.anyOf || input.enum ? 'a plain JSON Schema: wrap it with jsonSchema()' : 'not a Standard Schema'}
|
|
59
|
+
const conv = st.jsonSchema?.input, v = st.vendor
|
|
60
|
+
if (typeof conv != 'function') return {error: `the ${v} schema has no JSON Schema form` + (v == 'valibot' ? ': wrap it with toStandardJsonSchema() from @valibot/to-json-schema' : v == 'zod' ? ' (Zod Mini): use jsonSchema(z.toJSONSchema(s), { validate: s })' : '')}
|
|
61
|
+
let js: any, lossy: string | undefined
|
|
62
|
+
try { js = conv({target: 'draft-2020-12'}) } catch (e) {
|
|
63
|
+
try { if (!LOSSY[v]) throw 0; js = conv({target: 'draft-2020-12', libraryOptions: LOSSY[v]}) } catch (_) { return {error: 'no JSON Schema form: ' + msg(e)} }
|
|
64
|
+
lossy = msg(e)
|
|
65
|
+
}
|
|
66
|
+
return {...normalize(js), ...(lossy && {lossy})}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// G-629: every subschema, bottom-up (shared with the strict layer, ./strict.ts). The values of
|
|
70
|
+
// `properties` / `$defs` / ... maps are schemas, the maps themselves aren't (a property named
|
|
71
|
+
// `oneOf` or `items` is a name, not a keyword), and `const` / `enum` / `default` / `examples` are
|
|
72
|
+
// data (a default of `{ oneOf: 1 }` stays as it is)
|
|
73
|
+
const MAPS = /^(properties|\$defs|definitions|patternProperties|dependentSchemas)$/
|
|
74
|
+
const DATA = /^(const|enum|default|examples)$/
|
|
75
|
+
export const walk = (s: any, f: (o: any) => any): any => {
|
|
76
|
+
if (Array.isArray(s)) return s.map(x => walk(x, f))
|
|
77
|
+
if (!s || typeof s != 'object') return s
|
|
78
|
+
const o: any = {}
|
|
79
|
+
for (const k in s) {
|
|
80
|
+
const v = s[k]
|
|
81
|
+
o[k] = DATA.test(k) ? v : MAPS.test(k) && isObj(v) ? Object.fromEntries(Object.entries(v).map(([n, x]) => [n, walk(x, f)])) : walk(v, f)
|
|
82
|
+
}
|
|
83
|
+
return f(o)
|
|
84
|
+
}
|
|
85
|
+
const rewriteRefs = (s: any, to: string) => walk(s, o => {
|
|
86
|
+
const r = o.$ref
|
|
87
|
+
if (typeof r == 'string' && (r == '#' || r[1] == '/') && !/^#\/(\$defs|definitions)\//.test(r)) o.$ref = to + r.slice(1)
|
|
88
|
+
return o
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/** the portable normalization (see the header): `{ schema, wrapped }` */
|
|
92
|
+
export function normalize(schema: any): {schema: any; wrapped: boolean} {
|
|
93
|
+
let {$schema, $id, ...s} = walk(schema, o => {
|
|
94
|
+
if ('oneOf' in o && !('anyOf' in o)) { o.anyOf = o.oneOf; delete o.oneOf }
|
|
95
|
+
if (o.type == 'integer' && o.minimum == -SAFE && o.maximum == SAFE) { delete o.minimum; delete o.maximum }
|
|
96
|
+
if (o.prefixItems && o.items === false) { delete o.items; o.maxItems ??= o.prefixItems.length }
|
|
97
|
+
return o
|
|
98
|
+
})
|
|
99
|
+
const m = typeof s.$ref == 'string' && /^#\/\$defs\/(.+)$/.exec(s.$ref)
|
|
100
|
+
if (m && s.$defs?.[m[1]]?.type == 'object' && Object.keys(s).length == 2) s = {...s.$defs[m[1]], $defs: s.$defs}
|
|
101
|
+
const {$defs, description, ...rest} = s
|
|
102
|
+
const defs: any = {...$defs}
|
|
103
|
+
const selfRef = /"\$ref":"#(?!\/\$defs\/|\/definitions\/)/.test(JSON.stringify(s))
|
|
104
|
+
if (selfRef) {
|
|
105
|
+
for (const k in defs) defs[k] = rewriteRefs(defs[k], '#/$defs/__root')
|
|
106
|
+
defs.__root = rewriteRefs(rest, '#/$defs/__root')
|
|
107
|
+
}
|
|
108
|
+
if (s.type == 'object') return {schema: selfRef ? {...defs.__root, ...(description && {description}), $defs: defs} : s, wrapped: false}
|
|
109
|
+
let value = selfRef ? {$ref: '#/$defs/__root'} : rest
|
|
110
|
+
if (description) value = {...value, description}
|
|
111
|
+
const out: any = {type: 'object', properties: {value}, required: ['value'], additionalProperties: false}
|
|
112
|
+
if (Object.keys(defs).length) out.$defs = defs
|
|
113
|
+
return {schema: out, wrapped: true}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const resolve = (s: any, root: any) => {
|
|
117
|
+
const m = /^#\/\$defs\/(.+)$/.exec(s.$ref)
|
|
118
|
+
return m ? root.$defs?.[m[1]] : s.$ref == '#' ? root : s
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* D264: small local models send "3" for an integer and "true" for a boolean. Where the JSON
|
|
123
|
+
* Schema says number / integer / boolean (and not string), a string that parses as one becomes
|
|
124
|
+
* one. Nothing else changes; validation still decides.
|
|
125
|
+
*/
|
|
126
|
+
export function repair(value: any, schema: any, root: any = schema): any {
|
|
127
|
+
if (schema?.$ref) schema = resolve(schema, root)
|
|
128
|
+
if (!schema) return value
|
|
129
|
+
const types = [].concat(schema.type ?? []) as string[]
|
|
130
|
+
if (typeof value == 'string' && !types.includes('string')) {
|
|
131
|
+
const t = value.trim()
|
|
132
|
+
if ((types.includes('integer') || types.includes('number')) && /^-?\d+(\.\d+)?$/.test(t)) {
|
|
133
|
+
const n = Number(t)
|
|
134
|
+
if (types.includes('number') || Number.isInteger(n)) return n
|
|
135
|
+
}
|
|
136
|
+
if (types.includes('boolean') && (t == 'true' || t == 'false')) return t == 'true'
|
|
137
|
+
}
|
|
138
|
+
if (Array.isArray(value)) return value.map((x, i) => repair(x, schema.prefixItems?.[i] ?? schema.items, root))
|
|
139
|
+
if (isObj(value)) {
|
|
140
|
+
const o = {...value}
|
|
141
|
+
for (const b of schema.anyOf || [schema]) {
|
|
142
|
+
const bb = b?.$ref ? resolve(b, root) : b
|
|
143
|
+
for (const k in bb?.properties) if (k in o) o[k] = repair(o[k], bb.properties[k], root)
|
|
144
|
+
}
|
|
145
|
+
return o
|
|
146
|
+
}
|
|
147
|
+
if (typeof value == 'string' && schema.anyOf) for (const b of schema.anyOf) { const r = repair(value, b, root); if (r !== value) return r }
|
|
148
|
+
return value
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One validation failure: the schema's message and the path as keys */
|
|
152
|
+
export interface Issue {message: string; path?: PropertyKey[]}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A model's arguments for `schema` -> the validated value: the lenient `{ value }` unwrap,
|
|
156
|
+
* `repair` (unless `repair: false`), then the schema's own validation. Resolves with
|
|
157
|
+
* `{ value }` (the schema's output), `{ issues }`, or `{ error }` (no JSON Schema form)
|
|
158
|
+
*/
|
|
159
|
+
export async function parseInput(schema: any, args: any, options: {repair?: boolean} = {}): Promise<{value?: any; issues?: Issue[]; error?: string}> {
|
|
160
|
+
const c = toJsonSchema(schema)
|
|
161
|
+
if (c.error) return {error: c.error}
|
|
162
|
+
let v = c.wrapped && isObj(args) && 'value' in args ? args.value : args
|
|
163
|
+
if (options.repair !== false) v = repair(v, c.wrapped ? c.schema.properties.value : c.schema, c.schema)
|
|
164
|
+
const r = await schema['~standard'].validate(v)
|
|
165
|
+
return r.issues ? {issues: r.issues.map((i: any) => ({message: i.message, ...(i.path && {path: i.path.map((p: any) => isObj(p) ? p.key : p)})}))} : {value: r.value}
|
|
166
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-6 A-1 (D262, 0-S4 §4): plain JSON Schema as a tool `input` (or a chat `output`), only
|
|
3
|
+
* through `jsonSchema(json[, { validate }])`. It returns a Standard Schema that is also a
|
|
4
|
+
* Standard JSON Schema, so it takes the same path as Zod / Valibot / ArkType. Without
|
|
5
|
+
* `validate`, a subset validator checks the value: type (integer and type arrays too), enum,
|
|
6
|
+
* const, required, properties, additionalProperties (false or a schema), items, prefixItems,
|
|
7
|
+
* anyOf / oneOf, minLength / maxLength, pattern, minimum / maximum, minItems / maxItems and local
|
|
8
|
+
* `$ref`s (`#`, `#/$defs/…`, `#/definitions/…`). format, allOf, not, if/then/else, multipleOf,
|
|
9
|
+
* uniqueItems, exclusive* and the rest are accepted unchecked. `{ validate: s }` keeps a
|
|
10
|
+
* library's own validation (Zod Mini: `jsonSchema(z.toJSONSchema(s), { validate: s })`).
|
|
11
|
+
* A separate module: tree-shaken unless imported.
|
|
12
|
+
*/
|
|
13
|
+
import type {Issue} from './index'
|
|
14
|
+
|
|
15
|
+
const T = (v: any) => v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v
|
|
16
|
+
const is = (t: string, v: any) => t == 'integer' ? Number.isInteger(v) : t == 'number' ? typeof v == 'number' && isFinite(v) : T(v) == t
|
|
17
|
+
const S = JSON.stringify
|
|
18
|
+
|
|
19
|
+
/** the subset check: the issues of `v` against `s` (empty when valid) */
|
|
20
|
+
export function check(s: any, v: any, root: any = s, path: PropertyKey[] = [], out: Issue[] = []): Issue[] {
|
|
21
|
+
const bad = (message: string) => (out.push({message, path}), out)
|
|
22
|
+
if (s === false) return bad('no value allowed')
|
|
23
|
+
if (!s || s === true) return out
|
|
24
|
+
if (s.$ref) return check(s.$ref == '#' ? root : root.$defs?.[s.$ref.slice(8)] ?? root.definitions?.[s.$ref.slice(14)], v, root, path, out)
|
|
25
|
+
const types = [].concat(s.type ?? []) as string[]
|
|
26
|
+
if (types.length && !types.some(t => is(t, v))) return bad(`expected ${types.join(' or ')}, got ${T(v)}`)
|
|
27
|
+
if (s.enum && !s.enum.some((e: any) => S(e) == S(v))) return bad(`expected one of ${S(s.enum)}`)
|
|
28
|
+
if ('const' in s && S(s.const) != S(v)) return bad(`expected ${S(s.const)}`)
|
|
29
|
+
const any = s.anyOf ?? s.oneOf
|
|
30
|
+
if (any && !any.some((b: any) => !check(b, v, root, path).length)) return bad('matches no alternative')
|
|
31
|
+
if (typeof v == 'string') {
|
|
32
|
+
if (v.length < (s.minLength ?? 0)) bad(`at least ${s.minLength} characters`)
|
|
33
|
+
if (v.length > (s.maxLength ?? Infinity)) bad(`at most ${s.maxLength} characters`)
|
|
34
|
+
if (s.pattern && !new RegExp(s.pattern, 'u').test(v)) bad(`must match ${s.pattern}`)
|
|
35
|
+
}
|
|
36
|
+
if (typeof v == 'number') {
|
|
37
|
+
if (v < (s.minimum ?? -Infinity)) bad(`at least ${s.minimum}`)
|
|
38
|
+
if (v > (s.maximum ?? Infinity)) bad(`at most ${s.maximum}`)
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(v)) {
|
|
41
|
+
if (v.length < (s.minItems ?? 0)) bad(`at least ${s.minItems} items`)
|
|
42
|
+
if (v.length > (s.maxItems ?? Infinity)) bad(`at most ${s.maxItems} items`)
|
|
43
|
+
const pre = s.prefixItems ?? []
|
|
44
|
+
v.forEach((x, i) => check(i < pre.length ? pre[i] : s.items, x, root, [...path, i], out))
|
|
45
|
+
} else if (T(v) == 'object') {
|
|
46
|
+
for (const k of s.required ?? []) if (!(k in v)) bad(`${k} is required`)
|
|
47
|
+
const p = s.properties ?? {}
|
|
48
|
+
for (const k in v) check(k in p ? p[k] : s.additionalProperties, v[k], root, [...path, k], out)
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A plain JSON Schema as a Standard Schema with Standard JSON Schema: the schema goes to the
|
|
55
|
+
* model as is (normalized), and the subset validator (or `options.validate`, a Standard Schema)
|
|
56
|
+
* checks the value
|
|
57
|
+
*/
|
|
58
|
+
export function jsonSchema(schema: any, options: {validate?: any} = {}): any {
|
|
59
|
+
const own = options.validate?.['~standard']
|
|
60
|
+
const out = () => schema
|
|
61
|
+
return {
|
|
62
|
+
'~standard': {
|
|
63
|
+
version: 1,
|
|
64
|
+
vendor: 'sygnal',
|
|
65
|
+
validate: own ? (v: any) => own.validate(v) : (value: any) => {
|
|
66
|
+
const issues = check(schema, value)
|
|
67
|
+
return issues.length ? {issues} : {value}
|
|
68
|
+
},
|
|
69
|
+
jsonSchema: {input: out, output: out},
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|