@ggui-ai/protocol 0.2.0-alpha.4 → 0.3.0-rc.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 (197) hide show
  1. package/README.md +16 -16
  2. package/dist/envelope-adapters.d.ts +6 -5
  3. package/dist/envelope-adapters.d.ts.map +1 -1
  4. package/dist/envelope-adapters.js +3 -3
  5. package/dist/envelopes/builders.d.ts +6 -32
  6. package/dist/envelopes/builders.d.ts.map +1 -1
  7. package/dist/envelopes/builders.js +3 -25
  8. package/dist/errors/version-mismatch.d.ts +1 -1
  9. package/dist/errors/version-mismatch.js +1 -1
  10. package/dist/gadgets/resolve-app-gadgets.d.ts +10 -0
  11. package/dist/gadgets/resolve-app-gadgets.d.ts.map +1 -0
  12. package/dist/gadgets/resolve-app-gadgets.js +19 -0
  13. package/dist/gadgets/resolve-contract-gadgets.d.ts +6 -6
  14. package/dist/gadgets/resolve-contract-gadgets.js +5 -5
  15. package/dist/gadgets/stdlib-gadgets.d.ts +1 -1
  16. package/dist/gadgets/stdlib-gadgets.d.ts.map +1 -1
  17. package/dist/gadgets/stdlib-gadgets.js +43 -22
  18. package/dist/iframe-bridge.d.ts +4 -43
  19. package/dist/iframe-bridge.d.ts.map +1 -1
  20. package/dist/iframe-bridge.js +4 -141
  21. package/dist/index.d.ts +60 -62
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +66 -61
  24. package/dist/integrations/mcp-apps.d.ts +83 -38
  25. package/dist/integrations/mcp-apps.d.ts.map +1 -1
  26. package/dist/integrations/mcp-apps.js +98 -52
  27. package/dist/recommended-prompts.d.ts +1 -1
  28. package/dist/recommended-prompts.js +1 -1
  29. package/dist/registry/blueprint-key.d.ts +3 -0
  30. package/dist/registry/blueprint-key.d.ts.map +1 -1
  31. package/dist/registry/blueprint-key.js +9 -0
  32. package/dist/registry/blueprint-stamp.d.ts +12 -0
  33. package/dist/registry/blueprint-stamp.d.ts.map +1 -0
  34. package/dist/registry/blueprint-stamp.js +12 -0
  35. package/dist/registry/canonicalize-contract.d.ts +31 -2
  36. package/dist/registry/canonicalize-contract.d.ts.map +1 -1
  37. package/dist/registry/canonicalize-contract.js +125 -13
  38. package/dist/registry/portable-blueprint.d.ts +64 -0
  39. package/dist/registry/portable-blueprint.d.ts.map +1 -0
  40. package/dist/registry/portable-blueprint.js +103 -0
  41. package/dist/registry/summarize-contract.d.ts +7 -2
  42. package/dist/registry/summarize-contract.d.ts.map +1 -1
  43. package/dist/registry/summarize-contract.js +12 -3
  44. package/dist/registry/variant-key.d.ts +17 -0
  45. package/dist/registry/variant-key.d.ts.map +1 -0
  46. package/dist/registry/variant-key.js +22 -0
  47. package/dist/schema-learning/derive-contract.d.ts +2 -2
  48. package/dist/schema-learning/derive-contract.js +2 -2
  49. package/dist/schema-learning/merge.d.ts.map +1 -1
  50. package/dist/schema-learning/merge.js +3 -2
  51. package/dist/schemas/app-theme.d.ts +38 -0
  52. package/dist/schemas/app-theme.d.ts.map +1 -0
  53. package/dist/schemas/app-theme.js +43 -0
  54. package/dist/schemas/blueprint.d.ts +30 -11
  55. package/dist/schemas/blueprint.d.ts.map +1 -1
  56. package/dist/schemas/blueprint.js +62 -26
  57. package/dist/schemas/data-contract.d.ts +28 -30
  58. package/dist/schemas/data-contract.d.ts.map +1 -1
  59. package/dist/schemas/data-contract.js +26 -26
  60. package/dist/schemas/handshake-suggestion.d.ts +2 -4
  61. package/dist/schemas/handshake-suggestion.d.ts.map +1 -1
  62. package/dist/schemas/handshake-suggestion.js +5 -14
  63. package/dist/schemas/mcp.d.ts +240 -58
  64. package/dist/schemas/mcp.d.ts.map +1 -1
  65. package/dist/schemas/mcp.js +285 -93
  66. package/dist/schemas/ops-blueprint.d.ts +12 -12
  67. package/dist/schemas/ops-blueprint.d.ts.map +1 -1
  68. package/dist/schemas/ops-blueprint.js +14 -24
  69. package/dist/screen-blueprints/define.d.ts +2 -2
  70. package/dist/screen-blueprints/match.d.ts +12 -7
  71. package/dist/screen-blueprints/match.d.ts.map +1 -1
  72. package/dist/screen-blueprints/match.js +7 -5
  73. package/dist/screen-blueprints/types.d.ts +14 -18
  74. package/dist/screen-blueprints/types.d.ts.map +1 -1
  75. package/dist/transport/websocket.d.ts +5 -26
  76. package/dist/transport/websocket.d.ts.map +1 -1
  77. package/dist/types/app-config.d.ts +0 -1
  78. package/dist/types/app-config.d.ts.map +1 -1
  79. package/dist/types/auth.d.ts +1 -46
  80. package/dist/types/auth.d.ts.map +1 -1
  81. package/dist/types/blueprint-source.d.ts +123 -0
  82. package/dist/types/blueprint-source.d.ts.map +1 -0
  83. package/dist/types/blueprint-source.js +126 -0
  84. package/dist/types/blueprint.d.ts +25 -18
  85. package/dist/types/blueprint.d.ts.map +1 -1
  86. package/dist/types/contract-inference.d.ts +2 -43
  87. package/dist/types/contract-inference.d.ts.map +1 -1
  88. package/dist/types/data-contract.d.ts +59 -344
  89. package/dist/types/data-contract.d.ts.map +1 -1
  90. package/dist/types/data-contract.js +5 -12
  91. package/dist/types/events.d.ts +31 -69
  92. package/dist/types/events.d.ts.map +1 -1
  93. package/dist/types/{render-event.d.ts → ggui-session-event.d.ts} +15 -15
  94. package/dist/types/ggui-session-event.d.ts.map +1 -0
  95. package/dist/types/{render-event.js → ggui-session-event.js} +5 -5
  96. package/dist/types/handshake-suggestion.d.ts +49 -35
  97. package/dist/types/handshake-suggestion.d.ts.map +1 -1
  98. package/dist/types/host-context.d.ts +8 -8
  99. package/dist/types/host-context.d.ts.map +1 -1
  100. package/dist/types/host-context.js +5 -5
  101. package/dist/types/invoke.d.ts +1 -13
  102. package/dist/types/invoke.d.ts.map +1 -1
  103. package/dist/types/{canvas-lifecycle.d.ts → lifecycle.d.ts} +35 -37
  104. package/dist/types/lifecycle.d.ts.map +1 -0
  105. package/dist/types/{canvas-lifecycle.js → lifecycle.js} +12 -14
  106. package/dist/types/live-channel.d.ts +48 -104
  107. package/dist/types/live-channel.d.ts.map +1 -1
  108. package/dist/types/llm.d.ts +13 -24
  109. package/dist/types/llm.d.ts.map +1 -1
  110. package/dist/types/llm.js +23 -53
  111. package/dist/types/mcp.d.ts +55 -176
  112. package/dist/types/mcp.d.ts.map +1 -1
  113. package/dist/types/mcp.js +2 -2
  114. package/dist/types/oauth-user-id.d.ts +12 -0
  115. package/dist/types/oauth-user-id.d.ts.map +1 -0
  116. package/dist/types/oauth-user-id.js +10 -0
  117. package/dist/types/portable-blueprint.d.ts +53 -0
  118. package/dist/types/portable-blueprint.d.ts.map +1 -0
  119. package/dist/types/portable-blueprint.js +8 -0
  120. package/dist/types/{session.d.ts → render.d.ts} +51 -53
  121. package/dist/types/render.d.ts.map +1 -0
  122. package/dist/types/thread.d.ts.map +1 -1
  123. package/dist/types/thread.js +3 -3
  124. package/dist/types/ui-generator.d.ts +5 -46
  125. package/dist/types/ui-generator.d.ts.map +1 -1
  126. package/dist/types/ui-generator.js +1 -53
  127. package/dist/validation/ajv-runtime.d.ts +3 -3
  128. package/dist/validation/ajv-runtime.d.ts.map +1 -1
  129. package/dist/validation/ajv-runtime.js +6 -5
  130. package/dist/validation/contract-validator.d.ts +23 -22
  131. package/dist/validation/contract-validator.d.ts.map +1 -1
  132. package/dist/validation/contract-validator.js +28 -26
  133. package/dist/validation/cross-references.d.ts +3 -3
  134. package/dist/validation/cross-references.js +3 -3
  135. package/dist/validation/hygiene-rules.d.ts +9 -9
  136. package/dist/validation/hygiene-rules.js +13 -13
  137. package/dist/validation/is-record.d.ts +17 -0
  138. package/dist/validation/is-record.d.ts.map +1 -0
  139. package/dist/validation/is-record.js +18 -0
  140. package/dist/validation/lint-contract.d.ts +1 -1
  141. package/dist/validation/lint-contract.js +3 -3
  142. package/dist/validation/name-invariants.d.ts +4 -4
  143. package/dist/validation/name-invariants.js +4 -4
  144. package/dist/validation/reserved-channels.d.ts +43 -46
  145. package/dist/validation/reserved-channels.d.ts.map +1 -1
  146. package/dist/validation/reserved-channels.js +14 -177
  147. package/dist/validation/resolve-stream-channel.d.ts +3 -9
  148. package/dist/validation/resolve-stream-channel.d.ts.map +1 -1
  149. package/dist/validation/resolve-stream-channel.js +3 -4
  150. package/dist/validation/sanitize-error.d.ts +10 -9
  151. package/dist/validation/sanitize-error.d.ts.map +1 -1
  152. package/dist/validation/sanitize-error.js +19 -18
  153. package/dist/validation/schema-compat-invariants.d.ts +1 -1
  154. package/dist/validation/schema-compat-invariants.js +3 -3
  155. package/dist/validation/schema-meta-validation.d.ts +4 -4
  156. package/dist/validation/schema-meta-validation.d.ts.map +1 -1
  157. package/dist/validation/schema-meta-validation.js +7 -9
  158. package/dist/validation/schema-subset.d.ts +9 -8
  159. package/dist/validation/schema-subset.d.ts.map +1 -1
  160. package/dist/validation/ui-security.d.ts +0 -17
  161. package/dist/validation/ui-security.d.ts.map +1 -1
  162. package/dist/validation/ui-security.js +17 -22
  163. package/dist/validation/zod-to-json-schema.d.ts +1 -1
  164. package/dist/validation/zod-to-json-schema.d.ts.map +1 -1
  165. package/dist/validation/zod-to-json-schema.js +4 -3
  166. package/dist/version.d.ts +783 -9
  167. package/dist/version.d.ts.map +1 -1
  168. package/dist/version.js +782 -8
  169. package/package.json +2 -2
  170. package/dist/bridge/invoke-agent.d.ts +0 -65
  171. package/dist/bridge/invoke-agent.d.ts.map +0 -1
  172. package/dist/bridge/invoke-agent.js +0 -113
  173. package/dist/stream/stream-parser.d.ts +0 -62
  174. package/dist/stream/stream-parser.d.ts.map +0 -1
  175. package/dist/stream/stream-parser.js +0 -199
  176. package/dist/types/canvas-lifecycle.d.ts.map +0 -1
  177. package/dist/types/credential.d.ts +0 -41
  178. package/dist/types/credential.d.ts.map +0 -1
  179. package/dist/types/credential.js +0 -32
  180. package/dist/types/data-bindings.d.ts +0 -322
  181. package/dist/types/data-bindings.d.ts.map +0 -1
  182. package/dist/types/data-bindings.js +0 -29
  183. package/dist/types/feedback.d.ts +0 -22
  184. package/dist/types/feedback.d.ts.map +0 -1
  185. package/dist/types/feedback.js +0 -7
  186. package/dist/types/mcp-proxy.d.ts +0 -67
  187. package/dist/types/mcp-proxy.d.ts.map +0 -1
  188. package/dist/types/mcp-proxy.js +0 -46
  189. package/dist/types/openrouter-models.d.ts +0 -23
  190. package/dist/types/openrouter-models.d.ts.map +0 -1
  191. package/dist/types/openrouter-models.js +0 -4843
  192. package/dist/types/region.d.ts +0 -26
  193. package/dist/types/region.d.ts.map +0 -1
  194. package/dist/types/region.js +0 -36
  195. package/dist/types/render-event.d.ts.map +0 -1
  196. package/dist/types/session.d.ts.map +0 -1
  197. /package/dist/types/{session.js → render.js} +0 -0
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * This module is the **boundary** for MCP Apps outbound delivery. Anything
5
5
  * MCP-Apps-specific that ggui exposes to the rest of the codebase lives
