sygnal 6.0.0 → 6.1.1

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 (96) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/dist/ai.cjs.js +115 -0
  3. package/dist/ai.cjs.js.map +1 -0
  4. package/dist/ai.esm.js +2 -0
  5. package/dist/ai.esm.js.map +1 -0
  6. package/dist/astro/index.cjs.js +659 -18
  7. package/dist/astro/index.cjs.js.map +1 -1
  8. package/dist/astro/index.mjs +659 -18
  9. package/dist/astro/index.mjs.map +1 -1
  10. package/dist/astro/server.cjs.js.map +1 -1
  11. package/dist/astro/server.mjs.map +1 -1
  12. package/dist/devtools.cjs.js +413 -4
  13. package/dist/devtools.cjs.js.map +1 -1
  14. package/dist/devtools.esm.js +413 -5
  15. package/dist/devtools.esm.js.map +1 -1
  16. package/dist/diagnostics.cjs.js +170 -12
  17. package/dist/diagnostics.cjs.js.map +1 -1
  18. package/dist/diagnostics.esm.js +170 -12
  19. package/dist/diagnostics.esm.js.map +1 -1
  20. package/dist/guide/accessibility.md +54 -1
  21. package/dist/guide/agent.md +596 -0
  22. package/dist/guide/ai-chat.md +693 -0
  23. package/dist/guide/ai-decisions.md +330 -0
  24. package/dist/guide/forms-reference.md +28 -0
  25. package/dist/guide/forms.md +2 -1
  26. package/dist/guide/http.md +2 -0
  27. package/dist/guide/mcp-apps.md +272 -0
  28. package/dist/guide/recipes/ai-form-fill.md +163 -0
  29. package/dist/guide/recipes/ai-summarize.md +155 -0
  30. package/dist/guide/recipes/ai-support-inbox.md +190 -0
  31. package/dist/guide/recipes/overview.md +10 -0
  32. package/dist/guide/webmcp.md +226 -0
  33. package/dist/index.cjs.js +7841 -3370
  34. package/dist/index.cjs.js.map +1 -1
  35. package/dist/index.d.ts +1332 -6
  36. package/dist/index.esm.js +7139 -2696
  37. package/dist/index.esm.js.map +1 -1
  38. package/dist/sygnal.min.js +1 -1
  39. package/dist/sygnal.min.js.map +1 -1
  40. package/dist/vike/config/package.json +1 -1
  41. package/dist/vite/plugin.cjs.js +659 -18
  42. package/dist/vite/plugin.cjs.js.map +1 -1
  43. package/dist/vite/plugin.mjs +659 -18
  44. package/dist/vite/plugin.mjs.map +1 -1
  45. package/llms.txt +7 -1
  46. package/package.json +15 -1
  47. package/src/ai.d.ts +1186 -0
  48. package/src/ai.ts +23 -0
  49. package/src/core/hooks.ts +1 -1
  50. package/src/devtools.d.ts +20 -1
  51. package/src/devtools.ts +3 -0
  52. package/src/extra/ai/agent/index.ts +484 -0
  53. package/src/extra/ai/answers.ts +115 -0
  54. package/src/extra/ai/chat/behavior.ts +336 -0
  55. package/src/extra/ai/chat/driver.ts +271 -0
  56. package/src/extra/ai/chat/memoryTransport.ts +61 -0
  57. package/src/extra/ai/chat/output.ts +37 -0
  58. package/src/extra/ai/commandBar.ts +281 -0
  59. package/src/extra/ai/decide.ts +104 -0
  60. package/src/extra/ai/index.ts +42 -0
  61. package/src/extra/ai/link.ts +90 -0
  62. package/src/extra/ai/mcpApp.ts +314 -0
  63. package/src/extra/ai/messages.ts +51 -0
  64. package/src/extra/ai/schema/index.ts +166 -0
  65. package/src/extra/ai/schema/jsonSchema.ts +72 -0
  66. package/src/extra/ai/schema/strict.ts +152 -0
  67. package/src/extra/ai/transports/agui.ts +177 -0
  68. package/src/extra/ai/transports/anthropicMessages.ts +175 -0
  69. package/src/extra/ai/transports/chatCompletions.ts +105 -0
  70. package/src/extra/ai/transports/chromePrompt.ts +77 -0
  71. package/src/extra/ai/transports/encodeOpenResponses.ts +87 -0
  72. package/src/extra/ai/transports/fromAISDK.ts +126 -0
  73. package/src/extra/ai/transports/openResponses.ts +105 -0
  74. package/src/extra/ai/transports/shared.ts +125 -0
  75. package/src/extra/ai/transports/tools.ts +59 -0
  76. package/src/extra/ai/transports/uiMessageStream.ts +107 -0
  77. package/src/extra/ai/webmcp.ts +260 -0
  78. package/src/extra/copyAsTest.ts +2 -2
  79. package/src/extra/devMcp.ts +386 -0
  80. package/src/extra/devtoolsActions.ts +1 -1
  81. package/src/extra/diagnostics/checks/actionLog.ts +3 -3
  82. package/src/extra/diagnostics/checks/chat.ts +71 -0
  83. package/src/extra/diagnostics/checks/forms.ts +8 -0
  84. package/src/extra/diagnostics/checks/index.ts +7 -1
  85. package/src/extra/diagnostics/checks/public.d.ts +63 -2
  86. package/src/extra/diagnostics/checks/wiring.ts +5 -2
  87. package/src/extra/diagnostics/codes.ts +67 -0
  88. package/src/extra/form.ts +22 -6
  89. package/src/extra/formTool.ts +150 -0
  90. package/src/extra/testing.ts +125 -8
  91. package/src/index.d.ts +119 -3
  92. package/src/index.ts +21 -1
  93. package/src/shared.ts +9 -0
  94. package/src/vite/mcp.ts +568 -0
  95. package/src/vite/plugin.d.ts +43 -5
  96. package/src/vite/plugin.ts +109 -22
