@oneciel-ai/ciel-runtime 0.2.9 → 0.2.10

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.
@@ -46,7 +46,25 @@ def channel_mcp_tool_schemas() -> list[dict[str, Any]]:
46
46
  "type": "object",
47
47
  "properties": {
48
48
  "channel": {"type": "string", "description": "Destination channel id from the incoming message."},
49
- "message": {"type": "string", "description": "Message body to send."},
49
+ "message": {"type": "string", "description": "Legacy unstructured message body."},
50
+ "response": {
51
+ "type": "object",
52
+ "description": "Structured Web Chat response. Prefer this for browser acknowledgements and replies.",
53
+ "properties": {
54
+ "spoken": {
55
+ "type": "string",
56
+ "description": "Short conversational text intended for speech synthesis.",
57
+ },
58
+ "overview": {
59
+ "type": "string",
60
+ "description": "Concise on-screen summary.",
61
+ },
62
+ "details": {
63
+ "type": "string",
64
+ "description": "Optional detailed supporting Markdown.",
65
+ },
66
+ },
67
+ },
50
68
  "recipients": {
51
69
  "description": "Recipient id, 'all', or an array of recipients. Use 'web' for /ca/web/chat replies."
52
70
  },
@@ -59,7 +77,11 @@ def channel_mcp_tool_schemas() -> list[dict[str, Any]]:
59
77
  },
60
78
  "kind": {"type": "string", "description": "Optional message kind, for example 'reply' or 'status'."},
61
79
  },
62
- "required": ["channel", "message"],
80
+ "required": ["channel"],
81
+ "anyOf": [
82
+ {"required": ["message"]},
83
+ {"required": ["response"]},
84
+ ],
63
85
  },
64
86
  },