6
- * here — never in `types/live-channel.ts`, `types/mcp.ts`, `types/session.ts`,
6
+ * here — never in `types/live-channel.ts`, `types/mcp.ts`, `types/render.ts`,
7
7
  * or any other core module. Consumers opt in via the subpath import:
8
8
  *
9
9
  * ```ts
@@ -21,12 +21,14 @@
21
21
  * drift is bounded to callers that explicitly import from here.
22
22
  *
23
23
  * Core still carries two fields that make the bootstrap flow work —
24
- * `SubscribePayload.bootstrap?: string` and `AckPayload.renderToken?:
24
+ * `SubscribePayload.wsToken?: string` and `AckPayload.sessionToken?:
25
25
  * string`. Those are deliberately framed as **general transport bootstrap
26
26
  * credentials** (opaque strings), not MCP-Apps-specific. Any future
27
27
  * bootstrap mechanism (short-code auto-login, signed-URL bootstrap, etc.)
28
28
  * reuses the same slots.
29
29
  */
30
+ import { appThemeSchema } from '../schemas/app-theme.js';
31
+ import { isRecord } from '../validation/is-record.js';
30
32
  /**
31
33
  * MCP capability name ggui servers advertise in their MCP `initialize`
32
34
  * response capabilities when they implement the MCP Apps outbound path.
@@ -92,14 +94,14 @@ export function deriveContextName(slotKey) {
92
94
  // ```jsonc
93
95
  // "_meta": {
94
96
  // "ai.ggui/render": {
95
- // renderId, appId, runtimeUrl,
97
+ // sessionId, appId, runtimeUrl,
96
98
  // wsUrl?, wsToken?, expiresAt?,
97
99
  // pollingUrl?,
98
- // themeId?, themeMode?,
100
+ // themeId?, themeMode?, theme?,
99
101
  // gadgets?, publicEnv?, streamWebSocketLocalTools?,
100
- // appCallableTools?, permissionsPolicy?,
102
+ // permissionsPolicy?,
101
103
  // lastSequence?,
102
- // propsJson?, actionNextSteps?, contextSlots?,
104
+ // propsJson?, contextSlots?,
103
105
  // contractHash?, validatorsUrl?,
104
106
  // codeUrl?, codeHash?, kind?
105
107
  // }
@@ -128,7 +130,7 @@ export const MCP_APP_AI_GGUI_RENDER_META_KEY = 'ai.ggui/render';
128
130
  * Read the `ai.ggui/render` slice off a parsed JSON-RPC `_meta` object.
129
131
  *
130
132
  * Structural validation only. Missing key returns `{ok: true, meta: undefined}`
131
- * — not a failure. Required-fields gate (renderId / appId / runtimeUrl)
133
+ * — not a failure. Required-fields gate (sessionId / appId / runtimeUrl)
132
134
  * fires only when the key is present. Field-level optional-field
133
135
  * defensive parsing (e.g. context-slot schema narrowing, expiresAt date
134
136
  * parse) lives downstream in the iframe-runtime's `validateMeta`.
@@ -136,21 +138,20 @@ export const MCP_APP_AI_GGUI_RENDER_META_KEY = 'ai.ggui/render';
136
138
  * @public
137
139
  */
