@morit/cli 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  # Response UI와 Agent Timeline
2
2
 
3
- Response UI는 capability 결과를 AI 메시지 안에서 시각화합니다. 대화 아래의 별도 카드 목록이나
4
- overflow 메뉴 밑에 떨어뜨리지 않고, 해당 AI 메시지에 속한 하나의 response container 안에
5
- 제목·내용·행동을 함께 렌더링합니다.
3
+ Response UI는 capability 결과를 AI 메시지 안에서 시각화합니다. AI는 필요한 A2UI
4
+ Native Tool을 선택하지만 Host는 우선 최종 텍스트 답변을 완료·저장합니다. 컴포넌트는
5
+ 데이터 준비와 검증이 모두 성공한 뒤 해당 AI 메시지 본문 아래에 한 번에 자연스럽게
6
+ 표시됩니다. Tool이 실패해도 답변 완료 상태는 바뀌지 않습니다.
6
7
 
7
8
  ## Response extension
8
9
 
@@ -73,9 +74,11 @@ capability를 호출합니다. 기존 `a2ui` 선언이 없는 Response extension
73
74
 
74
75
  ## AI 선택과 Host 검증
75
76
 
76
- 서버는 모든 Tool 결과에 UI를 일괄 생성하거나 데이터 key로 컴포넌트를 추측하지 않습니다. 최종
77
- 답변 모델이 현재 답변에 실제 도움이 되는 후보의 `candidate_id`와 `component`만 선택합니다. 모델은
78
- 컴포넌트 데이터나 임의 ID를 만들 수 없고, 실제 Tool 결과는 Host가 선택 주입합니다.
77
+ 서버는 모든 Tool 결과에 UI를 일괄 생성하거나 키워드 정규식으로 컴포넌트를 강제하지 않습니다.
78
+ 최종 답변 모델은 통합 Registry Skill의 용도와 입력 Schema를 의미적으로 비교해 Native Tool을
79
+ 선택합니다. provider 컴포넌트에는 위치·통화·검색어만 넘기고, Host가 비동기로 실시간 데이터를
80
+ 조회합니다. generated 컴포넌트에는 답변에 근거한 차트 값만 Schema에 맞게
81
+ 전달합니다. Plugin 결과는 Host가 검증한 후보 데이터만 사용합니다.
79
82
 
80
83
  선택 뒤 Host는 다음을 모두 통과한 항목만 A2UI v0.9 DataPart로 저장하고 해당 AI 메시지에 붙입니다.
81
84
 
@@ -86,23 +89,28 @@ capability를 호출합니다. 기존 `a2ui` 선언이 없는 Response extension
86
89
  - Plugin 컴포넌트의 schema, A2UI version, 설치 상태, Instance 권한과 capability 연결이 유효함
87
90
 
88
91
  빈 데이터, 실패한 Tool, 무관하거나 중복된 결과, 불확실한 선택은 UI를 만들지 않습니다. 선택 모델
89
- 호출이나 렌더링이 실패해도 일반 텍스트 답변은 그대로 완료됩니다. 진행·실패 상태는 Agent Timeline에
90
- 표시하며 의미 없는 loading/failed Response 카드를 미리 만들거나 DB에 남기지 않습니다.
92
+ 호출이나 렌더링이 실패해도 일반 텍스트 답변은 그대로 완료됩니다. 로딩 문구,
93
+ skeleton, card는 렌더링하지 않고 준비가 끝난 컴포넌트만 표시합니다.
91
94
 
92
95
  ## 기본 A2UI Component Catalog
93
96
 
94
97
  | component | 표시 조건 |
95
98
  |---|---|
96
99
  | `weather` | 현재 기온·상태 또는 예보가 하나 이상 유효함 |
100
+ | `world_clock` | 1~8개 위치의 IANA timezone과 현재 시각이 유효함 |
97
101
  | `chart` | label과 수치가 있는 행이 2개 이상임; `bar`, `line`, `pie`, `scatter` |
98
102
  | `image_gallery` | HTTPS 이미지와 원문 URL·출처명이 함께 있음 |
99
103
  | `exchange_rate` | 기준 통화, 상대 통화, 유한한 환율 값이 있음 |
100
104
  | `article_list` | 제목·출처·원문이 있는 서로 다른 기사 2개 이상 |
101
- | `article_card` | 제목·출처·게시 시각·요약·본문 일부·이미지·원문이 모두 있음 |
105
+ | `article_card` | 제목·출처·요약·본문 일부·원문이 있고, 게시 시각·이미지는 제공될 때 표시 |
102
106
  | `file_result` | 실제 Morit item ID 또는 검증 가능한 다운로드 URL이 있음 |
103
107
  | `image_preview` | 실제 image item 또는 검증 가능한 preview URL이 있음 |
104
108
  | `video_preview` | 실제 video item/URL과 thumbnail URL이 함께 있음 |
105
109
 
110
+ `weather`, `exchange_rate`, `world_clock`, `article_list`, `article_card`, `image_gallery`는
111
+ provider mode이고 `chart`는 generated mode입니다. 기사와 이미지는 페이지 이동·다시 시도,
112
+ gallery는 반응형 grid/carousel을 지원합니다. 표는 일반 Markdown으로 답변하고 단계별
113
+ 텍스트 slider는 catalog에서 제거되었습니다.
106
114
  각 renderer도 같은 필수 값을 다시 확인하고, 값이 바뀌거나 손상되면 해당 컴포넌트만 fallback합니다.
107
115
 
108
116
  ## Plugin 컴포넌트의 동적 등록
@@ -134,7 +142,7 @@ Plugin Response는 Runtime v2의 전체 layout, surface, theme, image, action과
134
142
  - Response 내부에 자체 채팅 입력창을 만들지 않습니다.
135
143
  - 무한 높이 목록 대신 요약과 상세 화면 이동을 제공합니다.
136
144
  - background refresh가 대화 scroll 위치를 바꾸지 않게 기존 높이와 데이터를 가능한 유지합니다.
137
- - 렌더링 오류는 해당 Response 텍스트 fallback으로 바꾸고 대화 전체를 종료하지 않습니다.
145
+ - 렌더링 오류는 해당 Response UI를 생략하고 이미 완료된 텍스트 답변을 유지합니다.
138
146
  - accessibility 순서는 AI 본문 다음, Response 제목, 내용, 행동 순으로 유지합니다.
139
147
 
140
148
  ## Text fallback
@@ -119,16 +119,21 @@ action에는 반드시 `label`, 일반 `icon` node에는 `semantic_label`을 함
119
119
  |---|---|---|
120
120
  | `button` | 명시적인 주·보조 행동 | `label`, `icon`, `style`, `full_width`, `enabled` |
121
121
  | `chip` | 필터·짧은 선택 | `label`, `icon`, `selected`, `enabled` |
122
- | `field` | 텍스트·숫자 입력 | `state_key`, `label`, `placeholder`, `input_type`, `persist` |
123
- | `select` | 고정 선택지 | `state_key`, `label`, `options`, `persist` |
122
+ | `field` | 텍스트·숫자·autocomplete 입력 | `state_key`, `label`, `placeholder`, `input_type`, `suggestions`/`suggestions_source`, `persist` |
123
+ | `select` | 고정·비동기·검색 선택지 | `state_key`, `label`, `options`/`options_source`, `searchable`, `persist` |
124
124
  | `switch` | boolean 설정 | `state_key`, `label`, `persist` |
125
- | `form` | 관련 입력 묶음 | `spacing`, children, submit action 가진 button |
125
+ | `form` | 관련 입력 묶음 | `spacing`, `submit_label`, children, 선택적 submit action |
126
126
  | `dialog` | 짧고 집중된 확인·편집 | `title`, `label`, children |
127
127
  | `sheet` | 모바일 중심의 보조 작업 | `title`, `label`, children |
128
128
 
129
129
  `field`, `select`, `switch`의 `state_key`는 `initial_state`에 먼저 선언해야 합니다. select options는
130
- 1~32개의 `{ "value": ..., "label": "..." }` 객체입니다. `enabled: false`는 이유를 주변 문구로
131
- 설명할 때만 사용합니다.
130
+ 1~32개의 `{ "value": ..., "label": "..." }` 객체입니다. 정적과 source 옵션을 다 선언할 수
131
+ 없습니다. `required`, 문자열 길이, 숫자 범위와 `error_text`는 Host form validation에
132
+ 사용됩니다. `enabled: false`는 이유를 주변 문구로 설명할 때만 사용합니다.
133
+
134
+ action은 `surface`, `card`, `button`, `chip`, `form`, `field`, `select`, `switch`에만 붙일 수
135
+ 있습니다. 입력 action 직전에 Host가 IME composition과 controller를 commit하며,
136
+ `validate: true`는 잘못된 form의 Tool·navigation 실행을 차단합니다.
132
137
 
133
138
  ## 반복과 데이터 시각화
134
139
 
@@ -192,10 +197,14 @@ semantic label, 최소 탭 영역과 색 이외의 상태 표시는 Host Materia
192
197
  ## A2UI Response catalog와의 관계
193
198
 
194
199
  위 node는 Plugin Runtime을 구성하는 primitive입니다. AI가 직접 선택하는 Response catalog는
195
- `weather`, `chart`, `image_gallery`, `exchange_rate`, `article_list`, `article_card`,
196
- `file_result`, `image_preview`, `video_preview`와 활성 Plugin이 등록한 `plugin.<extension-id>`로
197
- 구성됩니다. AI는 primitive tree나 실제 결과 값을 생성하지 않고 catalog ID만 선택합니다. Host는
198
- 기본 컴포넌트 데이터 또는 Plugin의 `a2ui.schema`를 검증한 Runtime tree에 실제 값을 주입합니다.
200
+ `weather`, `world_clock`, `chart`, `image_gallery`,
201
+ `exchange_rate`, `article_list`, `article_card`, `file_result`, `image_preview`, `video_preview`와 활성
202
+ Plugin이 등록한 `plugin.<extension-id>`로 구성됩니다. 최종 답변 모델은 같은 Registry Skill과 Native
203
+ Tool에서 출처(`builtin`/`plugin`), mode(`provider`/`generated`/`result`), 용도와 Schema를 확인합니다.
204
+ provider mode는 조회 조건만, generated mode는 답변에 근거한 표시 값만 전달하며 Host가 검증한 뒤
205
+ Runtime tree에 주입합니다. 특정 키워드만으로 Tool을 강제하지 않습니다.
206
+ 구조화 표는 A2UI 전용 컴포넌트 대신 Markdown table을 사용합니다. 위 표의
207
+ Runtime `table` node는 플러그인 화면 호환성을 위한 primitive로 계속 지원합니다.
199
208
  자세한 등록·fallback 규칙은 [Response UI와 Agent Timeline](ai-response-and-timeline.md)을
200
209
  참고하세요.
201
210
 
@@ -32,8 +32,8 @@
32
32
 
