@nxisai/agent-harness 0.4.0 → 0.5.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 (103) hide show
  1. package/README.md +4 -2
  2. package/dist/harness/harness.d.ts.map +1 -1
  3. package/dist/harness/harness.js +31 -9
  4. package/dist/harness/harness.js.map +1 -1
  5. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  6. package/dist/harness/tools/action-tools.js +7 -5
  7. package/dist/harness/tools/action-tools.js.map +1 -1
  8. package/dist/harness/tools/bridge.d.ts.map +1 -1
  9. package/dist/harness/tools/bridge.js +33 -12
  10. package/dist/harness/tools/bridge.js.map +1 -1
  11. package/dist/harness/tools/local-context.d.ts +49 -9
  12. package/dist/harness/tools/local-context.d.ts.map +1 -1
  13. package/dist/harness/tools/local-context.js +377 -22
  14. package/dist/harness/tools/local-context.js.map +1 -1
  15. package/dist/harness/tools/open-page.d.ts +47 -1
  16. package/dist/harness/tools/open-page.d.ts.map +1 -1
  17. package/dist/harness/tools/open-page.js +119 -33
  18. package/dist/harness/tools/open-page.js.map +1 -1
  19. package/dist/harness/tools/retry.d.ts +53 -0
  20. package/dist/harness/tools/retry.d.ts.map +1 -0
  21. package/dist/harness/tools/retry.js +96 -0
  22. package/dist/harness/tools/retry.js.map +1 -0
  23. package/dist/harness/tools/schema.d.ts +18 -0
  24. package/dist/harness/tools/schema.d.ts.map +1 -1
  25. package/dist/harness/tools/schema.js +38 -0
  26. package/dist/harness/tools/schema.js.map +1 -1
  27. package/dist/harness/tools/site-actions.d.ts.map +1 -1
  28. package/dist/harness/tools/site-actions.js +3 -2
  29. package/dist/harness/tools/site-actions.js.map +1 -1
  30. package/dist/harness/tools/site-nav.d.ts +168 -0
  31. package/dist/harness/tools/site-nav.d.ts.map +1 -0
  32. package/dist/harness/tools/site-nav.js +316 -0
  33. package/dist/harness/tools/site-nav.js.map +1 -0
  34. package/dist/harness/tools/site-pages.d.ts +9 -4
  35. package/dist/harness/tools/site-pages.d.ts.map +1 -1
  36. package/dist/harness/tools/site-pages.js +46 -7
  37. package/dist/harness/tools/site-pages.js.map +1 -1
  38. package/dist/harness/tools/source.d.ts +11 -2
  39. package/dist/harness/tools/source.d.ts.map +1 -1
  40. package/dist/harness/tools/source.js +55 -15
  41. package/dist/harness/tools/source.js.map +1 -1
  42. package/dist/harness/tools/webmcp.d.ts +39 -3
  43. package/dist/harness/tools/webmcp.d.ts.map +1 -1
  44. package/dist/harness/tools/webmcp.js +93 -14
  45. package/dist/harness/tools/webmcp.js.map +1 -1
  46. package/dist/index.d.ts +7 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +4 -2
  49. package/dist/index.js.map +1 -1
  50. package/dist/types.d.ts +2 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  53. package/dist/ui/agent-fullscreen.js +13 -2
  54. package/dist/ui/agent-fullscreen.js.map +1 -1
  55. package/dist/ui/tailwind.generated.d.ts +1 -1
  56. package/dist/ui/tailwind.generated.d.ts.map +1 -1
  57. package/dist/ui/tailwind.generated.js +1 -1
  58. package/dist/ui/tailwind.generated.js.map +1 -1
  59. package/package.json +2 -1
  60. package/src/a2ui/cards.ts +177 -0
  61. package/src/a2ui/emit.ts +147 -0
  62. package/src/a2ui/form.ts +117 -0
  63. package/src/a2ui/host.ts +73 -0
  64. package/src/a2ui/index.ts +22 -0
  65. package/src/a2ui/stepper.ts +211 -0
  66. package/src/a2ui/theme.ts +134 -0
  67. package/src/a2ui/to-v09.ts +118 -0
  68. package/src/engine.ts +701 -0
  69. package/src/harness/harness.ts +956 -0
  70. package/src/harness/plan.ts +91 -0
  71. package/src/harness/policy.ts +59 -0
  72. package/src/harness/renderer.ts +208 -0
  73. package/src/harness/router/coordinator.ts +220 -0
  74. package/src/harness/router/rules.ts +65 -0
  75. package/src/harness/slots.ts +117 -0
  76. package/src/harness/steps.ts +156 -0
  77. package/src/harness/tools/action-tools.ts +870 -0
  78. package/src/harness/tools/bridge.ts +393 -0
  79. package/src/harness/tools/client.ts +26 -0
  80. package/src/harness/tools/discovery.ts +55 -0
  81. package/src/harness/tools/local-context.ts +480 -0
  82. package/src/harness/tools/open-page.ts +273 -0
  83. package/src/harness/tools/pending-action.ts +232 -0
  84. package/src/harness/tools/resume.ts +134 -0
  85. package/src/harness/tools/retry.ts +117 -0
  86. package/src/harness/tools/schema.ts +245 -0
  87. package/src/harness/tools/site-actions.ts +356 -0
  88. package/src/harness/tools/site-nav.ts +432 -0
  89. package/src/harness/tools/site-pages.ts +288 -0
  90. package/src/harness/tools/source.ts +353 -0
  91. package/src/harness/tools/webmcp.ts +200 -0
  92. package/src/index.ts +193 -0
  93. package/src/providers/capability.ts +167 -0
  94. package/src/providers/proxy.ts +191 -0
  95. package/src/providers/types.ts +150 -0
  96. package/src/types.ts +258 -0
  97. package/src/ui/agent-fullscreen.ts +3984 -0
  98. package/src/ui/agent.tw.css +49 -0
  99. package/src/ui/config.ts +138 -0
  100. package/src/ui/index.ts +32 -0
  101. package/src/ui/journey.ts +57 -0
  102. package/src/ui/tailwind.generated.ts +2 -0
  103. package/src/webmcp-contract.ts +65 -0