65
87
  {
@@ -169,13 +191,41 @@ def _send_message(
169
191
  services: ChannelMcpToolServices,
170
192
  ) -> dict[str, Any]:
171
193
  channel = str(args.get("channel") or "").strip()
194
+ structured = _structured_response(args.get("response"))
172
195
  message = str(args.get("message") or args.get("text") or "").strip()
196
+ if structured and not message:
197
+ message = _structured_message_text(structured)
173
198
  if not channel or not message:
174
- return channel_mcp_tool_response(request_id, "send_message requires channel and message.", True)
175
- saved = services.append_message(_message_payload(args, channel, message, "reply"))
199
+ return channel_mcp_tool_response(
200
+ request_id,
201
+ "send_message requires channel and either message or a non-empty response object.",
202
+ True,
203
+ )
204
+ payload = _message_payload(args, channel, message, "reply")
205
+ if structured:
206
+ payload["meta"]["web_response"] = structured
207
+ saved = services.append_message(payload)
176
208
  return _json_response(request_id, {"ok": True, "message": saved})
177
209
 
178
210
 
211
+ def _structured_response(value: Any) -> dict[str, str]:
212
+ if not isinstance(value, dict):
213
+ return {}
214
+ response = {
215
+ key: str(value.get(key) or "").strip()
216
+ for key in ("spoken", "overview", "details")
217
+ }
218
+ return response if any(response.values()) else {}
219
+
220
+
221
+ def _structured_message_text(response: dict[str, str]) -> str:
222
+ overview = response.get("overview", "")
223
+ details = response.get("details", "")
224
+ if overview and details:
225
+ return f"{overview}\n\n{details}"
226
+ return overview or details or response.get("spoken", "")
227
+
228
+
179
229
  def _send_file(
180
230
  request_id: Any,
181
231
  args: dict[str, Any],
@@ -156,7 +156,8 @@ def _web_chat_reply_routes(messages: list[dict[str, Any]]) -> list[dict[str, str
156
156
  if route in seen:
157
157
  continue
158
158
  seen.add(route)
159
- routes.append({"channel": channel, "thread_id": thread})
159
+ input_mode = str(meta.get("input_mode") or "text").strip().lower()
160
+ routes.append({"channel": channel, "thread_id": thread, "input_mode": input_mode})
160
161
  return routes
161
162
 
162
163
 
@@ -166,12 +167,21 @@ def _web_chat_reply_instruction(messages: list[dict[str, Any]]) -> str:
166
167
  return ""
167
168
  encoded_routes = json.dumps(routes, ensure_ascii=False, separators=(",", ":"))
168
169
  return (
169
- "[ciel-runtime web reply required] Do not leave the answer only in the terminal. "
170
- "After completing the answer, call MCP server `ciel-runtime-router` tool "
171
- "`send_message` once for each route in "
172
- f"{encoded_routes}, with that channel and thread_id, recipients=[\"web\"], "
173
- "delivery=[\"web\"], kind=\"reply\", and message set to the complete answer. "
174
- "Use `send_file` on the same route for files."
170
+ "[ciel-runtime web reply required] Do not leave responses only in the terminal. "
171
+ "For each route in "
172
+ f"{encoded_routes}: (1) immediately acknowledge the request with one short, honest sentence by calling "
173
+ "MCP server `ciel-runtime-router` tool `send_message` using that channel and thread_id, "
174
+ "recipients=[\"web\"], delivery=[\"web\"], kind=\"ack\", and "
175
+ "response={\"spoken\":\"brief conversational acknowledgement\","
176
+ "\"overview\":\"brief acknowledgement\",\"details\":\"\"}. "
177
+ "Do not claim completion in the acknowledgement. (2) Perform the requested work and then call "
178
+ "`send_message` again with kind=\"reply\" and a structured response object: "
179
+ "response={\"spoken\":\"one to three short conversational sentences suitable for TTS\","
180
+ "\"overview\":\"concise screen summary\","
181
+ "\"details\":\"supporting Markdown only when useful, otherwise empty\"}. "
182
+ "For input_mode=voice, spoken is required and must avoid Markdown, URLs, code, tables, and long lists; "
183
+ "the browser speaks only this field. Keep overview compact and put evidence, commands, links, and "
184
+ "technical detail in details. Use `send_file` on the same route for files."
175
185
  )
176
186
 
177
187
 
@@ -93,7 +93,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
93
93
  }
94
94
 
95
95
  APP_NAME = "Ciel Runtime"
96
- VERSION = "0.2.9"
96
+ VERSION = "0.2.10"
97
97
  CREDITS = "Credits: One Ciel LLC"
98
98
  PRELAUNCH_CANCEL = 10
99
99
  PRELAUNCH_LAUNCH_CODEX = 11
@@ -115,6 +115,13 @@ def render_web_chat_page(
115
115
  .recording {{ border-color: #ef4444 !important; color: #fecaca !important; }}
116
116
  .message-actions {{ display: flex; align-items: flex-start; padding: 4px; }}
117
117
  .message-actions button {{ border: 1px solid var(--line); border-radius: 999px; background: #0b111b; color: var(--muted); cursor: pointer; padding: 4px 8px; }}
118
+ .structured-response {{ display: grid; gap: 10px; }}
119
+ .response-section {{ display: grid; gap: 4px; }}
120
+ .response-section + .response-section {{ border-top: 1px solid rgba(255,255,255,.1); padding-top: 9px; }}
121
+ .response-label {{ color: #9fb1c8; font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }}
122
+ .response-spoken {{ color: #f0fdfa; }}
123
+ .live-transcript {{ display: none; width: 100%; border: 1px solid #315b66; border-radius: 6px; background: #0d1d25; color: #bcecf3; padding: 7px 9px; font-size: 13px; }}
124
+ .live-transcript.active {{ display: block; }}
118
125
  dialog {{ width: min(720px, calc(100vw - 28px)); max-height: calc(100vh - 28px); overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); padding: 0; }}
119
126
  dialog::backdrop {{ background: rgba(0,0,0,.72); }}
120
127
  .settings-head {{ position: sticky; top: 0; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); background: var(--panel); }}
@@ -197,6 +204,7 @@ def render_web_chat_page(
197
204
  <input id="fileInput" type="file" multiple>
198
205
  <div class="attachment-tray" id="attachmentTray" aria-live="polite"></div>
199
206
  </div>
207
+ <div class="live-transcript" id="liveTranscript" aria-live="polite"></div>
200
208
  <div class="hint">Enter sends. Shift+Enter inserts a new line. Live voice detects the end of each utterance, transcribes and sends it automatically, and supports interruption while TTS is speaking. The active coding-agent session handles the message, so its configured tools and MCP servers remain available.</div>
201
209
  </form>
202
210
  </main>
@@ -269,6 +277,7 @@ def render_web_chat_page(
269
277
  const micButton = document.getElementById('micButton');
270
278
  const fileInput = document.getElementById('fileInput');
271
279
  const attachmentTray = document.getElementById('attachmentTray');
280
+ const liveTranscript = document.getElementById('liveTranscript');
272
281
  const shareButton = document.getElementById('shareButton');
273
282
  const clearButton = document.getElementById('clearButton');
274
283
  const speechSettingsButton = document.getElementById('speechSettingsButton');
@@ -316,6 +325,9 @@ def render_web_chat_page(
316
325
  let vadVoicedSamples = 0;
317
326
  let vadNoiseFloor = 0.006;
318
327
  let liveTranscriptionQueue = Promise.resolve();
328
+ let livePartialInFlight = false;
329
+ let livePartialLastAt = 0;
330
+ let liveUtteranceSerial = 0;
319
331
  let activeSpeechAudio = null;
320
332
  let activeSpeechUrl = '';
321
333
  let speechGenerationController = null;
@@ -521,6 +533,58 @@ def render_web_chat_page(
521
533
  }}
522
534
  return bubble;
523
535
  }}
536
+ function structuredWebResponse(message) {{
537
+ const value = message && message.meta && message.meta.web_response;
538
+ if (!value || typeof value !== 'object') return null;
539
+ const response = {{
540
+ spoken: String(value.spoken || '').trim(),
541
+ overview: String(value.overview || '').trim(),
542
+ details: String(value.details || '').trim(),
543
+ }};
544
+ return response.spoken || response.overview || response.details ? response : null;
545
+ }}
546
+ function addStructuredBubble(response, mode = 'append', id = null) {{
547
+ if (id !== null && id !== undefined) {{
548
+ const key = String(id);
549
+ if (renderedIds.has(key)) return null;
550
+ renderedIds.add(key);
551
+ }}
552
+ const row = document.createElement('div');
553
+ row.className = 'row assistant';
554
+ const bubble = document.createElement('div');
555
+ bubble.className = 'bubble structured-response';
556
+ const sections = [
557
+ ['Voice', response.spoken, 'response-spoken'],
558
+ ['Overview', response.overview, 'markdown'],
559
+ ['Details', response.details, 'markdown'],
560
+ ];
561
+ sections.forEach(([labelText, value, className]) => {{
562
+ if (!value) return;
563
+ const section = document.createElement('section');
564
+ section.className = 'response-section ' + className;
565
+ const label = document.createElement('div');
566
+ label.className = 'response-label';
567
+ label.textContent = labelText;
568
+ const content = document.createElement('div');
569
+ if (className === 'markdown') content.innerHTML = renderMarkdown(value);
570
+ else content.textContent = value;
571
+ section.appendChild(label);
572
+ section.appendChild(content);
573
+ bubble.appendChild(section);
574
+ }});
575
+ row.appendChild(bubble);
576
+ const actions = document.createElement('div');
577
+ actions.className = 'message-actions';
578
+ const speak = document.createElement('button');
579
+ speak.type = 'button';
580
+ speak.textContent = 'Speak';
581
+ speak.addEventListener('click', () => speakText(response.spoken || response.overview));
582
+ actions.appendChild(speak);
583
+ row.appendChild(actions);
584
+ if (mode === 'prepend') transcript.insertBefore(row, transcript.firstChild);
585
+ else {{ transcript.appendChild(row); transcript.scrollTop = transcript.scrollHeight; }}
586
+ return bubble;
587
+ }}
524
588
  function rememberLastId(id) {{
525
589
  const numeric = Number(id || 0) || 0;
526
590
  if (numeric > lastId) {{
@@ -534,11 +598,14 @@ def render_web_chat_page(
534
598
  function renderIncomingMessage(message, mode = 'append') {{
535
599
  if (mode !== 'prepend') rememberLastId(message.id);
536
600
  const text = message.message || '';
537
- if (!text.trim()) return;
538
- addBubble(roleForMessage(message), text, mode, message.id);
601
+ const structured = structuredWebResponse(message);
602
+ if (!text.trim() && !structured) return;
603
+ if (structured && roleForMessage(message) === 'assistant') addStructuredBubble(structured, mode, message.id);
604
+ else addBubble(roleForMessage(message), text, mode, message.id);
539
605
  if (mode !== 'prepend' && message.sender_id !== 'web-user') {{
540
606
  setState('reply received', 'ok');
541
- if (speechConfig.tts && speechConfig.tts.enabled && (speechConfig.tts.auto_speak || liveVoiceEnabled)) speakText(text);
607
+ const speechText = structured ? (structured.spoken || structured.overview) : text;
608
+ if (speechConfig.tts && speechConfig.tts.enabled && speechText && (speechConfig.tts.auto_speak || liveVoiceEnabled)) speakText(speechText);
542
609
  }}
543
610
  }}
544
611
  function formatBytes(bytes) {{
@@ -712,17 +779,21 @@ def render_web_chat_page(
712
779
  setState('generating speech');
713
780
  const response = await fetch('/v1/audio/speech', {{
714
781
  method: 'POST',
715
- headers: {{'content-type': 'application/json'}},
782
+ headers: {{'content-type': 'application/json', 'accept': 'audio/*'}},
716
783
  body: JSON.stringify({{input: String(text || ''), model: speechConfig.tts.model, voice: speechConfig.tts.voice, language: speechConfig.tts.language, response_format: speechConfig.tts.response_format || 'wav'}}),
717
784
  signal: controller.signal,
718
785
  }});
719
786
  if (!response.ok) throw new Error(await response.text() || `HTTP ${{response.status}}`);
720
787
  const blob = await response.blob();
788
+ if (!blob.size) throw new Error('TTS returned empty audio');
789
+ if (blob.type && !blob.type.startsWith('audio/')) throw new Error(`TTS returned ${{blob.type}} instead of audio`);
721
790
  if (controller.signal.aborted) return;
722
791
  speechGenerationController = null;
723
792
  activeSpeechUrl = URL.createObjectURL(blob);
724
793
  activeSpeechAudio = new Audio(activeSpeechUrl);
725
794
  const audio = activeSpeechAudio;
795
+ audio.preload = 'auto';
796
+ audio.playsInline = true;
726
797
  const cleanup = () => {{
727
798
  if (activeSpeechAudio === audio) {{
728
799
  activeSpeechAudio = null;
@@ -742,13 +813,14 @@ def render_web_chat_page(
742
813
  addBubble('system', 'TTS failed: ' + String(err && err.message ? err.message : err));
743
814
  }}
744
815
  }}
745
- async function transcribeRecording(blob, populatePrompt = true) {{
746
- setState('transcribing');
816
+ async function transcribeRecording(blob, populatePrompt = true, options = {{}}) {{
817
+ if (!options.quiet) setState('transcribing');
747
818
  const audio_base64 = await fileToBase64(blob);
748
819
  const response = await fetch('/v1/audio/transcriptions', {{
749
820
  method: 'POST',
750
821
  headers: {{'content-type': 'application/json', 'accept': 'application/json'}},
751
- body: JSON.stringify({{audio_base64, filename: 'web-chat-recording.wav', content_type: blob.type || 'audio/wav', model: speechConfig.asr.model, language: speechConfig.asr.language}})
822
+ body: JSON.stringify({{audio_base64, filename: 'web-chat-recording.wav', content_type: blob.type || 'audio/wav', model: speechConfig.asr.model, language: speechConfig.asr.language}}),
823
+ signal: options.signal,
752
824
  }});
753
825
  const text = await response.text();
754
826
  let data = {{}};
@@ -760,7 +832,7 @@ def render_web_chat_page(
760
832
  prompt.value = prompt.value ? prompt.value + ' ' + transcriptText : transcriptText;
761
833
  prompt.focus();
762
834
  }}
763
- setState('transcribed', 'ok');
835
+ if (!options.quiet) setState('transcribed', 'ok');
764
836
  return transcriptText;
765
837
  }}
766
838
  function encodePcmWav(chunks, sampleRate) {{
@@ -798,13 +870,35 @@ def render_web_chat_page(
798
870
  vadLastVoiceAt = 0;
799
871
  vadVoicedSamples = 0;
800
872
  }}
801
- function queueLiveUtterance(chunks, sampleRate) {{
873
+ function setLiveTranscript(text, active = true) {{
874
+ liveTranscript.textContent = text;
875
+ liveTranscript.classList.toggle('active', Boolean(active && text));
876
+ }}
877
+ function requestLivePartial(now) {{
878
+ if (!vadSpeechActive || livePartialInFlight || !audioContext) return;
879
+ if (now - livePartialLastAt < 1200 || now - vadSpeechStartedAt < 900) return;
880
+ const serial = liveUtteranceSerial;
881
+ const blob = encodePcmWav(vadSpeechChunks.slice(), audioContext.sampleRate);
882
+ livePartialInFlight = true;
883
+ livePartialLastAt = now;
884
+ transcribeRecording(blob, false, {{quiet: true}}).then(text => {{
885
+ if (liveVoiceEnabled && vadSpeechActive && serial === liveUtteranceSerial) {{
886
+ setLiveTranscript('Live: ' + text);
887
+ }}
888
+ }}).catch(() => {{
889
+ // Partial transcription is best-effort; final transcription reports actionable errors.
890
+ }}).finally(() => {{
891
+ livePartialInFlight = false;
892
+ }});
893
+ }}
894
+ function queueLiveUtterance(chunks, sampleRate, serial) {{
802
895
  if (!chunks.length) return;
803
896
  const blob = encodePcmWav(chunks, sampleRate);
804
897
  liveTranscriptionQueue = liveTranscriptionQueue.then(async () => {{
805
898
  const transcriptText = await transcribeRecording(blob, false);
899
+ if (serial === liveUtteranceSerial) setLiveTranscript('Heard: ' + transcriptText);
806
900
  setState('sending voice');
807
- await sendMessage(transcriptText, []);
901
+ await sendMessage(transcriptText, [], {{inputMode: 'voice'}});
808
902
  if (liveVoiceEnabled) setState('listening', 'ok');
809
903
  }}).catch(err => {{
810
904
  setState('STT error', 'error');
@@ -814,9 +908,12 @@ def render_web_chat_page(
814
908
  function finishVadUtterance() {{
815
909
  const chunks = vadSpeechChunks.slice();
816
910
  const sampleRate = audioContext ? audioContext.sampleRate : 48000;
911
+ const serial = liveUtteranceSerial;
912
+ const partialText = liveTranscript.textContent.replace(/^Live:\\s*/, '');
913
+ setLiveTranscript(partialText ? 'Finalizing: ' + partialText : 'Finalizing speech...');
817
914
  resetVadUtterance();
818
915
  vadPreRollChunks = [];
819
- queueLiveUtterance(chunks, sampleRate);
916
+ queueLiveUtterance(chunks, sampleRate, serial);
820
917
  }}
821
918
  function processVadFrame(event) {{
822
919
  if (!liveVoiceEnabled || !audioContext) return;
@@ -838,12 +935,15 @@ def render_web_chat_page(
838
935
  return;
839
936
  }}
840
937
  vadSpeechActive = true;
938
+ liveUtteranceSerial += 1;
939
+ livePartialLastAt = now;
841
940
  vadSpeechStartedAt = now;
842
941
  vadLastVoiceAt = now;
843
942
  vadVoicedSamples = chunk.length;
844
943
  vadSpeechChunks = vadPreRollChunks.concat([chunk]);
845
944
  vadPreRollChunks = [];
846
945
  stopActiveSpeech();
946
+ setLiveTranscript('Listening to speech...');
847
947
  setState('hearing speech', 'ok');
848
948
  return;
849
949
  }}
@@ -856,9 +956,10 @@ def render_web_chat_page(
856
956
  const minSpeechMs = Number((speechConfig.asr && speechConfig.asr.min_speech_ms) || 300);
857
957
  const voicedMs = vadVoicedSamples * 1000 / audioContext.sampleRate;
858
958
  const utteranceMs = now - vadSpeechStartedAt;
959
+ requestLivePartial(now);
859
960
  if (now - vadLastVoiceAt >= silenceMs) {{
860
961
  if (voicedMs >= minSpeechMs) finishVadUtterance();
861
- else resetVadUtterance();
962
+ else {{ resetVadUtterance(); setLiveTranscript('Listening...', liveVoiceEnabled); }}
862
963
  }} else if (utteranceMs >= 30000) {{
863
964
  finishVadUtterance();
864
965
  }}
@@ -881,6 +982,7 @@ def render_web_chat_page(
881
982
  audioProcessor.connect(audioContext.destination);
882
983
  micButton.textContent = 'Stop live voice';
883
984
  micButton.classList.add('recording');
985
+ setLiveTranscript('Listening...');
884
986
  setState('listening', 'ok');
885
987
  }}
886
988
  async function stopVoiceInput() {{
@@ -901,6 +1003,7 @@ def render_web_chat_page(
901
1003
  audioContext = null;
902
1004
  mediaStream = null;
903
1005
  stopActiveSpeech();
1006
+ setLiveTranscript('', false);
904
1007
  micButton.textContent = 'Start live voice';
905
1008
  micButton.classList.remove('recording');
906
1009
  setState('ready');
@@ -1033,7 +1136,7 @@ def render_web_chat_page(
1033
1136
  setTimeout(startChannelStream, 1200);
1034
1137
  }};
1035
1138
  }}
1036
- async function sendMessage(text, files = []) {{
1139
+ async function sendMessage(text, files = [], options = {{}}) {{
1037
1140
  setState('queued');
1038
1141
  sendButton.disabled = true;
1039
1142
  attachButton.disabled = true;
@@ -1054,9 +1157,11 @@ def render_web_chat_page(
1054
1157
  meta: {{
1055
1158
  source: 'ciel-runtime-web-chat',
1056
1159
  web_chat_session: sessionId,
1160
+ input_mode: options.inputMode || 'text',
1057
1161
  reply_channel: channel,
1058
1162
  reply_recipient: 'web',
1059
- reply_instruction: 'Use the ciel-runtime-router send_message tool to answer this browser chat on the same channel/thread_id with recipients web and delivery web. Use send_file when returning a file attachment to this browser chat.',
1163
+ response_contract: {{version: 1, fields: ['spoken', 'overview', 'details'], tts_field: 'spoken'}},
1164
+ reply_instruction: 'Acknowledge briefly first, then use the ciel-runtime-router send_message tool with response.spoken, response.overview, and optional response.details. The browser speaks only response.spoken. Use send_file when returning a file attachment.',
1060
1165
  attachments: uploads
1061
1166
  }}
1062
1167
  }})
@@ -26,7 +26,11 @@ Colab sessions are ephemeral. Re-run the bootstrap after a runtime reset. The wo
26
26
 
27
27
  ## Live voice
28
28
 
29
- Web Chat's **Start live voice** button keeps the microphone open and uses browser-side voice activity detection (VAD). A completed utterance is encoded as PCM WAV, transcribed, and sent to the active coding-agent session automatically. While MOSS TTS is generating or playing a reply, new speech stops it immediately and starts a new utterance (barge-in). Tune end-of-speech silence, minimum speech duration, and the VAD threshold in Speech Settings. This is turn-based low-latency voice for the Colab HTTP workers; token-level streaming ASR/TTS requires workers with streaming protocols.
29
+ Web Chat's **Start live voice** button keeps the microphone open and uses browser-side voice activity detection (VAD). While the user is speaking, the browser sends rate-limited snapshots of the growing utterance to the Qwen worker and displays the latest best-effort partial transcript. A completed utterance is encoded as PCM WAV, transcribed once more for the final text, and sent to the active coding-agent session automatically. While MOSS TTS is generating or playing a reply, new speech stops it immediately and starts a new utterance (barge-in). Tune end-of-speech silence, minimum speech duration, and the VAD threshold in Speech Settings.
30
+
31
+ The Colab Qwen endpoint remains a batch HTTP API, so the live caption is progressive re-transcription rather than token-level server streaming. A future WebSocket or streaming HTTP worker can replace this transport without changing the final-turn behavior.
32
+
33
+ Web Chat requests carry an input mode and a structured response contract. The active agent first sends a short acknowledgement and then a final response containing `spoken`, `overview`, and optional `details` fields. The browser renders the fields separately and sends only `spoken` to TTS, avoiding long Markdown, URLs, code, and tables in synthesized speech. Legacy plain `message` replies remain supported.
30
34
 
31
35
  ## API surface
32
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/ciel-runtime",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Universal AI coding-agent runtime and model-routing layer for Claude, Codex, AGY, and compatible runtimes.",
5
5
  "license": "MIT",
6
6
  "author": "One Ciel LLC",