33
33
  ```text
34
34
  examples/plugins/school-life/dist/school-life-1.5.0.mplg
35
- 크기 263433 bytes
36
- SHA-256 0939f8956037638d56f7672ac4f9c4d8f6472d58d23840c1fd5baab198c41e29
35
+ 크기 263438 bytes
36
+ SHA-256 dfd39c29154c50bd14b4eeed390efffa7fa5ab3c6ad9361730eea67bdb836276
37
37
  ```
38
38
 
39
39
  1. Morit 플러그인 관리에서 위 `.mplg`를 선택합니다.
@@ -47,7 +47,7 @@ revision만 있으며 인증 정보는 들어가지 않습니다.
47
47
  "description": "오늘 할 일을 정리합니다.",
48
48
  "publisher": "example",
49
49
  "version": "1.0.0",
50
- "min_morit_version": "1.7.8",
50
+ "min_morit_version": "1.7.12",
51
51
  "max_morit_version": "1.999.999",
52
52
  "permissions": [],
53
53
  "capabilities": [
@@ -20,7 +20,7 @@
20
20
  "privacy_policy_url": "https://example.com/privacy",
21
21
  "publisher": "example",
22
22
  "version": "1.0.0",
23
- "min_morit_version": "1.7.8",
23
+ "min_morit_version": "1.7.12",
24
24
  "max_morit_version": "1.999.999",
25
25
  "permissions": ["network", "credentials"],
26
26
  "capabilities": [
@@ -110,6 +110,16 @@ namespace, quota, migration과 표준 CRUD Tool은 [Plugin Local Storage와 AI
110
110
  "description": "연결한 계정의 노트를 검색합니다.",
111
111
  "permissions": ["network", "credentials"],
112
112
  "timeout_seconds": 10,
113
+ "input_schema": {
114
+ "type": "object",
115
+ "properties": {"query": {"type": "string", "minLength": 1}},
116
+ "required": ["query"]
117
+ },
118
+ "output_schema": {
119
+ "type": "object",
120
+ "properties": {"items": {"type": "array", "items": {"type": "object"}}},
121
+ "required": ["items"]
122
+ },
113
123
  "runtime": {
114
124
  "adapter": "http_json",
115
125
  "connector_id": "notes_api"
@@ -121,6 +131,9 @@ namespace, quota, migration과 표준 CRUD Tool은 [Plugin Local Storage와 AI
121
131
  timeout은 0.1~30초입니다. capability 권한은 Manifest 권한의 부분집합이어야 합니다. `tool`과
122
132
  `skill`은 실행 가능한 runtime adapter가 필수입니다. Search `provider`는
123
133
  `runtime.role: "search"`와 adapter가 필요합니다.
134
+ `input_schema`/`output_schema`는 선택이지만 선언하면 AI Tool·UI·Host가 모두 같은
135
+ 안전한 JSON Schema 부분집합으로 입력과 결과를 검증합니다. UI binding 정적 검사는
136
+ [UI Runtime v2](ui-runtime-v2.md)를 참고하세요.
124
137
 
125
138
  ## UI extension
126
139
 
@@ -75,8 +75,10 @@ npx -y @morit/cli plugin preview . --output ./dist/preview.html
75
75
  python tools/morit_plugin.py preview . --output ./dist/preview.html
76
76
  ```
77
77
 
78
- 정보 구조와 compiled manifest, light/dark partial theme를 점검합니다. 실제 Flutter renderer 성공을
79
- 대신하지 않습니다.
78
+ 정보 구조와 compiled manifest, light/dark partial theme를 점검합니다. Preview는 Flutter Host와
79
+ 같은 state/binding/compound condition, form validation, autocomplete·searchable select, 순차·병렬
80
+ action flow, route parameter/result 규칙을 사용하며 Runtime Inspector로 변경을 확인할 수 있습니다.
81
+ 실제 인증 Tool·native route stack·플랫폼 IME 동작은 Flutter 앱에서 최종 확인합니다.
80
82
 
81
83
  ### 3. Package reopen
82
84
 
@@ -100,6 +102,12 @@ python tools/morit_plugin.py verify ./dist/plugin.mplg
100
102
  - local file 설치
101
103
  - 활성화와 권한 허용·거부
102
104
  - 화면 loading·empty·error·retry
105
+ - 한국어 IME 입력 직후 Tool arguments, focus 전환과 controller 동기화
106
+ - autocomplete, searchable/async select, form validation, 순차·병렬 action flow
107
+ - route parameters·result 반환, permission/connection/platform/screen 복합 조건
108
+ - 홈·플러그인 화면·AI Response UI 간 source cache 재사용과 storage mutation 자동 refresh
109
+ - A2UI 답변 완료 후 즉시 렌더링, 실패 생략, 로딩 UI 미표시
110
+ - 미디어 overlay의 영상·MP3 옵션, 실제 변환·진행률·재시도·모바일 너비
103
111
  - 시스템/라이트/다크 전환과 기존 theme 없는 package 호환
104
112
  - Storage CRUD·검색·즉시 UI 갱신, AI read/read_write 권한
105
113
  - 사용자·Installation·Instance·namespace 격리와 quota/revision/generation 충돌
@@ -111,8 +119,10 @@ python tools/morit_plugin.py verify ./dist/plugin.mplg
111
119
 
112
120
  ### 6. 회귀 검사
113
121
 
114
- SDK, Python Host, Flutter schema, Local MCP, Remote MCP가 같은 contract 반환하고 같은 fixture를 허용·거부하는지
115
- 확인합니다. 문서 예제 JSON도 source 검증 과정에서 실행해 구현과 함께 변경합니다.
122
+ SDK, Python Host, Flutter schema/runtime, Local MCP, Remote MCP, Preview가 같은 contract 경계를
123
+ 반환하고 같은 fixture를 허용·거부하는지 확인합니다. capability input/output schema와 UI
124
+ binding도 모든 validator에서 같이 실패해야 합니다. 문서 예제 JSON도 source 검증 과정에서
125
+ 실행해 구현과 함께 변경합니다.
116
126
 
117
127
  ## 실패 시 원칙
118
128
 
@@ -32,7 +32,7 @@ claude mcp add --transport http --scope user \
32
32
 
33
33
  | Tool | 목적 |
34
34
  |---|---|
35
- | `morit_sdk_contract` | SDK 1.7.8 Theme·Storage·UI/Host capability contract 전체 반환 |
35
+ | `morit_sdk_contract` | SDK 1.7.12 Theme·Storage·UI/Host capability contract 전체 반환 |
36
36
  | `morit_docs_search` | bundled 공식 문서 검색 |
37
37
  | `morit_docs_get` | 검색 결과의 Markdown 한 파일 읽기 |
38
38
 
@@ -7,7 +7,7 @@ Morit은 두 MCP 연결을 제공합니다.
7
7
  | Local `@morit/plugin-mcp` | 개발자 PC의 stdio process | 로컬 파일 직접 편집, 빠른 preview/build |
8
8
  | Remote `https://morit-api.moring.co/mcp` | Morit Cloud Streamable HTTP | 파일시스템 없는 AI client, 조직 Project·Secret·Deployment |
9
9
 
10
- 두 연결은 SDK 1.7.8의 `@morit/cli`와 Host Plugin contract를 사용합니다. 계약에는 mode별 Plugin
10
+ 두 연결은 SDK 1.7.12의 `@morit/cli`와 Host Plugin contract를 사용합니다. 계약에는 mode별 Plugin
11
11
  Theme, Local Storage/AI access, 표준 Storage Tool, UI binding, A2UI Response catalog와 safe
12
12
  preview가 포함됩니다. Local source에 접근할 필요가 없으면 Remote를, 현재 저장소 파일을 직접
13
13
  고쳐야 하면 Local을 선택합니다.
@@ -74,7 +74,7 @@ GET과 POST만 지원하며 redirect를 따라가지 않습니다. endpoint는
74
74
 
75
75
  ### Remote MCP
76
76
 
77
- Morit Host 1.7.8의 plugin outbound MCP adapter는 `mcp_http`입니다. 개발자용 Local/Remote Plugin
77
+ Morit Host 1.7.12의 plugin outbound MCP adapter는 `mcp_http`입니다. 개발자용 Local/Remote Plugin
78
78
  MCP 서버와 이름이 비슷하지만, 설치된 플러그인이 선언한 외부 MCP Tool을 Host 경계에서 호출하는
79
79
  runtime입니다.
80
80
 
@@ -133,10 +133,26 @@ enable/disable·권한 변경·Connection 생성/수정/삭제·OAuth 완료·
133
133
  "description": "연결한 학교의 오늘 시간표, 급식, 학사일정을 조회합니다.",
134
134
  "permissions": ["network", "storage"],
135
135
  "timeout_seconds": 10,
136
+ "input_schema": {
137
+ "type": "object",
138
+ "properties": {"date": {"type": "string"}},
139
+ "additionalProperties": false
140
+ },
141
+ "output_schema": {
142
+ "type": "object",
143
+ "properties": {"timetable": {"type": "array", "items": {"type": "object"}}},
144
+ "required": ["timetable"]
145
+ },
136
146
  "runtime": {"adapter": "neis_school", "operation": "overview"}
137
147
  }
138
148
  ```
139
149
 
150
+ `input_schema`/`output_schema`는 root `object`인 안전한 JSON Schema 부분집합입니다. AI Tool,
151
+ UI data source·invoke, Host 실행과 결과 binding이 같은 schema를 사용합니다. CLI/MCP validate는
152
+ 필수 필드·타입·범위와 `data.<source>.data...` 경로를 package build 전에 검사합니다.
153
+ 실행 시 schema와 다른 외부 응답은 정상 결과로 변환하지 않고 해당 capability 오류로
154
+ 격리합니다.
155
+
140
156
  UI `invoke`가 capability를 참조할 때도 enabled, generation, permission, timeout을 다시 확인합니다.
141
157
  UI에 capability ID를 사용자용 라벨로 표시하지 않습니다.
142
158
 
@@ -73,7 +73,8 @@ UI Runtime v2는 서명된 `.mplg`가 화면, 상태, data source, action을 JSO
73
73
  ```
74
74
 
75
75
  허용 config 필드는 `ui_schema`, `icon`, `description`, `placement`, `app_bar`, `navigation`,
76
- `theme`, `initial_state`, `data_sources`, `view`입니다.
76
+ `theme`, `initial_state`, `data_sources`, `a2ui`, `view`입니다. `a2ui`는 `point: "response"`에서만
77
+ 사용합니다.
77
78
 
78
79
  ## 트리와 크기 제한
79
80
 
@@ -150,9 +151,19 @@ theme를 생략한 기존 플러그인은 Morit의 현재 Material 3 라이트·
150
151
  ```
151
152
 
152
153
  `field`, `select`, `switch`, navigation selection, `set_state`는 여기에 선언된 key만 변경합니다.
154
+ Host는 focus된 입력과 `TextEditingController`를 action 실행 직전에 commit하므로 조합 중인
155
+ 한국어 IME 문자도 Tool arguments와 validation에 최신 값으로 반영됩니다.
153
156
  `persist: true`는 Instance settings의 extension 전용 namespace에 저장합니다. credential과 민감한
154
157
  사용자 데이터는 state에 저장하지 않습니다.
155
158
 
159
+ ### 입력과 검증
160
+
161
+ - `field.suggestions`는 정적 autocomplete 문자열, `suggestions_source`는 `data.<source>...` 배열입니다.
162
+ - `select.options`는 정적 `{value,label}` 목록, `options_source`는 capability가 돌려준 비동기 목록입니다.
163
+ - `select.searchable: true`는 Material 3 검색 select를 사용합니다.
164
+ - `required`, `min_length`, `max_length`, `minimum`, `maximum`, `error_text`는 `field`/`select`/`switch`
165
+ 검증에 사용합니다. action의 `validate: true`는 잘못된 form을 실행하지 않습니다.
166
+
156
167
  ## Data source와 binding
157
168
 
158
169
  ```json
@@ -185,6 +196,14 @@ arguments에 전달하고, 다른 글자와 섞으면 텍스트 template이 됩
185
196
  {"label": "{{state.period}} 일정"}
186
197
  ```
187
198
 
199
+ `context.platform`, `context.screen.width|height|size_class`, `context.permissions.<permission>`,
200
+ `context.connections.<credential>`, `context.connected`, `context.loading.<source>`,
201
+ `context.errors.<source>`, `context.route.<parameter>`도 조건과 binding에 사용할 수 있습니다.
202
+
203
+ Capability의 `input_schema`/`output_schema`는 안전한 JSON Schema 부분집합을 사용합니다. validate는
204
+ data source와 invoke arguments를 input schema와, `data.<source>.data...` binding을 output schema와 정적으로
205
+ 대조합니다. 필수 필드·타입·존재하지 않는 경로는 package build 전에 구체적인 오류로 거부됩니다.
206
+
188
207
  ### Local Storage binding
189
208
 
190
209
  저장소도 같은 data source/action 흐름을 사용합니다.
@@ -276,6 +295,25 @@ form dialog sheet field select switch empty
276
295
 
277
296
  `store`는 선언된 data source ID이며 성공 결과로 해당 source를 교체합니다.
278
297
 
298
+ ### Action Flow와 Form
299
+
300
+ ```json
301
+ {
302
+ "type": "flow",
303
+ "mode": "sequential",
304
+ "validate": true,
305
+ "continue_on_error": false,
306
+ "actions": [
307
+ {"type": "invoke", "capability": "school.save", "arguments": {"name": "{{state.name}}"}, "store": "saved"},
308
+ {"type": "navigate", "target": "school.details", "parameters": {"id": "{{data.saved.data.id}}"}, "result_state": "route_result"}
309
+ ]
310
+ }
311
+ ```
312
+
313
+ `mode`는 `sequential` 또는 `parallel`입니다. flow는 최대 4 depth, 전체 16 action, flow당
314
+ 8 action으로 제한됩니다. 순차 flow는 앞 action의 store를 다음 action에서 즉시 읽고,
315
+ 병렬 flow는 서로 의존하지 않는 action을 함께 실행합니다.
316
+
279
317
  ### State와 refresh
280
318
 
281
319
  ```json
@@ -292,13 +330,17 @@ form dialog sheet field select switch empty
292
330
  {
293
331
  "type": "navigate",
294
332
  "target": "school_life.details",
333
+ "parameters": {"date": "{{state.date}}"},
334
+ "result_state": "details_result",
295
335
  "transition": "platform",
296
336
  "replace": false
297
337
  }
298
338
  ```
299
339
 
300
340
  `transition`은 `platform`, `fade`, `slide`, `none`입니다. `replace: true`는 현재 route를 뒤로가기
301
- stack에서 교체합니다. 같은 manifest의 UI extension ID만 target이 될 수 있습니다.
341
+ stack에서 교체합니다. 같은 manifest의 UI extension ID만 target이 될 수 있습니다. `parameters`는
342
+ 대상의 `context.route.<parameter>`로 전달되고 닫힌 결과는 `result_state`에 지정한
343
+ `initial_state` key에 반영됩니다. 대상은 `back.result`로 결과 객체를 반환합니다.
302
344
 
303
345
  ```json
304
346
  {"type": "back"}
@@ -315,8 +357,9 @@ stack에서 교체합니다. 같은 manifest의 UI extension ID만 target이 될
315
357
  }
316
358
  ```
317
359
 
318
- `equals`, `not_equals`, `exists` 중 정확히 하나를 사용합니다. 조건은 state·data·현재 `item` 값만
319
- 읽고 임의 expression을 실행하지 않습니다.
360
+ 단일 조건은 `equals`, `not_equals`, `exists` 중 정확히 하나를 사용합니다. `all`, `any`, `not`으로
361
+ 조건을 재귀적으로 조합할 있으며 조건 depth 4, 전체 32개로 제한됩니다. 조건은
362
+ `state`, `data`, `item`, `context`만 읽고 임의 expression을 실행하지 않습니다.
320
363
 
321
364
  ## 로딩과 오류 복구
322
365
 
@@ -325,6 +368,18 @@ Host는 source별 실행을 격리하고 중복 실행을 합칩니다. 화면
325
368
  유지하고, 첫 로드도 실패한 경우에만 해당 영역에 retry 상태를 표시합니다. package generation이나
326
369
  화면이 바뀐 뒤 도착한 오래된 응답은 버립니다.
327
370
 
371
+ Host는 Instance·capability·해석된 query/arguments가 같은 결과를 Shell 수명의 공유 cache에
372
+ 보관합니다. 홈, 플러그인 화면, AI Response UI에서 같은 source를 열면 추가 호출 없이
373
+ 즉시 재사용하고 mutation/refresh 결과를 구독 중인 모든 surface에 자동 전파합니다.
374
+ 알림 Tool은 같은 Instance local storage를 source of truth로 읽으며 알림 action의 storage mutation도
375
+ storage revision을 갱신해 열린 UI source를 자동 refresh합니다.
376
+
377
+ ## Runtime Inspector
378
+
379
+ Preview와 debug Host의 `Runtime Inspector`는 현재 state, data source 결과, context, loading/error,
380
+ 마지막 action과 검증 오류를 표시합니다. secret과 credential 값은 포함하지 않으며 배포 build의
381
+ 일반 사용자 화면에서는 노출하지 않습니다.
382
+
328
383
  ## Response UI
329
384
 
330
385
  `point: "response"`도 같은 `theme`, `app_bar`, `navigation`, data/state/node/action 계약을
@@ -337,7 +392,15 @@ catalog는 [Response UI와 Agent Timeline](ai-response-and-timeline.md)을 참
337
392
  ## Preview와 실제 Host
338
393
 
339
394
  `morit plugin preview . --output ./dist/preview.html` 또는 `morit_project_preview`는 validate를 먼저
340
- 통과한 config를 실행 코드 없이 안전한 HTML로 렌더링합니다. 상단 토글로 light/dark variantHost
341
- 상속 결과를 비교할 있습니다. 같은 manifest를 Flutter 앱이 실제 Material widget으로 렌더링하므로
342
- node, binding, theme 계약은 공유하지만 focus, 키보드, route stack, 권한, 실제 Tool 호출은 서명
343
- package를 앱에 설치해 최종 확인합니다.
395
+ 통과한 config를 실행 코드 없이 안전한 HTML로 렌더링합니다. Preview는 Flutter Host같은
396
+ binding, compound condition, state commit, form validation, autocomplete/select, 순차·병렬 flow,
397
+ route params/result 규칙을 적용하고 invoke/navigation을 검증 가능한 Host event로 노출합니다.
398
+ 상단 토글로 light/dark variant와 Host 상속 결과를 비교하고 Runtime Inspector로 상태를 확인합니다.
399
+ 실제 인증·권한·network Tool과 native route stack만 서명 package를 앱에 설치해 최종 확인합니다.
400
+
401
+ ## 1.7.12 호환성
402
+
403
+ 기존 UI v2 manifest는 변경 없이 동작합니다. compound condition, action flow, input 검증,
404
+ async options, route data, capability schema는 모두 additive입니다. 기존 단일 action과
405
+ `{path, equals|not_equals|exists}` 조건은 그대로 유지됩니다. 새 필드를 사용하는 package는
406
+ `min_morit_version: "1.7.12"`를 지정합니다.
@@ -10,7 +10,7 @@ SHA-256을 함께 남기면 나중에 같은 결과를 재현할 수 있습니
10
10
 
11
11
  | 패키지 | 크기 | SHA-256 |
12
12
  |---|---:|---|
13
- | `school-life-1.5.0.mplg` | 263433 bytes | `0939f8956037638d56f7672ac4f9c4d8f6472d58d23840c1fd5baab198c41e29` |
13
+ | `school-life-1.5.0.mplg` | 263438 bytes | `dfd39c29154c50bd14b4eeed390efffa7fa5ab3c6ad9361730eea67bdb836276` |
14
14
  | `notion-1.1.0.mplg` | 3044 bytes | `eba57c440b305343c5cf917259e30e50b322272cfadb0ed64d39ecfc47539c4e` |
15
15
 
16
16
  ```powershell
@@ -1,5 +1,5 @@
1
1
  {
2
- "sdk_version": "1.7.8",
2
+ "sdk_version": "1.7.12",
3
3
  "manifest_schema_versions": [1, 2],
4
4
  "recommended_schema_version": 2,
5
5
  "package_content_type": "application/vnd.morit.plugin+zip",
@@ -45,7 +45,7 @@
45
45
  "slash_commands": {"target": "slash_commands", "kind": null}
46
46
  },
47
47
  "object_fields": {
48
- "capability": ["id", "kind", "title", "description", "permissions", "timeout_seconds", "runtime"],
48
+ "capability": ["id", "kind", "title", "description", "permissions", "timeout_seconds", "runtime", "input_schema", "output_schema"],
49
49
  "ui_extension": ["id", "point", "title", "order", "permissions", "config"],
50
50
  "credential": ["id", "label", "description", "kind", "required", "oauth_provider", "allow_multiple"],
51
51
  "slash_command": ["id", "command", "title", "description", "capability", "argument_template"],
@@ -116,10 +116,14 @@
116
116
  "catalog_id": "https://moring.co/a2ui/catalogs/morit-ai/v1",
117
117
  "selection": "ai_explicit_host_validated",
118
118
  "selection_limit": 4,
119
- "model_selection_fields": ["candidate_id", "component", "chart_type"],
120
- "host_injects_tool_data": true,
119
+ "skill": "morit_a2ui",
120
+ "tool": "render_a2ui",
121
+ "model_selection_fields": ["candidate_id", "component", "values", "chart_type"],
122
+ "host_injects_tool_data": false,
123
+ "tool_values_must_equal_candidate": false,
124
+ "tool_values_must_be_candidate_grounded": true,
121
125
  "fallback": "text_only",
122
- "built_in_components": ["weather", "chart", "image_gallery", "exchange_rate", "article_list", "article_card", "file_result", "image_preview", "video_preview"],
126
+ "built_in_components": ["weather", "world_clock", "chart", "image_gallery", "exchange_rate", "article_list", "article_card", "file_result", "image_preview", "video_preview"],
123
127
  "validation_gates": ["completed_or_partial", "non_empty", "component_data", "answer_relevance", "not_duplicate", "plugin_schema", "plugin_permission", "plugin_version"],
124
128
  "plugin_component_prefix": "plugin.",
125
129
  "plugin_metadata_fields": ["version", "description", "schema"],
@@ -278,6 +282,17 @@
278
282
  "top",
279
283
  "right",
280
284
  "bottom"
285
+ ,"required"
286
+ ,"min_length"
287
+ ,"max_length"
288
+ ,"minimum"
289
+ ,"maximum"
290
+ ,"error_text"
291
+ ,"suggestions"
292
+ ,"suggestions_source"
293
+ ,"searchable"
294
+ ,"options_source"
295
+ ,"submit_label"
281
296
  ],
282
297
  "app_bar_fields": ["title", "subtitle", "center_title", "pinned", "leading", "actions"],
283
298
  "navigation_fields": ["type", "items", "selected_state_key", "persist", "label_behavior", "rail_breakpoint"],
@@ -289,8 +304,11 @@
289
304
  "theme_border_fields": ["color", "width", "radius"],
290
305
  "theme_icon_fields": ["color", "size"],
291
306
  "theme_state_fields": ["disabled_opacity", "selected_color", "focus_color"],
292
- "action_fields": ["type", "capability", "query", "arguments", "store", "target", "source", "values", "persist", "transition", "replace"],
293
- "actions": ["invoke", "navigate", "set_state", "refresh", "back"],
307
+ "action_fields": ["type", "capability", "query", "arguments", "store", "target", "source", "values", "persist", "transition", "replace", "mode", "actions", "continue_on_error", "validate", "parameters", "result_state", "result"],
308
+ "actions": ["invoke", "navigate", "set_state", "refresh", "back", "flow"],
309
+ "condition_fields": ["path", "equals", "not_equals", "exists", "all", "any", "not"],
310
+ "condition_context_roots": ["platform", "screen", "permissions", "connections", "connected", "loading", "errors", "route"],
311
+ "action_flow_modes": ["sequential", "parallel"],
294
312
  "navigation_transitions": ["platform", "fade", "slide", "none"],
295
313
  "chart_types": ["bar", "line", "donut", "scatter"],
296
314
  "navigation_types": ["tabs", "bar", "rail", "drawer", "adaptive"],
@@ -408,7 +426,13 @@
408
426
  "data_sources": 8,
409
427
  "state_keys": 32,
410
428
  "navigation_items": 8,
411
- "app_bar_actions": 6
429
+ "app_bar_actions": 6,
430
+ "condition_depth": 4,
431
+ "condition_nodes": 32,
432
+ "action_flow_depth": 4,
433
+ "action_flow_actions": 16,
434
+ "actions_per_flow": 8,
435
+ "input_options": 32
412
436
  }
413
437
  },
414
438
  "signature": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morit/cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Official Morit Developer CLI for Cloud Projects, validation, builds, and deployments",
5
5
  "type": "module",
6
6
  "bin": {
package/src/preview.js CHANGED
@@ -51,27 +51,53 @@ function themeVariables(config) {
51
51
  return values.join(";");
52
52
  }
53
53
 
54
+ function jsonAttribute(value) {
55
+ return escape(JSON.stringify(value));
56
+ }
57
+
58
+ function nodeAttributes(node) {
59
+ const attributes = [];
60
+ if (node.visible_when) attributes.push('data-visible="' + jsonAttribute(node.visible_when) + '"');
61
+ if (node.action) attributes.push('data-action="' + jsonAttribute(node.action) + '"');
62
+ return attributes.length ? " " + attributes.join(" ") : "";
63
+ }
64
+
54
65
  function nodeHtml(node) {
55
66
  if (!node || typeof node !== "object") return "";
56
67
  const type = String(node.type || "surface");
57
68
  const props = node.props && typeof node.props === "object" ? node.props : {};
58
69
  const children = Array.isArray(node.children) ? node.children.map(nodeHtml).join("") : "";
59
70
  const label = escape(props.label || props.title || type);
60
- const action = node.action ? ' data-action="' + escape(node.action.type) + '"' : "";
61
- if (type === "text") return '<p class="ui-text ' + escape(props.style) + '">' + escape(props.text) + "</p>";
62
- if (type === "icon") return '<span class="ui-icon" role="img" aria-label="' + escape(props.semantic_label || props.icon) + '">' + escape(props.icon) + "</span>";
63
- if (type === "image") return '<div class="ui-image" role="img" aria-label="' + escape(props.semantic_label || "image") + '">🖼 ' + escape(props.asset || props.url || "image") + "</div>";
64
- if (type === "avatar") return '<span class="ui-avatar">' + escape(props.label || "M").slice(0, 1) + "</span>";
65
- if (type === "badge") return '<span class="ui-badge">' + label + children + "</span>";
71
+ const attributes = nodeAttributes(node);
72
+ const template = (value) => ' data-template="' + escape(value ?? "") + '"';
73
+ if (type === "text") return '<p class="ui-text ' + escape(props.style) + '"' + attributes + template(props.text) + '>' + escape(props.text) + "</p>";
74
+ if (type === "icon") return '<span class="ui-icon" role="img" aria-label="' + escape(props.semantic_label || props.icon) + '"' + attributes + '>' + escape(props.icon) + "</span>";
75
+ if (type === "image") return '<div class="ui-image" role="img" aria-label="' + escape(props.semantic_label || "image") + '"' + attributes + '>🖼 ' + escape(props.asset || props.url || "image") + "</div>";
76
+ if (type === "avatar") return '<span class="ui-avatar"' + attributes + '>' + escape(props.label || "M").slice(0, 1) + "</span>";
77
+ if (type === "badge") return '<span class="ui-badge"' + attributes + '>' + label + children + "</span>";
66
78
  if (type === "divider") return "<hr>";
67
79
  if (type === "spacer") return '<span class="ui-spacer" aria-hidden="true"></span>';
68
- if (type === "button") return "<button" + action + ">" + (props.icon ? escape(props.icon) + " " : "") + label + "</button>";
69
- if (type === "chip") return '<span class="ui-chip"' + action + ">" + label + "</span>";
70
- if (type === "metric") return '<div class="ui-metric"><strong>' + escape(props.value) + "</strong><span>" + label + "</span></div>";
71
- if (type === "progress") return '<div class="ui-progress"><span>' + label + '</span><progress value="' + Number(props.value ?? .5) + '" max="1"></progress></div>';
72
- if (type === "field") return '<label class="ui-field">' + label + '<input disabled placeholder="' + escape(props.hint || props.placeholder) + '"></label>';
73
- if (type === "select") return '<label class="ui-field">' + label + '<select disabled><option>' + escape(props.placeholder || "선택") + "</option></select></label>";
74
- if (type === "switch") return '<label class="ui-switch"><input type="checkbox" disabled> ' + label + "</label>";
80
+ if (type === "button") return "<button type=\"button\"" + attributes + ">" + (props.icon ? escape(props.icon) + " " : "") + label + "</button>";
81
+ if (type === "chip") return '<button type="button" class="ui-chip"' + attributes + '>' + label + "</button>";
82
+ if (type === "metric") return '<div class="ui-metric"' + attributes + '><strong' + template(props.value) + '>' + escape(props.value) + "</strong><span>" + label + "</span></div>";
83
+ if (type === "progress") return '<div class="ui-progress"' + attributes + '><span>' + label + '</span><progress data-value-template="' + escape(props.value ?? .5) + '" value="' + Number(props.value ?? .5) + '" max="1"></progress></div>';
84
+ if (type === "field") {
85
+ const inputType = props.input_type === "number" ? "number" : props.input_type === "email" ? "email" : props.input_type === "url" ? "url" : "text";
86
+ const constraints = (props.required ? " required" : "") + (props.min_length != null ? ' minlength="' + Number(props.min_length) + '"' : "") + (props.max_length != null ? ' maxlength="' + Number(props.max_length) + '"' : "") + (props.minimum != null ? ' min="' + Number(props.minimum) + '"' : "") + (props.maximum != null ? ' max="' + Number(props.maximum) + '"' : "");
87
+ const suggestions = Array.isArray(props.suggestions) ? '<datalist id="list-' + escape(props.state_key) + '">' + props.suggestions.map((value) => '<option value="' + escape(value) + '"></option>').join("") + "</datalist>" : "";
88
+ const listId = props.suggestions || props.suggestions_source ? "list-" + props.state_key : "";
89
+ return '<label class="ui-field"' + attributes + '>' + label + '<input type="' + inputType + '" data-state-key="' + escape(props.state_key) + '" data-suggestions-source="' + escape(props.suggestions_source || "") + '" placeholder="' + escape(props.hint || props.placeholder) + '"' + constraints + (listId ? ' list="' + escape(listId) + '"' : "") + ">" + (suggestions || (listId ? '<datalist id="' + escape(listId) + '"></datalist>' : "")) + "</label>";
90
+ }
91
+ if (type === "select") {
92
+ const options = Array.isArray(props.options) ? props.options : [];
93
+ if (props.searchable) {
94
+ const listId = "select-" + props.state_key;
95
+ return '<label class="ui-field"' + attributes + '>' + label + '<input data-search-select="true" data-state-key="' + escape(props.state_key) + '" data-options-source="' + escape(props.options_source || "") + '" data-options="' + jsonAttribute(options) + '" list="' + escape(listId) + '" placeholder="' + escape(props.placeholder || "검색하여 선택") + '"' + (props.required ? " required" : "") + '><datalist id="' + escape(listId) + '">' + options.map((option) => '<option value="' + escape(option.label) + '"></option>').join("") + "</datalist></label>";
96
+ }
97
+ const optionHtml = options.map((option, index) => '<option value="' + index + '" data-value="' + jsonAttribute(option.value) + '">' + escape(option.label) + "</option>").join("");
98
+ return '<label class="ui-field"' + attributes + '>' + label + '<select data-state-key="' + escape(props.state_key) + '" data-options-source="' + escape(props.options_source || "") + '"' + (props.required ? " required" : "") + '><option value="">' + escape(props.placeholder || "선택") + "</option>" + optionHtml + "</select></label>";
99
+ }
100
+ if (type === "switch") return '<label class="ui-switch"' + attributes + '><input type="checkbox" data-state-key="' + escape(props.state_key) + '"' + (props.required ? " required" : "") + "> " + label + "</label>";
75
101
  if (type === "empty") return '<div class="ui-empty"><strong>' + escape(props.title || "표시할 내용이 없어요.") + "</strong><p>" + escape(props.supporting) + "</p></div>";
76
102
  if (["list", "timeline", "calendar", "chart", "table"].includes(type)) {
77
103
  return '<section class="ui-collection"><small>' + escape(type + (props.source ? " · " + props.source : "")) + "</small>" + (children || '<p>' + escape(props.empty_text || "데이터가 연결되면 표시됩니다.") + "</p>") + "</section>";
@@ -83,7 +109,8 @@ function nodeHtml(node) {
83
109
  const heading = surface && (props.title || props.label)
84
110
  ? "<h3>" + label + "</h3>" + (props.subtitle ? "<p>" + escape(props.subtitle) + "</p>" : "")
85
111
  : "";
86
- return '<div class="' + className + '"' + action + ">" + heading + children + "</div>";
112
+ if (type === "form") return '<form class="' + className + '"' + attributes + ">" + heading + children + (props.submit_label ? '<button type="submit">' + escape(props.submit_label) + "</button>" : "") + "</form>";
113
+ return '<div class="' + className + '"' + attributes + ">" + heading + children + "</div>";
87
114
  }
88
115
 
89
116
  function chromeHtml(config) {
@@ -100,6 +127,214 @@ function chromeHtml(config) {
100
127
  return bar + nav;
101
128
  }
102
129
 
130
+ export function resolvePreviewPath(roots, path) {
131
+ const parts = String(path || "").split(".");
132
+ let value = roots[parts.shift()];
133
+ for (const part of parts) {
134
+ if (value == null) return null;
135
+ if (Array.isArray(value) && part === "length") value = value.length;
136
+ else if (Array.isArray(value) && /^\d+$/.test(part)) value = value[Number(part)];
137
+ else if (typeof value === "object") value = value[part];
138
+ else return null;
139
+ }
140
+ return value ?? null;
141
+ }
142
+
143
+ export function previewValuesEqual(left, right) {
144
+ if (Object.is(left, right)) return true;
145
+ if (Array.isArray(left) && Array.isArray(right)) {
146
+ return left.length === right.length && left.every((value, index) => previewValuesEqual(value, right[index]));
147
+ }
148
+ if (left && right && typeof left === "object" && typeof right === "object" && !Array.isArray(left) && !Array.isArray(right)) {
149
+ const keys = Object.keys(left);
150
+ return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && previewValuesEqual(left[key], right[key]));
151
+ }
152
+ return typeof left === "number" && typeof right === "number" && left === right;
153
+ }
154
+
155
+ export function resolvePreviewValue(value, roots) {
156
+ if (Array.isArray(value)) return value.map((item) => resolvePreviewValue(item, roots));
157
+ if (value && typeof value === "object") {
158
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolvePreviewValue(item, roots)]));
159
+ }
160
+ if (typeof value !== "string") return value;
161
+ const exact = value.match(/^\{\{((?:state|data|context|item)\.[A-Za-z0-9_.-]+)\}\}$/);
162
+ if (exact) return resolvePreviewPath(roots, exact[1]);
163
+ return value.replace(/\{\{((?:state|data|context|item)\.[A-Za-z0-9_.-]+)\}\}/g, (_, path) => String(resolvePreviewPath(roots, path) ?? ""));
164
+ }
165
+
166
+ export function evaluatePreviewCondition(condition, roots) {
167
+ if (!condition) return true;
168
+ if (Array.isArray(condition.all)) return condition.all.every((item) => evaluatePreviewCondition(item, roots));
169
+ if (Array.isArray(condition.any)) return condition.any.some((item) => evaluatePreviewCondition(item, roots));
170
+ if (condition.not) return !evaluatePreviewCondition(condition.not, roots);
171
+ const value = resolvePreviewPath(roots, condition.path);
172
+ if (Object.hasOwn(condition, "equals")) return previewValuesEqual(value, resolvePreviewValue(condition.equals, roots));
173
+ if (Object.hasOwn(condition, "not_equals")) return !previewValuesEqual(value, resolvePreviewValue(condition.not_equals, roots));
174
+ if (Object.hasOwn(condition, "exists")) return condition.exists === (value !== null);
175
+ return true;
176
+ }
177
+
178
+ function previewRuntime() {
179
+ const parse = (element, key) => element.dataset[key] ? JSON.parse(element.dataset[key]) : null;
180
+ const same = previewValuesEqual;
181
+ for (const article of document.querySelectorAll(".screen[data-extension]")) {
182
+ const config = JSON.parse(article.querySelector(".runtime-config").textContent);
183
+ const state = structuredClone(config.initial_state || {});
184
+ const data = {};
185
+ const context = {
186
+ platform: "web",
187
+ permissions: {}, connections: {}, connected: false,
188
+ loading: {}, errors: {}, route: {},
189
+ screen: { width: article.clientWidth, height: innerHeight, size_class: article.clientWidth < 600 ? "compact" : article.clientWidth < 840 ? "medium" : "expanded" },
190
+ };
191
+ const roots = () => ({ state, data, context, item: {} });
192
+ const inspector = article.querySelector(".runtime-inspector pre");
193
+ const log = [];
194
+
195
+ function optionValue(option) {
196
+ if (!option?.dataset.value) return option?.value ?? null;
197
+ return JSON.parse(option.dataset.value);
198
+ }
199
+
200
+ function update() {
201
+ for (const element of article.querySelectorAll("[data-visible]")) {
202
+ element.hidden = !evaluatePreviewCondition(parse(element, "visible"), roots());
203
+ }
204
+ for (const element of article.querySelectorAll("[data-template]")) {
205
+ element.textContent = String(resolvePreviewValue(element.dataset.template, roots()) ?? "");
206
+ }
207
+ for (const element of article.querySelectorAll("progress[data-value-template]")) {
208
+ element.value = Number(resolvePreviewValue(element.dataset.valueTemplate, roots()) ?? 0);
209
+ }
210
+ for (const input of article.querySelectorAll("input[data-suggestions-source]")) {
211
+ if (!input.dataset.suggestionsSource) continue;
212
+ const values = resolvePreviewPath(roots(), input.dataset.suggestionsSource);
213
+ const list = document.getElementById(input.getAttribute("list"));
214
+ if (list && Array.isArray(values)) list.replaceChildren(...values.filter((value) => typeof value === "string").map((value) => Object.assign(document.createElement("option"), { value })));
215
+ }
216
+ for (const select of article.querySelectorAll("select[data-options-source]")) {
217
+ if (!select.dataset.optionsSource) continue;
218
+ const values = resolvePreviewPath(roots(), select.dataset.optionsSource);
219
+ const placeholder = select.options[0]?.cloneNode(true) || document.createElement("option");
220
+ const options = Array.isArray(values) ? values.filter((value) => value && typeof value === "object" && typeof value.label === "string") : [];
221
+ select.replaceChildren(placeholder, ...options.map((value, index) => {
222
+ const option = document.createElement("option");
223
+ option.value = String(index);
224
+ option.textContent = value.label;
225
+ option.dataset.value = JSON.stringify(value.value);
226
+ return option;
227
+ }));
228
+ }
229
+ for (const input of article.querySelectorAll("input[data-search-select]")) {
230
+ const values = input.dataset.optionsSource ? resolvePreviewPath(roots(), input.dataset.optionsSource) : JSON.parse(input.dataset.options || "[]");
231
+ input._previewOptions = Array.isArray(values) ? values.filter((value) => value && typeof value === "object" && typeof value.label === "string") : [];
232
+ const list = document.getElementById(input.getAttribute("list"));
233
+ if (list) list.replaceChildren(...input._previewOptions.map((value) => Object.assign(document.createElement("option"), { value: value.label })));
234
+ }
235
+ for (const input of article.querySelectorAll("[data-state-key]")) {
236
+ const value = state[input.dataset.stateKey];
237
+ if (input.type === "checkbox") input.checked = value === true;
238
+ else if (input.dataset.searchSelect) {
239
+ const match = (input._previewOptions || []).find((option) => same(option.value, value));
240
+ if (document.activeElement !== input && match && input.value !== match.label) input.value = match.label;
241
+ }
242
+ else if (input.tagName === "SELECT") {
243
+ const match = [...input.options].find((option) => same(optionValue(option), value));
244
+ if (match && input.value !== match.value) input.value = match.value;
245
+ } else if (document.activeElement !== input && input.value !== String(value ?? "")) input.value = String(value ?? "");
246
+ }
247
+ inspector.textContent = JSON.stringify({ state, data, context, log: log.slice(-8) }, null, 2);
248
+ }
249
+
250
+ async function perform(action) {
251
+ if (!action) return false;
252
+ if (action.validate && ![...article.querySelectorAll("form")].every((form) => form.reportValidity())) return false;
253
+ if (action.type === "flow") {
254
+ const tasks = action.actions || [];
255
+ if (action.mode === "parallel") {
256
+ const results = await Promise.all(tasks.map(perform));
257
+ return action.continue_on_error || results.every(Boolean);
258
+ }
259
+ for (const child of tasks) if (!await perform(child) && !action.continue_on_error) return false;
260
+ return true;
261
+ }
262
+ if (action.type === "set_state") Object.assign(state, resolvePreviewValue(action.values || {}, roots()));
263
+ else if (action.type === "invoke") {
264
+ const detail = { capability: action.capability, query: resolvePreviewValue(action.query, roots()), arguments: resolvePreviewValue(action.arguments || {}, roots()), result: null };
265
+ article.dispatchEvent(new CustomEvent("morit-preview-invoke", { detail, bubbles: true }));
266
+ if (action.store && detail.result != null) data[action.store] = detail.result;
267
+ log.push({ invoke: detail.capability, arguments: detail.arguments });
268
+ } else if (action.type === "refresh") {
269
+ const detail = { source: action.source, result: null };
270
+ article.dispatchEvent(new CustomEvent("morit-preview-refresh", { detail, bubbles: true }));
271
+ if (detail.result != null) data[action.source] = detail.result;
272
+ log.push({ refresh: action.source });
273
+ } else if (action.type === "navigate") {
274
+ const detail = { target: action.target, parameters: resolvePreviewValue(action.parameters || {}, roots()), result: null };
275
+ article.dispatchEvent(new CustomEvent("morit-preview-navigate", { detail, bubbles: true }));
276
+ context.route = detail.parameters;
277
+ document.querySelector('.screen[data-extension="' + CSS.escape(action.target) + '"]')?.scrollIntoView({ behavior: "smooth" });
278
+ if (action.result_state && detail.result != null) state[action.result_state] = detail.result;
279
+ log.push({ navigate: action.target, parameters: detail.parameters });
280
+ } else if (action.type === "back") {
281
+ article.dispatchEvent(new CustomEvent("morit-preview-back", { detail: { result: resolvePreviewValue(action.result || {}, roots()) }, bubbles: true }));
282
+ log.push({ back: true });
283
+ }
284
+ update();
285
+ return true;
286
+ }
287
+
288
+ article.addEventListener("input", (event) => {
289
+ const input = event.target.closest("[data-state-key]");
290
+ if (!input) return;
291
+ if (input.dataset.searchSelect) return;
292
+ state[input.dataset.stateKey] = input.type === "checkbox" ? input.checked : input.type === "number" && input.value !== "" ? Number(input.value) : input.value;
293
+ update();
294
+ });
295
+ article.addEventListener("change", (event) => {
296
+ const input = event.target.closest("[data-state-key]");
297
+ if (!input) return;
298
+ if (input.dataset.searchSelect) {
299
+ const match = (input._previewOptions || []).find((option) => option.label === input.value);
300
+ if (!match) { input.setCustomValidity("목록에서 선택해 주세요."); input.reportValidity(); return; }
301
+ input.setCustomValidity("");
302
+ state[input.dataset.stateKey] = match.value;
303
+ } else state[input.dataset.stateKey] = input.tagName === "SELECT" ? optionValue(input.selectedOptions[0]) : input.type === "checkbox" ? input.checked : input.type === "number" && input.value !== "" ? Number(input.value) : input.value;
304
+ const actionHost = input.closest("[data-action]");
305
+ if (actionHost) perform(parse(actionHost, "action"));
306
+ update();
307
+ });
308
+ article.addEventListener("submit", (event) => {
309
+ event.preventDefault();
310
+ perform(parse(event.target, "action"));
311
+ });
312
+ article.addEventListener("click", (event) => {
313
+ const host = event.target.closest("button[data-action], .ui-surface[data-action], .ui-layout[data-action]");
314
+ if (host) perform(parse(host, "action"));
315
+ });
316
+ article.addEventListener("keydown", (event) => {
317
+ if (event.key !== "Enter" || event.isComposing) return;
318
+ const field = event.target.closest(".ui-field[data-action]");
319
+ if (field) { event.preventDefault(); perform(parse(field, "action")); }
320
+ });
321
+ new ResizeObserver(() => {
322
+ context.screen.width = article.clientWidth;
323
+ context.screen.size_class = article.clientWidth < 600 ? "compact" : article.clientWidth < 840 ? "medium" : "expanded";
324
+ update();
325
+ }).observe(article);
326
+ update();
327
+ }
328
+ }
329
+
330
+ const RUNTIME_SCRIPT = [
331
+ resolvePreviewPath.toString(),
332
+ previewValuesEqual.toString(),
333
+ resolvePreviewValue.toString(),
334
+ evaluatePreviewCondition.toString(),
335
+ "(" + previewRuntime.toString() + ")();",
336
+ ].join("\n");
337
+
103
338
  const CSS = [
104
339
  "*{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,sans-serif;background:#0d1211}",
105
340
  ".theme-toggle{position:fixed;right:18px;top:16px;z-index:3;padding:9px 13px;border-radius:999px;background:#fff;color:#17211f;box-shadow:0 4px 18px #0003;cursor:pointer}",
@@ -110,6 +345,7 @@ const CSS = [
110
345
  ".screen-meta,.app-bar,.screen-body,.ui-nav{padding:var(--space)}.screen-meta{display:flex;gap:8px;background:var(--surface-variant);color:var(--on-surface-variant)}.screen-meta span{margin-right:auto}.app-bar{display:flex;align-items:center;border-bottom:1px solid var(--outline-variant)}.app-bar div:first-child{display:grid}.app-bar small{color:var(--on-surface-variant)}.app-actions{margin-left:auto;display:flex;gap:6px}",
111
346
  ".screen-body,.ui-layout,.ui-surface,.ui-collection{display:flex;flex-direction:column;gap:var(--space)}.ui-layout.row{flex-direction:row;align-items:center}.ui-layout.wrap,.ui-nav{display:flex;flex-flow:row wrap;gap:8px}.ui-layout.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}.ui-surface,.ui-collection,.ui-empty{padding:var(--space);border:1px solid var(--outline-variant);border-radius:var(--radius);background:color-mix(in srgb,var(--surface),var(--primary-container) 9%)}",
112
347
  "h3,p{margin:0}.ui-text.heading{font-size:1.25em;font-weight:750}.ui-text.caption,small{color:var(--on-surface-variant)}button,.ui-chip{border:0;border-radius:999px;padding:8px 13px;background:var(--primary-container);color:var(--on-primary-container);font:inherit}.ui-icon{font:600 .75em monospace;color:var(--primary)}.ui-image{min-height:90px;display:grid;place-items:center;border-radius:var(--radius);background:var(--surface-variant);color:var(--on-surface-variant)}.ui-avatar{display:grid;place-items:center;width:42px;height:42px;border-radius:50%;background:var(--primary);color:var(--on-primary)}.ui-badge{padding:3px 7px;border-radius:99px;background:var(--error-container);color:var(--on-error-container)}.ui-metric{display:grid;padding:12px;border-radius:var(--radius);background:var(--secondary-container);color:var(--on-secondary-container)}.ui-metric strong{font-size:1.35em}.ui-field{display:grid;gap:5px}input,select{width:100%;padding:11px;border:1px solid var(--outline);border-radius:var(--radius);background:var(--surface);color:var(--on-surface)}progress{accent-color:var(--primary);width:100%}hr{width:100%;border:0;border-top:1px solid var(--outline-variant)}.ui-spacer{min-height:12px}.ui-nav{border-top:1px solid var(--outline-variant)}",
348
+ "[hidden]{display:none!important}[data-action]{cursor:pointer}.runtime-inspector{margin:0 var(--space) var(--space);border-top:1px solid var(--outline-variant);padding-top:8px}.runtime-inspector pre{max-height:260px;overflow:auto;white-space:pre-wrap;font:12px/1.4 ui-monospace,monospace;color:var(--on-surface-variant)}",
113
349
  ].join("");
114
350
 
115
351
  export function renderPreviewHtml(manifest) {
@@ -117,9 +353,10 @@ export function renderPreviewHtml(manifest) {
117
353
  const screens = extensions.length
118
354
  ? extensions.map((extension) => {
119
355
  const config = extension.config;
120
- return '<article class="screen" style="' + themeVariables(config) + '">' +
356
+ const safeConfig = JSON.stringify(config).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026");
357
+ return '<article class="screen" data-extension="' + escape(extension.id) + '" style="' + themeVariables(config) + '">' +
121
358
  '<div class="screen-meta"><span>' + escape(extension.point) + "</span><strong>" + escape(extension.title) + "</strong></div>" +
122
- chromeHtml(config) + '<main class="screen-body">' + nodeHtml(config.view) + "</main></article>";
359
+ chromeHtml(config) + '<main class="screen-body">' + nodeHtml(config.view) + '</main><details class="runtime-inspector"><summary>Runtime Inspector</summary><pre></pre></details><script type="application/json" class="runtime-config">' + safeConfig + "</script></article>";
123
360
  }).join("")
124
361
  : '<article class="screen"><div class="ui-empty"><strong>UI extension 없음</strong><p>UI Runtime v2 화면을 추가하면 여기에 표시됩니다.</p></div></article>';
125
362
  const title = escape(manifest.name);
@@ -127,6 +364,6 @@ export function renderPreviewHtml(manifest) {
127
364
  title + " preview</title><style>" + CSS +
128
365
  '</style></head><body><input id="preview-dark" type="checkbox"><label class="theme-toggle" for="preview-dark">☀︎ / ☾</label><div class="preview"><header class="preview-head"><small>Morit UI Runtime v2 · 안전한 계약 Preview</small><h1>' +
129
366
  title + "</h1><p>" + escape(manifest.description || "") +
130
- '</p></header><section class="screens">' + screens + "</section></div></body></html>";
367
+ '</p></header><section class="screens">' + screens + "</section></div><script>" + RUNTIME_SCRIPT + "</script></body></html>";
131
368
  return Buffer.from(content, "utf8");
132
369
  }
package/src/workspace.js CHANGED
@@ -40,7 +40,7 @@ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
40
40
  const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|clock\$|com[1-9]|lpt[1-9])(?:\.|$)/i;
41
41
  const UI_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
42
42
  const UI_STATE_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
43
- const UI_BINDING = /^(?:state|data|item)(?:\.[A-Za-z0-9_-]+){0,12}$/;
43
+ const UI_BINDING = /^(?:state|data|item|context)(?:\.[A-Za-z0-9_-]+){0,12}$/;
44
44
  const UI_TEMPLATE_BINDING = /\{\{\s*([^{}]+?)\s*\}\}/g;
45
45
  const UI_SINGLE_TEMPLATE_BINDING = /^\{\{\s*([^{}]+?)\s*\}\}$/;
46
46
  const UI_ASSET_PATH = /^assets\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\.(?:gif|jpe?g|png|webp)$/i;
@@ -152,7 +152,7 @@ export class LocalWorkspace {
152
152
  version: "1.0.0",
153
153
  cloud_project_id: null,
154
154
  required_secrets: [],
155
- min_morit_version: "1.7.8",
155
+ min_morit_version: "1.7.12",
156
156
  max_morit_version: "1.999.999",
157
157
  permissions: [],
158
158
  capabilities: [],
@@ -813,6 +813,12 @@ function validateManifest(manifest, files) {
813
813
  throw new Error(`unsupported runtime adapter: ${adapter || "missing"}`);
814
814
  }
815
815
  validateRuntime(capability, runtime, permissions, credentials, connectors, dependencies);
816
+ for (const [field, schema] of [["input_schema", capability.input_schema], ["output_schema", capability.output_schema]]) {
817
+ if (schema === undefined) continue;
818
+ assertObject(schema, `capability ${capability.id} ${field}`);
819
+ if (Buffer.byteLength(JSON.stringify(schema), "utf8") > 16 * 1024) throw new Error(`capability ${capability.id} ${field} is too large`);
820
+ validateResponseSchema(schema, 0, true);
821
+ }
816
822
  if (adapter === "sandbox_python") {
817
823
  const entrypoint = normalizeEntrypointPath(runtime.entrypoint);
818
824
  if (!entrypoint.startsWith("src/") || !entrypoint.endsWith(".py")) throw new Error("sandbox_python entrypoint must be src/*.py");
@@ -1242,11 +1248,15 @@ function validateRuntimeReferences(capabilities, connectors, connectorIds, depen
1242
1248
  function validateUiConfig(extension, capabilities, routeIds, files, hasStorage = false) {
1243
1249
  const config = extension.config === undefined ? {} : extension.config;
1244
1250
  const executable = new Set(capabilities.filter((value) => ["tool", "skill", "provider", "notification"].includes(value.kind)).map((value) => value.id));
1251
+ const capabilitySchemas = new Map(capabilities.map((value) => [value.id, {
1252
+ input_schema: value.input_schema,
1253
+ output_schema: value.output_schema,
1254
+ }]));
1245
1255
  if (hasStorage) for (const capability of STORAGE_TOOLS) executable.add(capability);
1246
1256
  if (extension.point === "response" && config.ui_schema !== 2) throw new Error("response UI requires UI Runtime v2");
1247
1257
  if (config.ui_schema === 2) {
1248
1258
  if (config.placement != null && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
1249
- validateUiRuntimeV2(config, executable, routeIds);
1259
+ validateUiRuntimeV2(config, executable, routeIds, capabilitySchemas);
1250
1260
  validateResponseComponent(config, extension.point === "response");
1251
1261
  for (const asset of uiRuntimeAssetPaths(config.view)) {
1252
1262
  if (!(asset in files)) throw new Error(`UI image asset is not packaged: ${asset}`);
@@ -1376,7 +1386,7 @@ function validateLegacyUiForm(form, executable, extensionId) {
1376
1386
  }
1377
1387
  }
1378
1388
 
1379
- function validateUiRuntimeV2(config, executable, routes) {
1389
+ function validateUiRuntimeV2(config, executable, routes, capabilitySchemas = new Map()) {
1380
1390
  rejectUnknownFields(config, new Set(contract.ui_runtime.config_fields), "UI Runtime v2");
1381
1391
  if (config.ui_schema !== 2) throw new Error("UI Runtime version must be 2");
1382
1392
  validateUiIcon(config.icon);
@@ -1429,6 +1439,7 @@ function validateUiRuntimeV2(config, executable, routes) {
1429
1439
  validateUiNavigation(config.navigation, executable, routes, sourceIds, stateKeys);
1430
1440
  assertObject(config.view, "UI Runtime v2 view");
1431
1441
  validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys, null);
1442
+ validateUiCapabilityBindings(config, sources, capabilitySchemas);
1432
1443
  }
1433
1444
 
1434
1445
  function validateUiTheme(theme, variant = false) {
@@ -1600,7 +1611,7 @@ function validateUiNode(node, depth, counter, executable, routes, sources, state
1600
1611
  const props = node.props === undefined ? {} : node.props;
1601
1612
  validateUiProps(node.type, props, sources, stateKeys);
1602
1613
  validateUiCondition(node.visible_when, stateKeys, sources);
1603
- if (node.action != null && !["surface", "card", "button", "chip"].includes(node.type)) {
1614
+ if (node.action != null && !["surface", "card", "button", "chip", "form", "field", "select", "switch"].includes(node.type)) {
1604
1615
  throw new Error("UI action requires an interactive component");
1605
1616
  }
1606
1617
  validateUiAction(node.action, executable, routes, sources, stateKeys);
@@ -1633,7 +1644,7 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
1633
1644
  "border_radius", "elevation", "opacity", "clip", "enabled", "tooltip", "semantic_label", "exclude_semantics",
1634
1645
  ]);
1635
1646
  if (nodeType === "surface") rejectUnknownFields(props, surfaceProps, "surface UI component props");
1636
- for (const key of ["text", "title", "subtitle", "label", "supporting", "placeholder", "empty_text", "value", "tooltip", "semantic_label"]) {
1647
+ for (const key of ["text", "title", "subtitle", "label", "supporting", "placeholder", "empty_text", "value", "tooltip", "semantic_label", "error_text", "submit_label"]) {
1637
1648
  if (props[key] !== undefined) {
1638
1649
  optionalUiTemplate(props[key], 1200, true);
1639
1650
  validateTemplateReferences(props[key], stateKeys, sources);
@@ -1644,6 +1655,11 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
1644
1655
  }
1645
1656
  if (props.icon !== undefined) validateUiIcon(props.icon, true);
1646
1657
  if (props.max_lines !== undefined && (!Number.isInteger(props.max_lines) || props.max_lines < 1 || props.max_lines > 100)) throw new Error("UI numeric property max_lines is invalid");
1658
+ if (props.input_type !== undefined && !["text", "number", "email", "url"].includes(props.input_type)) throw new Error("UI input type is invalid");
1659
+ for (const key of ["min_length", "max_length"]) if (props[key] !== undefined && (!Number.isInteger(props[key]) || props[key] < 0 || props[key] > 2000)) throw new Error(`UI ${key} is invalid`);
1660
+ if ((props.min_length ?? 0) > (props.max_length ?? 2000)) throw new Error("UI text length constraints are invalid");
1661
+ for (const key of ["minimum", "maximum"]) if (props[key] !== undefined) validateUiNumber(props[key], `UI ${key}`, -1000000000, 1000000000);
1662
+ if ((props.minimum ?? -1000000000) > (props.maximum ?? 1000000000)) throw new Error("UI numeric constraints are invalid");
1647
1663
  for (const key of ["columns", "size", "limit"]) {
1648
1664
  if (props[key] !== undefined && (!Number.isInteger(props[key]) || props[key] < 0 || props[key] > 100)) throw new Error(`UI numeric property ${key} is invalid`);
1649
1665
  }
@@ -1668,7 +1684,7 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
1668
1684
  for (const key of ["left", "top", "right", "bottom"]) if (props[key] !== undefined) validateUiNumber(props[key], `UI position ${key}`, -4096, 4096);
1669
1685
  if (props.stack_at !== undefined && (!Number.isInteger(props.stack_at) || props.stack_at < 0 || props.stack_at > 1200)) throw new Error("UI stack_at is invalid");
1670
1686
  if (props.min_item_width !== undefined && (!Number.isInteger(props.min_item_width) || props.min_item_width < 96 || props.min_item_width > 600)) throw new Error("UI min_item_width is invalid");
1671
- for (const key of ["persist", "selected", "dense", "show_legend", "full_width", "exclude_semantics", "clip", "shrink_wrap", "enabled"]) if (props[key] !== undefined && typeof props[key] !== "boolean") throw new Error(`UI boolean property ${key} is invalid`);
1687
+ for (const key of ["persist", "selected", "dense", "show_legend", "full_width", "exclude_semantics", "clip", "shrink_wrap", "enabled", "required", "searchable"]) if (props[key] !== undefined && typeof props[key] !== "boolean") throw new Error(`UI boolean property ${key} is invalid`);
1672
1688
  if (props.fit !== undefined && !["contain", "cover", "fill", "fit_width", "fit_height", "none", "scale_down"].includes(props.fit)) throw new Error("UI image fit is invalid");
1673
1689
  if (props.scroll_direction !== undefined && !["vertical", "horizontal"].includes(props.scroll_direction)) throw new Error("UI scroll direction is invalid");
1674
1690
  if (["image", "avatar"].includes(nodeType)) validateUiImageSource(props, sources);
@@ -1688,6 +1704,11 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
1688
1704
  if (nodeType === "chart" && !["bar", "line", "donut", "scatter"].includes(chartType)) throw new Error("UI chart type is invalid");
1689
1705
  if (nodeType === "calendar" && props.state_key != null && !stateKeys.has(props.state_key)) throw new Error("UI calendar state is unknown");
1690
1706
  if (["field", "select", "switch"].includes(nodeType) && !stateKeys.has(props.state_key)) throw new Error("UI state field is unknown");
1707
+ if (props.suggestions !== undefined || props.suggestions_source !== undefined) {
1708
+ if (nodeType !== "field" || (props.suggestions !== undefined && props.suggestions_source !== undefined)) throw new Error("UI autocomplete options are invalid");
1709
+ if (props.suggestions !== undefined && (!Array.isArray(props.suggestions) || props.suggestions.length < 1 || props.suggestions.length > 32 || props.suggestions.some((value) => typeof value !== "string" || !value.trim() || value.length > 120))) throw new Error("UI autocomplete options are invalid");
1710
+ if (props.suggestions_source !== undefined) validateUiDataBinding(props.suggestions_source, sources, "autocomplete");
1711
+ }
1691
1712
  if (props.options != null) {
1692
1713
  if (nodeType !== "select" || !Array.isArray(props.options) || props.options.length < 1 || props.options.length > 32) throw new Error("UI select options are invalid");
1693
1714
  for (const option of props.options) {
@@ -1696,6 +1717,18 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
1696
1717
  optionalUiText(option.label, 80, { required: true });
1697
1718
  }
1698
1719
  }
1720
+ if (props.options !== undefined && props.options_source !== undefined) throw new Error("UI select cannot declare two options sources");
1721
+ if (props.options_source !== undefined) {
1722
+ if (nodeType !== "select") throw new Error("UI options source requires a select");
1723
+ validateUiDataBinding(props.options_source, sources, "select options");
1724
+ }
1725
+ if (props.searchable !== undefined && nodeType !== "select") throw new Error("UI searchable requires a select");
1726
+ for (const key of ["required", "min_length", "max_length", "minimum", "maximum", "error_text"]) if (props[key] !== undefined && !["field", "select", "switch"].includes(nodeType)) throw new Error("UI validation properties require an input");
1727
+ if (props.submit_label !== undefined && nodeType !== "form") throw new Error("UI submit label requires a form");
1728
+ }
1729
+
1730
+ function validateUiDataBinding(value, sources, label) {
1731
+ if (typeof value !== "string" || !UI_BINDING.test(value) || !value.startsWith("data.") || !sources.has(value.split(".")[1])) throw new Error(`UI ${label} source is invalid`);
1699
1732
  }
1700
1733
 
1701
1734
  function validateUiImageSource(props, sources) {
@@ -1737,8 +1770,21 @@ function validateUiInsets(value, label) {
1737
1770
  for (const item of Object.values(value)) validateUiNumber(item, label, 0, 128);
1738
1771
  }
1739
1772
 
1740
- function validateUiCondition(condition, stateKeys, sources) {
1773
+ function validateUiCondition(condition, stateKeys, sources, depth = 0, counter = { value: 0 }) {
1741
1774
  if (condition == null) return;
1775
+ assertObject(condition, "UI visibility condition");
1776
+ counter.value += 1;
1777
+ if (depth > 4 || counter.value > 32) throw new Error("UI visibility condition is too complex");
1778
+ const composites = ["all", "any", "not"].filter((key) => Object.hasOwn(condition, key));
1779
+ if (composites.length) {
1780
+ if (composites.length !== 1 || Object.keys(condition).length !== 1) throw new Error("UI visibility condition is invalid");
1781
+ const key = composites[0];
1782
+ if (key === "not") return validateUiCondition(condition.not, stateKeys, sources, depth + 1, counter);
1783
+ const children = condition[key];
1784
+ if (!Array.isArray(children) || children.length < 1 || children.length > 8) throw new Error("UI visibility condition group is invalid");
1785
+ for (const child of children) validateUiCondition(child, stateKeys, sources, depth + 1, counter);
1786
+ return;
1787
+ }
1742
1788
  rejectUnknownFields(condition, new Set(["path", "equals", "not_equals", "exists"]), "UI visibility condition");
1743
1789
  if (typeof condition.path !== "string" || !UI_BINDING.test(condition.path)) throw new Error("UI visibility path is invalid");
1744
1790
  validateKnownBinding(condition.path, stateKeys, sources);
@@ -1747,13 +1793,23 @@ function validateUiCondition(condition, stateKeys, sources) {
1747
1793
  if (checks[0] !== "exists") validateUiJson(condition[checks[0]], 0);
1748
1794
  }
1749
1795
 
1750
- function validateUiAction(action, executable, routes, sources, stateKeys, required = false) {
1796
+ function validateUiAction(action, executable, routes, sources, stateKeys, required = false, depth = 0, counter = { value: 0 }) {
1751
1797
  if (action == null) {
1752
1798
  if (required) throw new Error("UI action is required");
1753
1799
  return;
1754
1800
  }
1801
+ assertObject(action, "UI action");
1802
+ counter.value += 1;
1803
+ if (depth > 4 || counter.value > 16) throw new Error("UI action flow is too complex");
1755
1804
  rejectUnknownFields(action, new Set(contract.ui_runtime.action_fields), "UI action");
1756
- if (!["invoke", "navigate", "set_state", "refresh", "back"].includes(action.type)) throw new Error("UI action type is invalid");
1805
+ if (!["invoke", "navigate", "set_state", "refresh", "back", "flow"].includes(action.type)) throw new Error("UI action type is invalid");
1806
+ if (action.validate !== undefined && typeof action.validate !== "boolean") throw new Error("UI action validation flag is invalid");
1807
+ if (action.type === "flow") {
1808
+ rejectUnknownFields(action, new Set(["type", "mode", "actions", "continue_on_error", "validate"]), "UI action flow");
1809
+ if (!["sequential", "parallel"].includes(action.mode) || !Array.isArray(action.actions) || action.actions.length < 1 || action.actions.length > 8 || (action.continue_on_error !== undefined && typeof action.continue_on_error !== "boolean")) throw new Error("UI action flow is invalid");
1810
+ for (const child of action.actions) validateUiAction(child, executable, routes, sources, stateKeys, true, depth + 1, counter);
1811
+ return;
1812
+ }
1757
1813
  if (action.type === "invoke") {
1758
1814
  if (!executable.has(action.capability)) throw new Error("UI action targets a non-executable capability");
1759
1815
  optionalUiTemplate(action.query, 2000);
@@ -1768,6 +1824,11 @@ function validateUiAction(action, executable, routes, sources, stateKeys, requir
1768
1824
  if (!["platform", "fade", "slide", "none"].includes(transition)) throw new Error("UI navigation transition is invalid");
1769
1825
  const replace = action.replace === undefined ? false : action.replace;
1770
1826
  if (typeof replace !== "boolean") throw new Error("UI navigation replacement is invalid");
1827
+ const parameters = action.parameters === undefined ? {} : action.parameters;
1828
+ assertObject(parameters, "UI navigation parameters");
1829
+ validateUiBindingValue(parameters, 0, stateKeys, sources);
1830
+ if (action.result_state !== undefined && !stateKeys.has(action.result_state)) throw new Error("UI navigation result state is unknown");
1831
+ if (action.result_state !== undefined && replace) throw new Error("replacement navigation cannot return a result");
1771
1832
  } else if (action.type === "set_state") {
1772
1833
  assertObject(action.values, "UI state action values");
1773
1834
  if (!Object.keys(action.values).length || Object.keys(action.values).some((key) => !stateKeys.has(key))) throw new Error("UI state action targets an unknown state key");
@@ -1776,8 +1837,21 @@ function validateUiAction(action, executable, routes, sources, stateKeys, requir
1776
1837
  if (typeof persist !== "boolean") throw new Error("UI state persistence flag is invalid");
1777
1838
  } else if (action.type === "refresh") {
1778
1839
  if (!sources.has(action.source)) throw new Error("UI refresh source is unknown");
1779
- } else if (Object.keys(action).length !== 1) throw new Error("back UI action cannot contain arguments");
1840
+ } else {
1841
+ rejectUnknownFields(action, new Set(["type", "result"]), "back UI action");
1842
+ const result = action.result === undefined ? {} : action.result;
1843
+ assertObject(result, "UI route result");
1844
+ validateUiBindingValue(result, 0, stateKeys, sources);
1845
+ }
1780
1846
  if (action.type !== "navigate" && (action.transition !== undefined || action.replace !== undefined)) throw new Error("navigation options require a navigate UI action");
1847
+ const allowed = {
1848
+ invoke: new Set(["type", "capability", "query", "arguments", "store", "validate"]),
1849
+ navigate: new Set(["type", "target", "transition", "replace", "parameters", "result_state", "validate"]),
1850
+ set_state: new Set(["type", "values", "persist"]),
1851
+ refresh: new Set(["type", "source"]),
1852
+ back: new Set(["type", "result"]),
1853
+ }[action.type];
1854
+ rejectUnknownFields(action, allowed, "UI action");
1781
1855
  }
1782
1856
 
1783
1857
  function validateUiBindingValue(value, depth, stateKeys, sources) {
@@ -1818,6 +1892,73 @@ function validateUiJson(value, depth) {
1818
1892
  for (const item of Object.values(value)) validateUiJson(item, depth + 1);
1819
1893
  }
1820
1894
 
1895
+ function validateUiCapabilityBindings(config, sources, capabilitySchemas) {
1896
+ const sourceCapabilities = new Map(sources.map((source) => [source.id, source.capability]));
1897
+ for (const source of sources) {
1898
+ const schema = capabilitySchemas.get(source.capability)?.input_schema;
1899
+ if (schema !== undefined && !uiSchemaAccepts(schema, source.arguments === undefined ? {} : source.arguments)) {
1900
+ throw new Error(`UI data source ${source.id} does not match capability input_schema`);
1901
+ }
1902
+ }
1903
+ function visit(value) {
1904
+ if (typeof value === "string") {
1905
+ for (const match of value.matchAll(UI_TEMPLATE_BINDING)) {
1906
+ const parts = match[1].trim().split(".");
1907
+ if (parts.length < 4 || parts[0] !== "data" || parts[2] !== "data") continue;
1908
+ const schema = capabilitySchemas.get(sourceCapabilities.get(parts[1]))?.output_schema;
1909
+ if (schema !== undefined && !uiSchemaHasPath(schema, parts.slice(3))) throw new Error("UI binding references an unknown capability output");
1910
+ }
1911
+ return;
1912
+ }
1913
+ if (Array.isArray(value)) return value.forEach(visit);
1914
+ if (!value || typeof value !== "object") return;
1915
+ if (value.type === "invoke") {
1916
+ const schema = capabilitySchemas.get(value.capability)?.input_schema;
1917
+ if (schema !== undefined && !uiSchemaAccepts(schema, value.arguments === undefined ? {} : value.arguments)) throw new Error("UI action does not match capability input_schema");
1918
+ }
1919
+ Object.values(value).forEach(visit);
1920
+ }
1921
+ visit(config);
1922
+ }
1923
+
1924
+ function uiSchemaAccepts(schema, value) {
1925
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return false;
1926
+ if (typeof value === "string") {
1927
+ const exact = UI_SINGLE_TEMPLATE_BINDING.exec(value);
1928
+ if (exact) return true;
1929
+ if ([...value.matchAll(UI_TEMPLATE_BINDING)].length) return schema.type === "string";
1930
+ }
1931
+ if (schema.enum !== undefined && !schema.enum.some((item) => JSON.stringify(item) === JSON.stringify(value))) return false;
1932
+ if (schema.type === "null") return value === null;
1933
+ if (schema.type === "boolean") return typeof value === "boolean";
1934
+ if (schema.type === "integer") return Number.isInteger(value) && (schema.minimum === undefined || value >= schema.minimum) && (schema.maximum === undefined || value <= schema.maximum);
1935
+ if (schema.type === "number") return typeof value === "number" && Number.isFinite(value) && (schema.minimum === undefined || value >= schema.minimum) && (schema.maximum === undefined || value <= schema.maximum);
1936
+ if (schema.type === "string") return typeof value === "string" && value.length >= (schema.minLength ?? 0) && value.length <= (schema.maxLength ?? 2000);
1937
+ if (schema.type === "array") return Array.isArray(value) && value.length >= (schema.minItems ?? 0) && value.length <= (schema.maxItems ?? 64) && value.every((item) => uiSchemaAccepts(schema.items, item));
1938
+ if (schema.type !== "object" || !value || typeof value !== "object" || Array.isArray(value)) return false;
1939
+ const properties = schema.properties || {};
1940
+ if ((schema.required || []).some((key) => !Object.hasOwn(value, key))) return false;
1941
+ if (schema.additionalProperties === false && Object.keys(value).some((key) => !Object.hasOwn(properties, key))) return false;
1942
+ return Object.entries(value).every(([key, item]) => !Object.hasOwn(properties, key) || uiSchemaAccepts(properties[key], item));
1943
+ }
1944
+
1945
+ function uiSchemaHasPath(schema, parts) {
1946
+ let current = schema;
1947
+ for (const part of parts) {
1948
+ if (!current || typeof current !== "object" || Array.isArray(current)) return false;
1949
+ if (current.type === "array") {
1950
+ current = current.items;
1951
+ if (part === "length") return true;
1952
+ if (/^\d+$/.test(part)) continue;
1953
+ }
1954
+ if (!current || current.type !== "object") return false;
1955
+ const properties = current.properties || {};
1956
+ if (!Object.hasOwn(properties, part)) return current.additionalProperties !== false;
1957
+ current = properties[part];
1958
+ }
1959
+ return true;
1960
+ }
1961
+
1821
1962
  function optionalUiTemplate(value, maximum, required = false) {
1822
1963
  if (value === undefined || value === null) {
1823
1964
  if (required) throw new Error("UI text template is required");
@@ -1836,6 +1977,12 @@ function validateKnownBinding(path, stateKeys, sources) {
1836
1977
  const parts = path.split(".");
1837
1978
  if (parts[0] === "state" && (parts.length < 2 || !stateKeys.has(parts[1]))) throw new Error("UI state binding is unknown");
1838
1979
  if (parts[0] === "data" && (parts.length < 2 || !sources.has(parts[1]))) throw new Error("UI data binding is unknown");
1980
+ if (parts[0] === "context") {
1981
+ const roots = new Set(["platform", "screen", "permissions", "connections", "connected", "loading", "errors", "route"]);
1982
+ if (parts.length < 2 || !roots.has(parts[1])) throw new Error("UI runtime context binding is unknown");
1983
+ if (parts[1] === "screen" && (parts.length !== 3 || !["width", "height", "size_class"].includes(parts[2]))) throw new Error("UI screen context binding is unknown");
1984
+ if (["loading", "errors"].includes(parts[1]) && (parts.length !== 3 || !sources.has(parts[2]))) throw new Error("UI data-source context binding is unknown");
1985
+ }
1839
1986
  }
1840
1987
 
1841
1988
  function optionalUiText(value, maximum, { required = false, identifier = false } = {}) {