@@ -0,0 +1,211 @@
1
+ /**
2
+ * The two prompt surfaces: asking for missing values, and confirming a write.
3
+ *
4
+ * The stepper never recreates its surface. Steps arrive as component updates
5
+ * against a live surface whose data model already holds every answer, so moving
6
+ * between steps changes the components without touching the data, and nothing
7
+ * already entered is lost.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import type { JsonSchema } from '../types.js';
13
+ import type { A2uiMessage } from './emit.js';
14
+ import type { ClarificationPlan, PlanStep } from '../harness/plan.js';
15
+ import type { Slot } from '../harness/slots.js';
16
+
17
+ const CATALOG = 'https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json';
18
+
19
+ interface Component { id: string; component: string; [prop: string]: unknown }
20
+
21
+ const humanize = (s: string): string =>
22
+ s.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ')
23
+ .replace(/^\w/, c => c.toUpperCase());
24
+
25
+ function labelFor(slot: Slot): string {
26
+ return slot.schema.description || humanize(slot.name);
27
+ }
28
+
29
+ function enumOptions(values: unknown[]): { label: string; value: string }[] {
30
+ return values
31
+ .map(v => String(v))
32
+ // A leading placeholder option is a markup convention rather than a
33
+ // choice, and offering it would allow it to be submitted as an answer.
34
+ .filter(v => !/[…:]\s*$/.test(v))
35
+ .map(v => ({ label: humanize(v), value: v }));
36
+ }
37
+
38
+ /** One input bound to /form/<name>. */
39
+ function inputFor(slot: Slot): Component {
40
+ const id = `f_${slot.name}`;
41
+ const value = { path: `/form/${slot.name}` };
42
+ const label = labelFor(slot);
43
+ const spec: JsonSchema = slot.schema;
44
+
45
+ const values = spec.enum ?? spec.items?.enum;
46
+ if (Array.isArray(values) && values.length) {
47
+ return { id, component: 'ChoicePicker', label, variant: 'mutuallyExclusive',
48
+ options: enumOptions(values), value };
49
+ }
50
+ if (spec.type === 'boolean') return { id, component: 'CheckBox', label, value };
51
+
52
+ const variant =
53
+ spec.type === 'number' || spec.type === 'integer' ? 'number'
54
+ : /description|message|notes|details/.test(slot.name) ? 'longText'
55
+ : 'shortText';
56
+ return { id, component: 'TextField', label, variant, value };
57
+ }
58
+
59
+ function stepComponents(
60
+ step: PlanStep,
61
+ plan: ClarificationPlan,
62
+ toolName: string,
63
+ ): Component[] {
64
+ const fields = step.slots.map(inputFor);
65
+ const isLast = step.index === plan.steps.length - 1;
66
+ const isFirst = step.index === 0;
67
+
68
+ const actionIds = [
69
+ ...(plan.stepped && !isFirst ? ['back'] : []),
70
+ 'cancel',
71
+ isLast ? 'submit' : 'next',
72
+ ];
73
+
74
+ // Same header shape as the review and submitted cards: an uppercase eyebrow
75
+ // then the detail. Uppercased in the string itself because no CSS of
76
+ // ours reaches inside these components.
77
+ //
78
+ // The headline is NOT Manrope. --a2ui-font-family-title applies only to
79
+ // markdown headings, and Text falls back to rendering the raw string unless
80
+ // a MarkdownRenderer is provided through Lit context, so "### Foo" renders as
81
+ // literal hashes. Reaching the display face here needs @a2ui/markdown-it
82
+ // wired into the surface, not a formatting trick.
83
+ const heading: Component[] = [
84
+ { id: 'eyebrow', component: 'Text',
85
+ text: plan.stepped
86
+ ? `STEP ${step.index + 1} OF ${plan.steps.length}`
87
+ : 'ONE MORE DETAIL',
88
+ variant: 'caption' },
89
+ { id: 'headline', component: 'Text', text: step.title, variant: 'body' },
90
+ ];
91
+
92
+ return [
93
+ // Every inline component is a card: the surface is a thing placed in the
94
+ // conversation, and the card edge is what separates it from the message
95
+ // bubbles either side. Card takes one child, so the content column sits
96
+ // inside it rather than being the root itself.
97
+ { id: 'root', component: 'Card', child: 'body' },
98
+ { id: 'body', component: 'Column',
99
+ children: [...heading.map(h => h.id), ...fields.map(f => f.id), 'actions'] },
100
+ ...heading,
101
+ ...fields,
102
+ { id: 'actions', component: 'Row', children: actionIds, justify: 'end' },
103
+
104
+ { id: 'back_label', component: 'Text', text: 'Back' },
105
+ { id: 'back', component: 'Button', child: 'back_label',
106
+ action: { event: { name: 'nxis.back', context: { tool: toolName, step: step.index } } } },
107
+
108
+ { id: 'cancel_label', component: 'Text', text: 'Cancel' },
109
+ { id: 'cancel', component: 'Button', child: 'cancel_label',
110
+ action: { event: { name: 'nxis.cancel', context: { tool: toolName } } } },
111
+
112
+ { id: 'next_label', component: 'Text', text: 'Next' },
113
+ { id: 'next', component: 'Button', child: 'next_label', variant: 'primary',
114
+ action: { event: { name: 'nxis.next', context: { tool: toolName, step: step.index } } } },
115
+
116
+ { id: 'submit_label', component: 'Text', text: 'Submit' },
117
+ { id: 'submit', component: 'Button', child: 'submit_label', variant: 'primary',
118
+ action: { event: { name: 'nxis.submit', context: { tool: toolName } } } },
119
+ ];
120
+ }
121
+
122
+ /** Create the surface, showing the first step. */
123
+ export function emitAsk(
124
+ toolName: string,
125
+ plan: ClarificationPlan,
126
+ known: Record<string, unknown>,
127
+ surfaceId = 'ask',
128
+ ): A2uiMessage[] {
129
+ // Seed every gap so its input has a value to bind to, and carry the known
130
+ // values along: they are submitted with the answers even though no step
131
+ // ever displays them.
132
+ const form: Record<string, unknown> = { ...known };
133
+ for (const step of plan.steps) {
134
+ for (const slot of step.slots) {
135
+ if (form[slot.name] === undefined) {
136
+ form[slot.name] = slot.schema.type === 'boolean' ? false : '';
137
+ }
138
+ }
139
+ }
140
+
141
+ return [{
142
+ version: 'v1.0',
143
+ createSurface: {
144
+ surfaceId, catalogId: CATALOG,
145
+ components: stepComponents(plan.steps[0], plan, toolName),
146
+ dataModel: { form, step: 0 },
147
+ },
148
+ }];
149
+ }
150
+
151
+ /** Move to another step: components only, so answers already typed survive. */
152
+ export function emitStep(
153
+ surfaceId: string,
154
+ toolName: string,
155
+ plan: ClarificationPlan,
156
+ index: number,
157
+ ): A2uiMessage[] {
158
+ return [
159
+ { version: 'v1.0',
160
+ updateComponents: { surfaceId, components: stepComponents(plan.steps[index], plan, toolName) } },
161
+ { version: 'v1.0', updateDataModel: { surfaceId, path: '/step', value: index } },
162
+ ];
163
+ }
164
+
165
+ /**
166
+ * The write confirmation: what is about to be sent, read-only.
167
+ *
168
+ * Not a form: nothing is missing, so there is nothing to fill in,
169
+ * showing inputs would imply otherwise and invite editing that was not asked
170
+ * for. Edit is available, as a button, for when it genuinely was.
171
+ */
172
+ export function emitConfirm(
173
+ toolName: string,
174
+ slots: Slot[],
175
+ surfaceId = 'confirm',
176
+ submitLabel = 'Submit',
177
+ ): A2uiMessage[] {
178
+ const rows = slots.map(s => ({ label: labelFor(s), value: String(s.value ?? '') }));
179
+
180
+ const components: Component[] = [
181
+ // The review is a card for the same reason the inputs are: it is one
182
+ // reviewable object, and its edge is what says "this is what will be sent"
183
+ // rather than "this is more conversation".
184
+ { id: 'root', component: 'Card', child: 'body' },
185
+ { id: 'body', component: 'Column', children: ['eyebrow', 'headline', 'rows', 'actions'] },
186
+ { id: 'eyebrow', component: 'Text', text: 'READY TO SEND', variant: 'caption' },
187
+ { id: 'headline', component: 'Text', text: humanize(toolName), variant: 'body' },
188
+
189
+ { id: 'rows', component: 'List',
190
+ children: { path: '/rows', componentId: 'row_tpl' }, direction: 'vertical' },
191
+ { id: 'row_tpl', component: 'Row', children: ['row_label', 'row_value'] },
192
+ { id: 'row_label', component: 'Text', text: '${label}', variant: 'caption' },
193
+ { id: 'row_value', component: 'Text', text: '${value}' },
194
+
195
+ { id: 'actions', component: 'Row', children: ['edit', 'cancel', 'send'], justify: 'end' },
196
+ { id: 'edit_label', component: 'Text', text: 'Edit' },
197
+ { id: 'edit', component: 'Button', child: 'edit_label',
198
+ action: { event: { name: 'nxis.edit', context: { tool: toolName } } } },
199
+ { id: 'cancel_label', component: 'Text', text: 'Cancel' },
200
+ { id: 'cancel', component: 'Button', child: 'cancel_label',
201
+ action: { event: { name: 'nxis.cancel', context: { tool: toolName } } } },
202
+ { id: 'send_label', component: 'Text', text: submitLabel },
203
+ { id: 'send', component: 'Button', child: 'send_label', variant: 'primary',
204
+ action: { event: { name: 'nxis.submit', context: { tool: toolName } } } },
205
+ ];
206
+
207
+ return [{
208
+ version: 'v1.0',
209
+ createSurface: { surfaceId, catalogId: CATALOG, components, dataModel: { rows } },
210
+ }];
211
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Theme tokens for A2UI surfaces.
3
+ *
4
+ * The Lit renderer styles its components entirely through `--a2ui-*` custom
5
+ * properties. Those inherit across shadow boundaries, so setting them on the
6
+ * element containing `<a2ui-surface>` restyles everything inside it without
7
+ * `::part()`, a forked catalog, or a patched package.
8
+ *
9
+ * Values map onto the tokens the packaged element already declares, so surfaces
10
+ * follow the same palette rather than carrying a second copy of it.
11
+ *
12
+ * The rules encoded here:
13
+ * - sans first: Manrope for the title, Inter for everything readable
14
+ * - mono for labels only: field labels and tags, never body copy
15
+ * - blue for trust and action: the primary button and focus ring
16
+ * - light by default
17
+ */
18
+ export const A2UI_THEME_CSS = `
19
+ .a2ui-scope {
20
+ /* Core roles */
21
+ --a2ui-color-primary: var(--blue);
22
+ --a2ui-color-primary-hover: var(--blue-strong);
23
+ --a2ui-color-primary-dark: var(--blue-strong);
24
+ --a2ui-color-primary-light: var(--blue-tint);
25
+ --a2ui-color-on-primary: #FFFFFF;
26
+ --a2ui-color-secondary: var(--surface);
27
+ --a2ui-color-secondary-hover: var(--surface-2);
28
+ --a2ui-color-on-secondary: var(--ink);
29
+ --a2ui-color-surface: var(--bg);
30
+ --a2ui-color-on-surface: var(--ink);
31
+ --a2ui-color-on-background: var(--ink);
32
+ --a2ui-color-border: var(--border);
33
+ --a2ui-color-input: var(--bg);
34
+ --a2ui-color-on-input: var(--ink);
35
+
36
+ /* Type — Manrope displays, Inter reads (design.md §1) */
37
+ --a2ui-font-family-title: var(--display);
38
+ --a2ui-font-size-xs: 11px; /* eyebrow / label size, matching .dc-row dt */
39
+ --a2ui-font-size-s: 13px;
40
+ --a2ui-font-size-m: 14px;
41
+ --a2ui-font-size-l: 18px; /* markdown ### → .dc-title size */
42
+ --a2ui-font-size-xl: 22px;
43
+ --a2ui-font-size-2xl: 31px;
44
+ --a2ui-line-height-body: 1.65;
45
+ --a2ui-line-height-headings: 1.25;
46
+ --a2ui-text-color-text: var(--ink);
47
+ --a2ui-text-caption-color: var(--ink-3); /* same muted label ink as .dc-row dt */
48
+ --a2ui-text-a-color: var(--blue);
49
+ --a2ui-text-a-font-weight: 600;
50
+
51
+ /* Radii — 14px cards, 10px buttons, 999px pills (design.md §1) */
52
+ --a2ui-border-radius: var(--radius-sm);
53
+ --a2ui-border: 1px solid var(--border);
54
+ --a2ui-border-width: 1px;
55
+
56
+ --a2ui-card-background: var(--bg);
57
+ --a2ui-card-border: 1px solid var(--border);
58
+ --a2ui-card-border-radius: var(--radius);
59
+ --a2ui-card-box-shadow: var(--shadow-sm);
60
+ /* 22px matches .detailcard, so an input card and a review card sitting in
61
+ the same thread share one rhythm rather than three inherited paddings. */
62
+ --a2ui-card-padding: 22px;
63
+ --a2ui-card-margin: 0;
64
+
65
+ /* Buttons: radius 10, weight 600, one primary per view (design.md §4) */
66
+ --a2ui-button-border-radius: var(--radius-sm);
67
+ --a2ui-button-font-weight: 600;
68
+ --a2ui-button-padding: 11px 20px;
69
+ --a2ui-button-border: 1px solid var(--border-strong);
70
+ --a2ui-button-background: var(--bg);
71
+ --a2ui-button-box-shadow: none;
72
+ /* Buttons sit in a Row with its own gap; a margin here would double it. */
73
+ --a2ui-button-margin: 0;
74
+ /* The label is a Text child, so the button's own colour comes from the
75
+ secondary text token — this is what makes a default A2UI button read as
76
+ .btn.ghost rather than as unstyled chrome. */
77
+ --a2ui-color-on-secondary: var(--ink);
78
+ --a2ui-color-secondary-hover: var(--surface-2);
79
+
80
+ /* Inputs */
81
+ --a2ui-textfield-border: 1px solid var(--border);
82
+ --a2ui-textfield-border-radius: var(--radius-sm);
83
+ --a2ui-textfield-padding: 10px 12px;
84
+ --a2ui-textfield-color-border-focus: var(--blue);
85
+ --a2ui-textfield-color-error: var(--red);
86
+ --a2ui-textfield-label-font-size: 11px;
87
+ --a2ui-textfield-label-font-weight: 500;
88
+
89
+ --a2ui-checkbox-border: 1px solid var(--border-strong);
90
+ --a2ui-checkbox-border-radius: 6px;
91
+ --a2ui-checkbox-background: var(--bg);
92
+ --a2ui-checkbox-color-error: var(--red);
93
+ --a2ui-checkbox-size: 18px;
94
+ --a2ui-checkbox-gap: 10px;
95
+ --a2ui-checkbox-label-font-size: 14px;
96
+ --a2ui-checkbox-label-font-weight: 400;
97
+
98
+ /* Tags render as ChoicePicker chips / caption Text — pill radius */
99
+ --a2ui-choicepicker-chip-border-radius: var(--radius-pill);
100
+ --a2ui-choicepicker-chip-padding: 6px 12px;
101
+ --a2ui-choicepicker-label-color: var(--ink-3);
102
+ --a2ui-choicepicker-label-font-size: 11px;
103
+ --a2ui-choicepicker-label-font-weight: 500;
104
+
105
+ /* Rhythm */
106
+ --a2ui-spacing-xs: 4px;
107
+ --a2ui-spacing-s: 8px;
108
+ --a2ui-spacing-m: 12px;
109
+ --a2ui-spacing-l: 18px;
110
+ --a2ui-column-gap: 12px;
111
+ --a2ui-row-gap: 10px;
112
+ --a2ui-list-gap: 12px;
113
+ --a2ui-list-padding: 0;
114
+
115
+ font-family: var(--sans);
116
+ color: var(--ink);
117
+ }
118
+
119
+ /*
120
+ * Mono for labels only (design.md §3.2). The renderer has no token for label
121
+ * *family*, only size and weight, so the eyebrow treatment is applied to the
122
+ * label elements directly. Scoped to .a2ui-scope so it cannot leak.
123
+ */
124
+ .a2ui-scope a2ui-surface::part(label),
125
+ .a2ui-scope label {
126
+ font-family: var(--mono);
127
+ letter-spacing: .14em;
128
+ text-transform: uppercase;
129
+ color: var(--ink-3);
130
+ }
131
+ `;
132
+
133
+ /** Signal Blue — the one theme value A2UI itself carries on createSurface. */
134
+ export const NXIS_PRIMARY = '#2563EB';
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Downgrades A2UI v1.0 messages to v0.9.
3
+ *
4
+ * The emitters target v1.0, which is the documented spec and the one with
5
+ * published JSON Schemas, but available renderers support v0.9 and earlier, so
6
+ * messages are downgraded before rendering.
7
+ *
8
+ * Two mechanical differences:
9
+ *
10
+ * 1. `version` is the string "v0.9", not "v1.0".
11
+ * 2. v0.9's `createSurface` carries only surfaceId/catalogId: it rejects
12
+ * inline `components` and `dataModel` as additional properties. They have
13
+ * to follow as separate `updateComponents` / `updateDataModel` messages for
14
+ * the same surfaceId. So one v1.0 message becomes up to three.
15
+ *
16
+ * No longer needed once a v1.0 renderer is available.
17
+ *
18
+ * @module
19
+ */
20
+
21
+ import type { A2uiMessage } from './emit.js';
22
+ import { NXIS_PRIMARY } from './theme.js';
23
+
24
+ type Msg = Record<string, any>;
25
+
26
+ /**
27
+ * The catalog is versioned alongside the protocol, and the renderer looks it up
28
+ * by exact id, so a v1.0 id against a v0.9 catalog registry throws
29
+ * "Catalog not found" rather than falling back.
30
+ */
31
+ const CATALOG_V1 = 'https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json';
32
+ const CATALOG_V09 = 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json';
33
+
34
+ const downgradeCatalog = (id: string | undefined): string | undefined =>
35
+ id === CATALOG_V1 ? CATALOG_V09 : id;
36
+
37
+ /**
38
+ * v1.0 interpolates with `${expr}` inside a string. v0.9 has no interpolation
39
+ * at all: a dynamic value is a DataBinding object, `{ path }`. A `${title}`
40
+ * left as-is is not an error: it renders as the literal characters, which is
41
+ * how it first showed up on the verification page.
42
+ *
43
+ * Only a string that is *entirely* one expression can convert. "Hi ${name}"
44
+ * has no v0.9 equivalent, so it is left alone and will render literally; the
45
+ * emitters avoid mixed interpolation for that reason.
46
+ */
47
+ const INTERPOLATION = /^\$\{([^}]+)\}$/;
48
+
49
+ function bindStrings(value: unknown): unknown {
50
+ if (typeof value === 'string') {
51
+ const m = INTERPOLATION.exec(value);
52
+ return m ? { path: m[1] } : value;
53
+ }
54
+ if (Array.isArray(value)) return value.map(bindStrings);
55
+ if (value && typeof value === 'object') {
56
+ const out: Msg = {};
57
+ for (const [k, v] of Object.entries(value as Msg)) {
58
+ // Identity and structure keys are never data-bound.
59
+ out[k] = k === 'id' || k === 'component' || k === 'componentId' ? v : bindStrings(v);
60
+ }
61
+ return out;
62
+ }
63
+ return value;
64
+ }
65
+
66
+ /**
67
+ * Downgrade v1.0 messages to v0.9.
68
+ *
69
+ * @param messages - The messages as authored by the emitters.
70
+ * @returns The equivalent v0.9 messages, which may be more numerous because a
71
+ * v1.0 surface creation splits into separate component and data updates.
72
+ */
73
+ export function toV09(messages: A2uiMessage[]): Msg[] {
74
+ const out: Msg[] = [];
75
+
76
+ for (const msg of messages as unknown as Msg[]) {
77
+ if (msg.createSurface) {
78
+ const { surfaceId, catalogId, components, dataModel } = msg.createSurface;
79
+
80
+ // `theme` exists in v0.9 and was dropped in v1.0, so it is set here
81
+ // rather than in the emitters, which must stay v1.0-valid. The CSS
82
+ // custom properties do the real styling; this carries the accent for any
83
+ // renderer that never sees our stylesheet.
84
+ out.push({
85
+ version: 'v0.9',
86
+ createSurface: {
87
+ surfaceId,
88
+ catalogId: downgradeCatalog(catalogId),
89
+ theme: { primaryColor: NXIS_PRIMARY },
90
+ },
91
+ });
92
+
93
+ if (components) {
94
+ out.push({ version: 'v0.9', updateComponents: { surfaceId, components: bindStrings(components) } });
95
+ }
96
+ // v0.9 patches one path at a time, so a whole-model seed becomes one
97
+ // message per top-level key rather than a single root write. The payload
98
+ // key is `value` and the object is additionalProperties:false, so a
99
+ // wrong name here is rejected outright rather than ignored.
100
+ if (dataModel) {
101
+ for (const [key, value] of Object.entries(dataModel)) {
102
+ out.push({ version: 'v0.9', updateDataModel: { surfaceId, path: `/${key}`, value } });
103
+ }
104
+ }
105
+ continue;
106
+ }
107
+
108
+ if (msg.updateDataModel) {
109
+ const { surfaceId, path, value } = msg.updateDataModel;
110
+ out.push({ version: 'v0.9', updateDataModel: { surfaceId, path, value } });
111
+ continue;
112
+ }
113
+
114
+ out.push({ ...msg, version: 'v0.9' });
115
+ }
116
+
117
+ return out;
118
+ }