@@ -0,0 +1,104 @@
1
+ /*
2
+ * PLAN-6 M-1: decisions (TypeSafe Jev, Ollama `nimble`/`clef`, OpenRouter, AI Gateway, OpenAI
3
+ * Decisions) are plain HTTP requests, so there is no driver here: `decide()` builds a
4
+ * `makeFetchDriver` request that works as a reply-action request (`ok` / `error`) and as a
5
+ * `resources` entry. Types and docs: src/ai.d.ts.
6
+ *
7
+ * Dictionary form (TypeSafe `/v1/systemone`, Ollama ≥ 0.35 `/v1/systemone`; verified against
8
+ * Ollama 0.40.2 + nimble):
9
+ * request { model, state, questions: { name: { type, instructions, criteria? } }, images? }
10
+ * choice: criteria { option: description | null }; noul: criteria { true, false }
11
+ * (optional); score: criteria [level descriptions, lowest first]
12
+ * response { model, answers: { name: { type: 'choice', choice, probabilities, confidence }
13
+ * | { type: 'noul', noul } | { type: 'score', score, legend, probabilities, confidence } },
14
+ * usage: { input_tokens, output_tokens } }
15
+ *
16
+ * OpenAI Decisions (`POST /v1/decisions`, the array form): `decide.openai()` sends
17
+ * { model, input, questions: [{ type: 'predicate' | 'choice' | 'score', name, instructions,
18
+ * choices?: [{ value, description }], levels?: [{ label, description }] }] }
19
+ * and its `parse` maps `{ answers: [{ type, name, … }] }` back to the dictionary form, so the
20
+ * reply action / resource data has the same shape whichever form was sent (a refusal becomes
21
+ * `{ type: 'refusal' }` under the question's name).
22
+ *
23
+ * Side-effect free (0 B for apps that don't import it).
24
+ */
25
+
26
+ /** a `choice` question: pick one option (criteria: option → description, or an array of option names) */
27
+ export const choice = (instructions: any, criteria: any) => ({
28
+ type: 'choice',
29
+ instructions,
30
+ criteria: Array.isArray(criteria) ? Object.fromEntries(criteria.map(c => [c, null])) : criteria,
31
+ });
32
+
33
+ /** a `noul` question: the probability of yes (criteria: `{ true, false }` descriptions) */
34
+ export const noul = (instructions: any, criteria?: any) =>
35
+ criteria ? {type: 'noul', instructions, criteria} : {type: 'noul', instructions};
36
+
37
+ /** a `score` question: an ordered scale, lowest first (the answer is the weighted level index) */
38
+ export const score = (instructions: any, levels: any) => ({type: 'score', instructions, criteria: levels});
39
+
40
+ /** the dictionary-form request (TypeSafe, Ollama, OpenRouter, AI Gateway) */
41
+ function dictionary({url = '/api/decide', model, state, questions, images, ...rest}: any = {}) {
42
+ const json: any = {model, state, questions};
43
+ if (images) json.images = images;
44
+ return {url, method: 'POST', json, ...rest};
45
+ }
46
+
47
+ const text = (x: any) => typeof x == 'string' ? x : JSON.stringify(x);
48
+ // the dictionary form takes base64 files; OpenAI takes data URLs (the type from the magic bytes)
49
+ const dataUrl = (b64: string) => b64.startsWith('data:') ? b64
50
+ : `data:image/${b64.startsWith('/9j/') ? 'jpeg' : b64.startsWith('UklGR') ? 'webp' : 'png'};base64,${b64}`;
51
+
52
+ /** the questions in OpenAI's array form */
53
+ export const toOpenAI = (questions: any) => Object.keys(questions || {}).map(name => {
54
+ const q = questions[name];
55
+ const c = q.criteria;
56
+ const instructions = text(q.instructions);
57
+ if (q.type == 'choice') {
58
+ return {type: 'choice', name, instructions,
59
+ choices: Object.keys(c).map(value => c[value] == null ? {value} : {value, description: c[value]})};
60
+ }
61
+ if (q.type == 'score') return {type: 'score', name, instructions, levels: c.map((d: string) => ({label: d, description: d}))};
62
+ // a predicate has no criteria: they go into the instructions
63
+ return {type: 'predicate', name,
64
+ instructions: c ? `${instructions}\nTrue: ${c.true}\nFalse: ${c.false}` : instructions};
65
+ });
66
+
67
+ // a refusal (or a type this mapping doesn't know) keeps its fields but `name`
68
+ const withoutName = ({name, ...a}: any) => a;
69
+
70
+ const toMap = (list: any[], k: string) => {
71
+ const out: any = {};
72
+ (list || []).forEach(p => { out[String(p[k])] = p.probability; });
73
+ return out;
74
+ };
75
+
76
+ /** an OpenAI Decisions reply in the dictionary form */
77
+ export const fromOpenAI = (reply: any) => {
78
+ if (!reply || !Array.isArray(reply.answers)) return reply;
79
+ const answers: any = {};
80
+ reply.answers.forEach((a: any) => {
81
+ const {type, name} = a;
82
+ answers[name] = type == 'predicate' ? {type: 'noul', noul: a.probability}
83
+ : type == 'choice' ? {type, choice: a.choice, probabilities: toMap(a.probabilities, 'value'), confidence: a.confidence}
84
+ : type == 'score' ? {type, score: a.score,
85
+ legend: Object.fromEntries((a.probabilities || []).map((p: any) => [String(p.value), p.label])),
86
+ probabilities: toMap(a.probabilities, 'value'), confidence: a.confidence}
87
+ : withoutName(a);
88
+ });
89
+ return {...reply, answers};
90
+ };
91
+
92
+ const parseOpenAI = async (res: any) => fromOpenAI(await res.json());
93
+
94
+ /** the same questions as an OpenAI Decisions request; the reply arrives in the dictionary form */
95
+ function openai({url = '/api/decide', model, state, questions, images, ...rest}: any = {}) {
96
+ const input = images && images.length
97
+ ? [{role: 'user', content: [{type: 'input_text', text: text(state)},
98
+ ...images.map((b: string) => ({type: 'input_image', image_url: dataUrl(b)}))]}]
99
+ : text(state);
100
+ return {url, method: 'POST', json: {model, input, questions: toOpenAI(questions)}, parse: parseOpenAI, ...rest};
101
+ }
102
+
103
+ /** the dictionary-form request (TypeSafe, Ollama, OpenRouter, AI Gateway); `decide.openai()`: the same as an OpenAI Decisions request */
104
+ export const decide = /*#__PURE__*/ Object.assign(dictionary, {openai})
@@ -0,0 +1,42 @@
1
+ /*
2
+ * PLAN-6 (D240, D253): the implementation behind 'sygnal/ai'. It lives in the main package so
3
+ * there is one copy of the reply helpers and the diagnostics module (G-581) and renderComponent's
4
+ * fakes can use it; src/index.ts exports these names and src/ai.ts re-exports them from the
5
+ * external 'sygnal', which is the documented import. Every module here must be side-effect free
6
+ * so apps that don't use it pay 0 B (the size gate).
7
+ *
8
+ * Layout (Phase 1): messages.ts (L-1 message helpers), chat/ (L-1 driver, L-2 transports),
9
+ * agent/ (A-1 layer), schema/ (A-1 input contract), decide.ts (M-1).
10
+ */
11
+ export {messageText, withToolResults} from './messages'
12
+ export {decide, choice, noul, score} from './decide'
13
+ // A-1 (1-A): the agent layer and the schema input contract
14
+ export {agentTools} from './agent/index'
15
+ export {toJsonSchema, parseInput} from './schema/index'
16
+ export {jsonSchema} from './schema/jsonSchema'
17
+ // L-1 (1-L): the chat driver; outputJsonSchema is the structured-output seam transports use (1-A fills it)
18
+ export {makeChatDriver} from './chat/driver'
19
+ export {outputJsonSchema} from './chat/output'
20
+ // A-2 (2-W): WebMCP (experimental, D241)
21
+ export {experimentalExposeWebMcp} from './webmcp'
22
+ // L-3 (2-C): the chat behavior
23
+ export {chat} from './chat/behavior'
24
+ // L-2 (2-T): transports, wave 1 (D248), each its own module (tree-shaken), and the Open Responses
25
+ // event encoder (D273)
26
+ export {openResponses} from './transports/openResponses'
27
+ export {chatCompletions} from './transports/chatCompletions'
28
+ export {uiMessageStream} from './transports/uiMessageStream'
29
+ export {chromePrompt} from './transports/chromePrompt'
30
+ export {encodeOpenResponses} from './transports/encodeOpenResponses'
31
+ // M-2 / M-3 (3-M): decision reply fixtures and the command bar
32
+ export {answers} from './answers'
33
+ // PLAN-6 A-3 (experimental, D241, D291): form(schema, { tool: formTool({ … }) })
34
+ export {formTool} from '../formTool'
35
+ export {commandBar} from './commandBar'
36
+ // L-2 (3-W2): transports, wave 2 (D248), and the opt-in strict layer (D285)
37
+ export {anthropicMessages} from './transports/anthropicMessages'
38
+ export {agui} from './transports/agui'
39
+ export {fromAISDK} from './transports/fromAISDK'
40
+ export {strictSchemas} from './schema/strict'
41
+ // X-1 (3-X): MCP Apps, the view side
42
+ export {makeMcpAppDriver} from './mcpApp'
@@ -0,0 +1,90 @@
1
+ /*
2
+ * PLAN-6 (D283): how the sygnal/ai behaviors (`chat`, L-3; `commandBar`, M-3) reach their app's
3
+ * runtime, with 0 core bytes. The agent layer (A-1) needs the app's runtime API and the host
4
+ * instance, which a behavior's handlers don't get. The first factory call (`chat()`,
5
+ * `commandBar()`) adds one hook-layer factory to the core bridge's `layers`
6
+ * (`globalThis.__SYGNAL_DIAGNOSTICS__.layers`, which every App reads when it is constructed, as
7
+ * the dev entries do), so each app made after it hands the layer its runtime API. The layer's
8
+ * onAction (called synchronously right before an action's handlers) records the instance and its
9
+ * pre-action state; a handler takes them when its `props.state` is that state.
10
+ *
11
+ * G-625: an app made before the first factory call (a host loaded lazily into a running app)
12
+ * didn't read the layer at construction, so the first factory call also adds it to the apps that
13
+ * are already running and can be found without core changes: the one `run()` registers as
14
+ * `window.__SYGNAL_DEVTOOLS_APP__` (the first live app; its `__runtime.addHooks`). Another app
15
+ * started before it (a second `run()`, a `sygnal/element` custom element) can't be found: its
16
+ * hosts are reported as SYG442 and run without the link.
17
+ *
18
+ * Per (host instance, uses key) a behavior keeps one engine (`engineOf`); the layer's onDispose
19
+ * stops the host's engines.
20
+ */
21
+ import {error as logError} from '../diagnostics/legacy'
22
+
23
+ const G: any = globalThis
24
+ /** marks a `uses` value whose host needs the link (`linked()`) */
25
+ const AI = Symbol.for('sygnal.aiBehavior')
26
+
27
+ interface Ctx {api: any; iv: any; pre: any}
28
+ export interface Engine {api: any; iv: any; k: string; stop(): void}
29
+
30
+ /** the instance whose action is being handled (set by the layer's onAction; one object per app) */
31
+ let cur: Ctx | undefined
32
+ const engines = new WeakMap<any, Map<string, Engine>>()
33
+ /** the dispose$ of each linked host an app with the layer created (SYG442 otherwise) */
34
+ const known = new WeakSet<any>()
35
+ const isHost = (iv: any) => {
36
+ const u = iv.def.view.uses
37
+ for (const k in u) if (u[k]?.[AI]) return true
38
+ return false
39
+ }
40
+
41
+ const layer = (api: any) => {
42
+ const c: Ctx = {api, iv: null, pre: null}
43
+ return {
44
+ onCreate: (iv: any) => { if (isHost(iv)) known.add(iv.sources.dispose$) },
45
+ onAction: (iv: any) => { c.iv = iv; c.pre = iv.state; cur = c },
46
+ onDispose: (iv: any) => { engines.get(iv)?.forEach(e => e.stop()) },
47
+ }
48
+ }
49
+
50
+ /** the runtimes the layer was added to after they started (G-625) */
51
+ const late = new WeakSet<any>()
52
+
53
+ /** install the layer (apps made from now on read it) and mark the behavior value `b` as needing it */
54
+ export function linked<B>(b: B): B {
55
+ const D = G.__SYGNAL_DIAGNOSTICS__ ||= {}, L = D.layers ||= new Set()
56
+ if (!(L.has ? L.has(layer) : L.includes(layer))) {
57
+ L.add ? L.add(layer) : L.push(layer)
58
+ // G-625: the app already running (registered by run())
59
+ const rt = (typeof window != 'undefined' ? window as any : G).__SYGNAL_DEVTOOLS_APP__?.__runtime
60
+ if (rt && typeof rt.addHooks == 'function' && !late.has(rt)) {
61
+ late.add(rt)
62
+ rt.addHooks(layer(rt))
63
+ }
64
+ }
65
+ ;(b as any)[AI] = true
66
+ return b
67
+ }
68
+
69
+ /**
70
+ * The engine of the host whose action runs now, made by `make(api, iv)` on first use (undefined
71
+ * when the app has no layer, or `props` isn't the acting instance's)
72
+ */
73
+ export function engineOf<E extends Engine>(props: any, k: string, make: (api: any, iv: any) => E): E | undefined {
74
+ const c = cur
75
+ if (!c || !props || props.state !== c.pre || c.iv.disposed) return
76
+ const iv = c.iv
77
+ let m = engines.get(iv)
78
+ if (!m) engines.set(iv, m = new Map())
79
+ let e = m.get(k) as E | undefined
80
+ if (!e) m.set(k, e = make(c.api, iv))
81
+ return e
82
+ }
83
+
84
+ const warned = new Set<string>()
85
+ /** SYG442, once per behavior: an intent's host has no link (its app was made before the factory call) */
86
+ export function checkLinked(sources: any, name: string, without: string) {
87
+ if (known.has(sources.dispose$) || warned.has(name)) return
88
+ warned.add(name)
89
+ logError('SYG442', name, `the ${name} behavior has no connection to its app (the app was started before the first ${name}() call, and isn't the page's first app): ${without}`, `Call ${name}() in a module imported before run(), not only in a component loaded later`)
90
+ }
@@ -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
+ }