138
140
  export function parseMcpAppAiGguiRenderMeta(meta) {
139
- if (meta === null || typeof meta !== 'object') {
141
+ if (!isRecord(meta)) {
140
142
  return { ok: true };
141
143
  }
142
- const m = meta;
143
- const raw = m[MCP_APP_AI_GGUI_RENDER_META_KEY];
144
+ const raw = meta[MCP_APP_AI_GGUI_RENDER_META_KEY];
144
145
  if (raw === undefined) {
145
146
  return { ok: true };
146
147
  }
147
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
148
+ if (!isRecord(raw)) {
148
149
  return { ok: false, reason: 'MALFORMED_RENDER' };
149
150
  }
150
151
  const s = raw;
151
152
  // Identity — required when slice is present.
152
- if (typeof s.renderId !== 'string' ||
153
- s.renderId.length === 0 ||
153
+ if (typeof s.sessionId !== 'string' ||
154
+ s.sessionId.length === 0 ||
154
155
  typeof s.appId !== 'string' ||
155
156
  s.appId.length === 0 ||
156
157
  typeof s.runtimeUrl !== 'string' ||
@@ -200,8 +201,14 @@ export function parseMcpAppAiGguiRenderMeta(meta) {
200
201
  cHash.length > 0 &&
201
202
  typeof vUrl === 'string' &&
202
203
  vUrl.length > 0;
204
+ // Theme overlay — validated via the complete `appThemeSchema` so the
205
+ // parsed value is typed `AppTheme` without an unguarded cast. A
206
+ // malformed/absent overlay yields `undefined` and is dropped from the
207
+ // slice (tolerant degrade, consistent with the other optional fields).
208
+ const themeParse = s.theme !== undefined ? appThemeSchema.safeParse(s.theme) : undefined;
209
+ const parsedTheme = themeParse?.success === true ? themeParse.data : undefined;
203
210
  const slice = {
204
- renderId: s.renderId,
211
+ sessionId: s.sessionId,
205
212
  appId: s.appId,
206
213
  runtimeUrl: s.runtimeUrl,
207
214
  ...(hasW && hasT ? { wsUrl: aw, wsToken: at } : {}),
@@ -211,6 +218,12 @@ export function parseMcpAppAiGguiRenderMeta(meta) {
211
218
  ...(s.themeMode !== undefined
212
219
  ? { themeMode: s.themeMode }
213
220
  : {}),
221
+ // `theme` is the only structured optional field on the slice, so
222
+ // it gets the schema as its parser (the rest are scalar/array
223
+ // casts). `appThemeSchema` is the complete validator — a malformed
224
+ // overlay degrades to "no overlay" rather than failing the whole
225
+ // slice, matching the tolerant posture of the other optional fields.
226
+ ...(parsedTheme !== undefined ? { theme: parsedTheme } : {}),
214
227
  ...(s.gadgets !== undefined
215
228
  ? { gadgets: s.gadgets }
216
229
  : {}),
@@ -222,19 +235,11 @@ export function parseMcpAppAiGguiRenderMeta(meta) {
222
235
  streamWebSocketLocalTools: s.streamWebSocketLocalTools,
223
236
  }
224
237
  : {}),
225
- ...(s.appCallableTools !== undefined
226
- ? { appCallableTools: s.appCallableTools }
227
- : {}),
228
238
  ...(s.permissionsPolicy !== undefined
229
239
  ? { permissionsPolicy: s.permissionsPolicy }
230
240
  : {}),
231
241
  ...(ls !== undefined ? { lastSequence: ls } : {}),
232
242
  ...(s.propsJson !== undefined ? { propsJson: s.propsJson } : {}),
233
- ...(s.actionNextSteps !== undefined
234
- ? {
235
- actionNextSteps: s.actionNextSteps,
236
- }
237
- : {}),
238
243
  ...(s.contextSlots !== undefined
239
244
  ? {
240
245
  contextSlots: s.contextSlots,
@@ -295,15 +300,14 @@ export const MCP_APP_AI_GGUI_HOST_SESSION_META_KEY = 'ai.ggui/host-session';
295
300
  * @public
296
301
  */
297
302
  export function parseMcpAppAiGguiHostSessionMeta(meta) {
298
- if (meta === null || typeof meta !== 'object') {
303
+ if (!isRecord(meta)) {
299
304
  return { ok: true };
300
305
  }
301
- const m = meta;
302
- const raw = m[MCP_APP_AI_GGUI_HOST_SESSION_META_KEY];
306
+ const raw = meta[MCP_APP_AI_GGUI_HOST_SESSION_META_KEY];
303
307
  if (raw === undefined) {
304
308
  return { ok: true };
305
309
  }
306
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
310
+ if (!isRecord(raw)) {
307
311
  return { ok: false, reason: 'MALFORMED_HOST_SESSION' };
308
312
  }
309
313
  const r = raw;
@@ -322,41 +326,44 @@ export function parseMcpAppAiGguiHostSessionMeta(meta) {
322
326
  };
323
327
  }
324
328
  /**
325
- * Type guard: narrows a `Render` (or unknown) to {@link McpAppsRender}.
329
+ * Type guard: narrows a `GguiSession` (or unknown) to {@link McpAppsGguiSession}.
326
330
  * Uses the discriminator.
327
331
  */
328
- export function isMcpAppsRender(entry) {
332
+ export function isMcpAppsGguiSession(entry) {
329
333
  return (entry !== null &&
330
334
  typeof entry === 'object' &&
331
335
  entry.type === 'mcpApps');
332
336
  }
333
337
  /**
334
- * Structural validator for an `McpAppsRender` — not a Zod schema
338
+ * Structural validator for an `McpAppsGguiSession` — not a Zod schema
335
339
  * so we don't force a Zod dependency here. Returns null on failure
336
340
  * (caller maps to an appropriate error code). Required when accepting
337
341
  * one over the wire from an agent: the discriminator alone isn't
338
342
  * enough.
339
343
  */
340
- export function validateMcpAppsRender(input) {
341
- if (input === null || typeof input !== 'object')
342
- return null;
343
- const item = input;
344
- if (item.type !== 'mcpApps')
345
- return null;
346
- if (typeof item.id !== 'string' || item.id.length === 0)
347
- return null;
348
- if (typeof item.createdAt !== 'string')
349
- return null;
350
- const source = item.source;
351
- if (!source || typeof source !== 'object')
352
- return null;
344
+ export function validateMcpAppsGguiSession(input) {
345
+ return hasMcpAppsGguiSessionShape(input) ? input : null;
346
+ }
347
+ /** Structural predicate behind {@link validateMcpAppsGguiSession}. */
348
+ function hasMcpAppsGguiSessionShape(input) {
349
+ if (!isRecord(input))
350
+ return false;
351
+ if (input.type !== 'mcpApps')
352
+ return false;
353
+ if (typeof input.id !== 'string' || input.id.length === 0)
354
+ return false;
355
+ if (typeof input.createdAt !== 'string')
356
+ return false;
357
+ const source = input.source;
358
+ if (!isRecord(source))
359
+ return false;
353
360
  if (typeof source.connectorId !== 'string' || source.connectorId.length === 0)
354
- return null;
361
+ return false;
355
362
  if (typeof source.toolName !== 'string' || source.toolName.length === 0)
356
- return null;
363
+ return false;
357
364
  if (typeof source.resourceUri !== 'string' || !source.resourceUri.startsWith('ui://'))
358
- return null;
359
- return input;
365
+ return false;
366
+ return true;
360
367
  }
361
368
  /**
362
369
  * The closed set of valid lifecycle states. Exposed as a `readonly`
@@ -380,7 +387,7 @@ export const MCP_APP_LIFECYCLE_STATES = [
380
387
  * - Outer envelope is an object with `type === 'ggui:lifecycle'`.
381
388
  * - `event` is an object with `state` matching {@link
382
389
  * MCP_APP_LIFECYCLE_STATES}.
383
- * - If `renderId` is present, it is a non-empty string.
390
+ * - If `sessionId` is present, it is a non-empty string.
384
391
  * - If `error` is present, it is an object with string `code` +
385
392
  * `message`.
386
393
  *
@@ -400,8 +407,8 @@ export function isMcpAppLifecycleMessage(message) {
400
407
  if (!MCP_APP_LIFECYCLE_STATES.includes(e.state)) {
401
408
  return false;
402
409
  }
403
- if (e.renderId !== undefined) {
404
- if (typeof e.renderId !== 'string' || e.renderId.length === 0) {
410
+ if (e.sessionId !== undefined) {
411
+ if (typeof e.sessionId !== 'string' || e.sessionId.length === 0) {
405
412
  return false;
406
413
  }
407
414
  }
@@ -415,6 +422,43 @@ export function isMcpAppLifecycleMessage(message) {
415
422
  }
416
423
  return true;
417
424
  }
425
+ // =============================================================================
426
+ // Renderer → host postMessage envelope vocabulary
427
+ //
428
+ // The renderer (inside the MCP Apps iframe) speaks to its parent via a
429
+ // small closed family of `{type: 'ggui:…'}` postMessage envelopes. The
430
+ // DISCRIMINATOR vocabulary is protocol-owned and lives HERE, in one
431
+ // place — hosts on every platform (web iframe hosts, React Native
432
+ // WebView hosts) and the renderer itself import these constants rather
433
+ // than re-encoding the strings, so a tag rename is a single-point
434
+ // change and recognizer drift (a host classifying a tag no renderer
435
+ // emits) is structurally impossible.
436
+ //
437
+ // Payload OWNERSHIP follows the vocabulary's semantics:
438
+ // - `ggui:lifecycle` — protocol-owned end to end (see
439
+ // {@link McpAppLifecycleMessage} above): the payload is a wire
440
+ // contract with host obligations.
441
+ // - `ggui:renderer-ready` / `ggui:bootstrap-failed` — envelope shape
442
+ // is protocol-owned ({@link McpAppRendererReadyMessage} /
443
+ // {@link McpAppBootstrapFailedMessage}); the renderer narrows
444
+ // `reason` to its own closed reason union at the emission site.
445
+ // - `ggui:observe` — only the TAG is protocol-owned. The event union
446
+ // it carries is renderer-internal telemetry vocabulary
447
+ // (`ObservabilityEvent` in the renderer package); hosts treat it
448
+ // as extensibly-closed.
449
+ // =============================================================================
450
+ /** Envelope tag: renderer alive + bundle evaluated (pre-`ui/initialize`). */
451
+ export const MCP_APP_RENDERER_READY_TYPE = 'ggui:renderer-ready';
452
+ /** Envelope tag: a boot-path failure (parse / initialize / handshake). */
453
+ export const MCP_APP_BOOTSTRAP_FAILED_TYPE = 'ggui:bootstrap-failed';
454
+ /** Envelope tag: renderer-internal observability event (telemetry). */
455
+ export const MCP_APP_OBSERVE_TYPE = 'ggui:observe';
456
+ /**
457
+ * Envelope tag: mount-lifecycle transition. Constant twin of the
458
+ * literal on {@link McpAppLifecycleMessage} — the annotation ties the
459
+ * two so they cannot drift.
460
+ */
461
+ export const MCP_APP_LIFECYCLE_TYPE = 'ggui:lifecycle';
418
462
  /**
419
463
  * The three canonical gesture kinds — useful for exhaustiveness checks
420
464
  * in `switch (kind) { ... }` blocks. Frozen so consumers can safely use
@@ -438,12 +482,12 @@ export const SUBMIT_ACTION_KINDS = [
438
482
  * MUST validate shape before consuming.
439
483
  */
440
484
  export function isGguiSubmitActionInput(value) {
441
- if (value === null || typeof value !== 'object')
485
+ if (!isRecord(value))
442
486
  return false;
443
487
  const v = value;
444
488
  if (typeof v.kind !== 'string' || v.kind.length === 0)
445
489
  return false;
446
- if (typeof v.renderId !== 'string' || v.renderId.length === 0)
490
+ if (typeof v.sessionId !== 'string' || v.sessionId.length === 0)
447
491
  return false;
448
492
  if (typeof v.appId !== 'string' || v.appId.length === 0)
449
493
  return false;
@@ -451,7 +495,9 @@ export function isGguiSubmitActionInput(value) {
451
495
  return false;
452
496
  if (typeof v.firedAt !== 'string' || v.firedAt.length === 0)
453
497
  return false;
454
- if (v.payload === null || typeof v.payload !== 'object')
498
+ // Payload-object presence is the invariant: a JSON OBJECT, never an
499
+ // array — array payloads reject even for unknown extension kinds.
500
+ if (!isRecord(v.payload))
455
501
  return false;
456
502
  // Per-kind payload narrowing for the closed primary set. Unknown
457
503
  // kinds pass through with whatever payload-object the caller supplied
@@ -39,7 +39,7 @@
39
39
  * @example
40
40
  * ```ts
41
41
  * import { query } from '@anthropic-ai/claude-agent-sdk';
42
- * import { GGUI_AGENT_SYSTEM_PROMPT } from '@ggui-ai/protocol/recommended-prompts';
42
+ * import { GGUI_AGENT_SYSTEM_PROMPT } from '@ggui-ai/protocol';
43
43
  *
44
44
  * query({
45
45
  * prompt: userInput,
@@ -39,7 +39,7 @@
39
39
  * @example
40
40
  * ```ts
41
41
  * import { query } from '@anthropic-ai/claude-agent-sdk';
42
- * import { GGUI_AGENT_SYSTEM_PROMPT } from '@ggui-ai/protocol/recommended-prompts';
42
+ * import { GGUI_AGENT_SYSTEM_PROMPT } from '@ggui-ai/protocol';
43
43
  *
44
44
  * query({
45
45
  * prompt: userInput,
@@ -6,4 +6,7 @@ import type { DataContract } from '../types/data-contract.js';
6
6
  * Node versions (sha256 + utf-8 encoding are well-specified).
7
7
  */
8
8
  export declare function blueprintKey(contract: DataContract | undefined): string;
9
+ export { variantKey } from './variant-key.js';
10
+ export { toPortableBlueprint, fromPortableBlueprint, PORTABLE_BLUEPRINT_SCHEMA_VERSION, PORTABLE_BLUEPRINT_V1_REJECTION, type PortableBlueprintSource, type PortableBlueprintImportResult, } from './portable-blueprint.js';
11
+ export * from './blueprint-stamp.js';
9
12
  //# sourceMappingURL=blueprint-key.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"blueprint-key.d.ts","sourceRoot":"","sources":["../../src/registry/blueprint-key.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAG9D;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,YAAY,GAAG,SAAS,GAAG,MAAM,CAGvE"}
1
+ {"version":3,"file":"blueprint-key.d.ts","sourceRoot":"","sources":["../../src/registry/blueprint-key.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAG9D;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,YAAY,GAAG,SAAS,GAAG,MAAM,CAGvE;AAMD,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAE9C,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,iCAAiC,EACjC,+BAA+B,EAC/B,KAAK,uBAAuB,EAC5B,KAAK,6BAA6B,GACnC,MAAM,yBAAyB,CAAC;AAIjC,cAAc,sBAAsB,CAAC"}
@@ -26,3 +26,12 @@ export function blueprintKey(contract) {
26
26
  const canonical = canonicalizeContracts(contract);
27
27
  return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
28
28
  }
29
+ // The `@ggui-ai/protocol/blueprint-key` subpath maps to this single
30
+ // file (NOT a barrel), so the variant axis of the reuse key must be
31
+ // re-exported here to be importable at that subpath alongside
32
+ // `blueprintKey`. See `variant-key.ts` for the implementation.
33
+ export { variantKey } from './variant-key.js';
34
+ export { toPortableBlueprint, fromPortableBlueprint, PORTABLE_BLUEPRINT_SCHEMA_VERSION, PORTABLE_BLUEPRINT_V1_REJECTION, } from './portable-blueprint.js';
35
+ // `computeToolCatalogHash` pulls `node:crypto`, so it lives behind this
36
+ // server-only subpath alongside `blueprintKey` rather than the root barrel.
37
+ export * from './blueprint-stamp.js';
@@ -0,0 +1,12 @@
1
+ /** Structural shape of a tool-identity catalog (handlers' ToolIdentityCatalog is assignable to this). */
2
+ export type ToolCatalogShape = Record<string, {
3
+ readonly name: string;
4
+ readonly version?: string;
5
+ }>;
6
+ /**
7
+ * Deterministic 16-char hex hash of a tool-identity catalog. Key order is
8
+ * normalized so the hash is stable across deployments that build the catalog
9
+ * in different orders.
10
+ */
11
+ export declare function computeToolCatalogHash(catalog: ToolCatalogShape): string;
12
+ //# sourceMappingURL=blueprint-stamp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blueprint-stamp.d.ts","sourceRoot":"","sources":["../../src/registry/blueprint-stamp.ts"],"names":[],"mappings":"AAEA,yGAAyG;AACzG,MAAM,MAAM,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEpG;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,gBAAgB,GAAG,MAAM,CAKxE"}
@@ -0,0 +1,12 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Deterministic 16-char hex hash of a tool-identity catalog. Key order is
4
+ * normalized so the hash is stable across deployments that build the catalog
5
+ * in different orders.
6
+ */
7
+ export function computeToolCatalogHash(catalog) {
8
+ const normalized = Object.keys(catalog)
9
+ .sort()
10
+ .map((k) => [k, catalog[k].name, catalog[k].version ?? null]);
11
+ return createHash("sha256").update(JSON.stringify(normalized)).digest("hex").slice(0, 16);
12
+ }
@@ -1,4 +1,5 @@
1
1
  import type { DataContract } from '../types/data-contract.js';
2
+ import type { BlueprintVariance } from '../types/blueprint.js';
2
3
  type JsonValue = string | number | boolean | null | JsonValue[] | {
3
4
  [key: string]: JsonValue;
4
5
  };
@@ -15,12 +16,19 @@ type JsonValue = string | number | boolean | null | JsonValue[] | {
15
16
  *
16
17
  * Exported for tests / debugging — production callers should use
17
18
  * `canonicalizeContracts()`.
19
+ *
20
+ * `stripProse` (default `true`) controls the `description`/`usage`
21
+ * STRIPPED_KEYS strip. The contract pipeline strips (prose is
22
+ * informational); the variance pipeline ({@link canonicalizeVariance})
23
+ * passes `false` because variance prose is load-bearing signal.
18
24
  */
19
- export declare function canonicalizeValue(value: unknown): JsonValue | undefined;
25
+ export declare function canonicalizeValue(value: unknown, stripProse?: boolean): JsonValue | undefined;
20
26
  /**
21
27
  * Produce the canonical bytes for a `DataContract` value, suitable
22
28
  * for hashing or external content-address lookups. Stable across
23
- * paraphrase, key order, whitespace, and description-only edits.
29
+ * paraphrase, key order, whitespace, description-only edits, and
30
+ * `serverInfo.version` (server-version metadata; `serverInfo.name` is
31
+ * identity and is preserved).
24
32
  *
25
33
  * Empty / undefined / `{}` all collapse to the same canonical bytes,
26
34
  * which produces a stable `blueprintKey` for the "no-contract" case.
@@ -31,5 +39,26 @@ export declare function canonicalizeValue(value: unknown): JsonValue | undefined
31
39
  * implementations using any JCS library produce the same bytes.
32
40
  */
33
41
  export declare function canonicalizeContracts(contract: DataContract | undefined): string;
42
+ /**
43
+ * Canonical bytes for a {@link BlueprintVariance} block — the input to
44
+ * `variantKey()`. Same JCS + NFC pipeline as {@link canonicalizeContracts}
45
+ * with two DELIBERATE divergences from the contract pipeline:
46
+ *
47
+ * 1. **No `description`/`usage` strip.** For a contract, prose is
48
+ * informational and stripped so a doc tweak doesn't invalidate a
49
+ * cache key. For variance, the prose IS the signal: `seedPrompt`
50
+ * and any `context` value steer the generated `componentCode`, so
51
+ * stripping them would collapse genuinely distinct variants onto
52
+ * one key (false reuse). This is the inverse of the contract rule.
53
+ * 2. **Self-normalizing (D9).** Empty-string fields, empty objects,
54
+ * and empty arrays are elided internally so `undefined`, `{}`,
55
+ * `{persona:''}`, and an all-empty block all collapse to the same
56
+ * "default variant" canonical form. Callers never pre-normalize —
57
+ * the accept path (verbatim `blueprintMeta.variance`) and the
58
+ * override path produce identical keys for equivalent variance.
59
+ *
60
+ * Pure function — no I/O, no globals.
61
+ */
62
+ export declare function canonicalizeVariance(variance: BlueprintVariance | undefined): string;
34
63
  export {};
35
64
  //# sourceMappingURL=canonicalize-contract.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"canonicalize-contract.d.ts","sourceRoot":"","sources":["../../src/registry/canonicalize-contract.ts"],"names":[],"mappings":"AAyEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAW9D,KAAK,SAAS,GACV,MAAM,GACN,MAAM,GACN,OAAO,GACP,IAAI,GACJ,SAAS,EAAE,GACX;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC;AAEjC;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,SAAS,CA4CvE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,YAAY,GAAG,SAAS,GAAG,MAAM,CAOhF"}
1
+ {"version":3,"file":"canonicalize-contract.d.ts","sourceRoot":"","sources":["../../src/registry/canonicalize-contract.ts"],"names":[],"mappings":"AAqFA,OAAO,KAAK,EAAkB,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAC9E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAY/D,KAAK,SAAS,GACV,MAAM,GACN,MAAM,GACN,OAAO,GACP,IAAI,GACJ,SAAS,EAAE,GACX;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC;AAEjC;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,OAAO,EACd,UAAU,UAAO,GAChB,SAAS,GAAG,SAAS,CAgDvB;AA+CD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,YAAY,GAAG,SAAS,GAAG,MAAM,CAOhF;AA6BD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,iBAAiB,GAAG,SAAS,GACtC,MAAM,CAQR"}
@@ -34,7 +34,7 @@
34
34
  * an agent's intent-prose override on a gadget export-use entry (the
35
35
  * `description?` / `usage?` fields of each
36
36
  * `clientCapabilities.gadgets[<package>][<exportName>]`, agent-authored
37
- * at push time) does not pollute the cache key.
37
+ * at render time) does not pollute the cache key.
38
38
  *
39
39
  * The strip is keyed on `description`/`usage` as STRING-VALUED fields,
40
40
  * never as map keys. A gadget package, an `agentCapabilities.tools`
@@ -52,10 +52,22 @@
52
52
  * - The four typed specs: `propsSpec`, `actionSpec`, `contextSpec`,
53
53
  * `streamSpec` (including `streamSpec[*].source.tool` cross-refs).
54
54
  * - The two catalogs: `agentCapabilities.tools[*]` (key names + their
55
- * `inputSchema`/`outputSchema`) and `clientCapabilities.gadgets`
56
- * (package-keyed — npm package names as outer keys, export names as
57
- * the inner `exports` map keys; no `version`, no transport metadata
58
- * on the wire).
55
+ * `toolInfo.inputSchema`/`toolInfo.outputSchema`) and
56
+ * `clientCapabilities.gadgets` (package-keyed — npm package names as
57
+ * outer keys, export names as the inner `exports` map keys; no
58
+ * `version`, no transport metadata on the wire).
59
+ *
60
+ * # Domain rule: `agentCapabilities.tools[*].serverInfo.version` is stripped
61
+ *
62
+ * `serverInfo` ({name, version?}) records the owning MCP server identity.
63
+ * `(serverInfo.name, toolName)` is the canonical cross-framework identity and
64
+ * stays in the hash — it disambiguates two servers that expose a byte-identical
65
+ * bare tool name (Todoist's `todo_add` vs Google-Tasks' `todo_add` must NOT
66
+ * reuse one another's UI). `serverInfo.version` is METADATA, not identity — a
67
+ * server version bump MUST NOT invalidate a registered blueprint — so ONLY the
68
+ * `version` field is removed (structurally, context-scoped to the tool-catalog
69
+ * recursion) BEFORE the JCS pass. `toolInfo.inputSchema` / `toolInfo.outputSchema`
70
+ * stay — they ARE identity (they shape the data).
59
71
  * - Inner JSON Schema fields under every `schema:` wrapper (`type`,
60
72
  * `enum`, `properties`, `required`, `items`, `additionalProperties`,
61
73
  * `nullable`, `format`, …).
@@ -71,6 +83,7 @@
71
83
  * Pure function — no I/O, no globals.
72
84
  */
73
85
  import canonicalize from 'canonicalize';
86
+ import { isRecord } from '../validation/is-record.js';
74
87
  /**
75
88
  * Informational-prose field names removed from the canonical form —
76
89
  * but ONLY where they are string-valued (see `canonicalizeValue`); a
@@ -92,8 +105,13 @@ const STRIPPED_KEYS = new Set(['description', 'usage']);
92
105
  *
93
106
  * Exported for tests / debugging — production callers should use
94
107
  * `canonicalizeContracts()`.
108
+ *
109
+ * `stripProse` (default `true`) controls the `description`/`usage`
110
+ * STRIPPED_KEYS strip. The contract pipeline strips (prose is
111
+ * informational); the variance pipeline ({@link canonicalizeVariance})
112
+ * passes `false` because variance prose is load-bearing signal.
95
113
  */
96
- export function canonicalizeValue(value) {
114
+ export function canonicalizeValue(value, stripProse = true) {
97
115
  if (value === undefined)
98
116
  return undefined;
99
117
  if (value === null)
@@ -107,13 +125,13 @@ export function canonicalizeValue(value) {
107
125
  if (Array.isArray(value)) {
108
126
  const out = [];
109
127
  for (const item of value) {
110
- const c = canonicalizeValue(item);
128
+ const c = canonicalizeValue(item, stripProse);
111
129
  if (c !== undefined)
112
130
  out.push(c);
113
131
  }
114
132
  return out;
115
133
  }
116
- if (t === 'object') {
134
+ if (isRecord(value)) {
117
135
  const obj = value;
118
136
  // NFC-normalize keys before sort so two contracts with the same
119
137
  // key in different normalization forms (precomposed "café" vs
@@ -128,13 +146,14 @@ export function canonicalizeValue(value) {
128
146
  // literally `description`/`usage` as a MAP key (a gadget package,
129
147
  // an `agentCapabilities.tools` entry, a spec slot) maps to an
130
148
  // object/array and MUST survive: stripping it collapses distinct
131
- // contracts onto one blueprint cache key.
132
- .filter((k) => !(STRIPPED_KEYS.has(k) && typeof obj[k] === 'string'))
149
+ // contracts onto one blueprint cache key. Skipped entirely when
150
+ // `stripProse` is false (the variance pipeline).
151
+ .filter((k) => !(stripProse && STRIPPED_KEYS.has(k) && typeof obj[k] === 'string'))
133
152
  .map((k) => ({ source: k, normalized: k.normalize('NFC') }))
134
153
  .sort((a, b) => (a.normalized < b.normalized ? -1 : a.normalized > b.normalized ? 1 : 0));
135
154
  const out = {};
136
155
  for (const { source, normalized } of entries) {
137
- const c = canonicalizeValue(obj[source]);
156
+ const c = canonicalizeValue(obj[source], stripProse);
138
157
  if (c !== undefined)
139
158
  out[normalized] = c;
140
159
  }
@@ -143,10 +162,43 @@ export function canonicalizeValue(value) {
143
162
  // Functions, symbols, bigint — not JSON-serializable; treat as absent.
144
163
  return undefined;
145
164
  }
165
+ /**
166
+ * Structurally reduce `agentCapabilities.tools[*].serverInfo` to its
167
+ * identity-bearing `name` before the generic JCS walk — `serverInfo.name`
168
+ * is load-bearing identity (it disambiguates two servers that expose a
169
+ * byte-identical bare tool name, e.g. Todoist vs Google-Tasks `todo_add`),
170
+ * while `serverInfo.version` is metadata that MUST NOT bust the cache key.
171
+ * Context-scoped to the tool catalog (NOT a global value-guard) so a spec
172
+ * slot / gadget package / tool literally named `serverInfo` elsewhere can
173
+ * never be collateral-stripped. Pure — returns a shallow-cloned projection;
174
+ * the input is never mutated. Returns the input unchanged when there is no
175
+ * `agentCapabilities.tools` to walk.
176
+ */
177
+ function stripToolServerInfo(contract) {
178
+ const tools = contract.agentCapabilities?.tools;
179
+ if (!tools)
180
+ return contract;
181
+ const cleanedTools = {};
182
+ for (const [name, entry] of Object.entries(tools)) {
183
+ if (entry.serverInfo === undefined) {
184
+ cleanedTools[name] = entry;
185
+ continue;
186
+ }
187
+ // Keep serverInfo.name (identity); drop serverInfo.version (metadata).
188
+ const { version: _version, ...nameOnly } = entry.serverInfo;
189
+ cleanedTools[name] = { ...entry, serverInfo: nameOnly };
190
+ }
191
+ return {
192
+ ...contract,
193
+ agentCapabilities: { ...contract.agentCapabilities, tools: cleanedTools },
194
+ };
195
+ }
146
196
  /**
147
197
  * Produce the canonical bytes for a `DataContract` value, suitable
148
198
  * for hashing or external content-address lookups. Stable across
149
- * paraphrase, key order, whitespace, and description-only edits.
199
+ * paraphrase, key order, whitespace, description-only edits, and
200
+ * `serverInfo.version` (server-version metadata; `serverInfo.name` is
201
+ * identity and is preserved).
150
202
  *
151
203
  * Empty / undefined / `{}` all collapse to the same canonical bytes,
152
204
  * which produces a stable `blueprintKey` for the "no-contract" case.
@@ -157,10 +209,70 @@ export function canonicalizeValue(value) {
157
209
  * implementations using any JCS library produce the same bytes.
158
210
  */
159
211
  export function canonicalizeContracts(contract) {
160
- const stripped = canonicalizeValue(contract ?? {});
212
+ const stripped = canonicalizeValue(stripToolServerInfo(contract ?? {}));
161
213
  // `canonicalize` may return undefined if every field was stripped;
162
214
  // fall back to the empty-object canonical form so the hash function
163
215
  // always receives a stable string.
164
216
  const result = canonicalize(stripped);
165
217
  return result ?? '{}';
166
218
  }
219
+ /**
220
+ * Recursively elide "absent-equivalent" fields so semantically-empty
221
+ * variance collapses to the empty-object canonical form. Empty strings
222
+ * (`''`), empty objects (`{}`), empty arrays, `null`, and `undefined`
223
+ * are all dropped; a primitive that survives is kept verbatim. This is
224
+ * the D9 self-normalization step — callers never pre-normalize.
225
+ */
226
+ function elideEmpty(value) {
227
+ if (value === undefined || value === null)
228
+ return undefined;
229
+ if (typeof value === 'string')
230
+ return value === '' ? undefined : value;
231
+ if (typeof value === 'number' || typeof value === 'boolean')
232
+ return value;
233
+ if (Array.isArray(value)) {
234
+ const out = [];
235
+ for (const item of value) {
236
+ const e = elideEmpty(item);
237
+ if (e !== undefined)
238
+ out.push(e);
239
+ }
240
+ return out.length === 0 ? undefined : out;
241
+ }
242
+ const out = {};
243
+ for (const key of Object.keys(value)) {
244
+ const e = elideEmpty(value[key]);
245
+ if (e !== undefined)
246
+ out[key] = e;
247
+ }
248
+ return Object.keys(out).length === 0 ? undefined : out;
249
+ }
250
+ /**
251
+ * Canonical bytes for a {@link BlueprintVariance} block — the input to
252
+ * `variantKey()`. Same JCS + NFC pipeline as {@link canonicalizeContracts}
253
+ * with two DELIBERATE divergences from the contract pipeline:
254
+ *
255
+ * 1. **No `description`/`usage` strip.** For a contract, prose is
256
+ * informational and stripped so a doc tweak doesn't invalidate a
257
+ * cache key. For variance, the prose IS the signal: `seedPrompt`
258
+ * and any `context` value steer the generated `componentCode`, so
259
+ * stripping them would collapse genuinely distinct variants onto
260
+ * one key (false reuse). This is the inverse of the contract rule.
261
+ * 2. **Self-normalizing (D9).** Empty-string fields, empty objects,
262
+ * and empty arrays are elided internally so `undefined`, `{}`,
263
+ * `{persona:''}`, and an all-empty block all collapse to the same
264
+ * "default variant" canonical form. Callers never pre-normalize —
265
+ * the accept path (verbatim `blueprintMeta.variance`) and the
266
+ * override path produce identical keys for equivalent variance.
267
+ *
268
+ * Pure function — no I/O, no globals.
269
+ */
270
+ export function canonicalizeVariance(variance) {
271
+ // NFC-normalize + sort keys (canonicalizeValue), but withOUT the
272
+ // STRIPPED_KEYS prose strip (`stripProse: false`) — variance prose
273
+ // is load-bearing signal, the inverse of the contract rule.
274
+ const normalized = canonicalizeValue(variance ?? {}, false);
275
+ const elided = elideEmpty(normalized) ?? {};
276
+ const result = canonicalize(elided);
277
+ return result ?? '{}';
278
+ }