@oneciel-ai/ciel-runtime 0.2.8 → 0.2.9

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.
@@ -42,6 +42,9 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
42
42
  "endpoint": "/v1/audio/transcriptions",
43
43
  "model": "Qwen/Qwen3-ASR-0.6B",
44
44
  "language": "auto",
45
+ "silence_ms": 900,
46
+ "min_speech_ms": 300,
47
+ "vad_threshold": 0.018,
45
48
  "api_key": "",
46
49
  "timeout_seconds": 300,
47
50
  },
@@ -93,7 +93,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
93
93
  }
94
94
 
95
95
  APP_NAME = "Ciel Runtime"
96
- VERSION = "0.2.8"
96
+ VERSION = "0.2.9"
97
97
  CREDITS = "Credits: One Ciel LLC"
98
98
  PRELAUNCH_CANCEL = 10
99
99
  PRELAUNCH_LAUNCH_CODEX = 11
@@ -167,7 +167,7 @@ class SpeechHttpController:
167
167
  @staticmethod
168
168
  def _validated_value(service: str, key: str, value: Any) -> Any:
169
169
  allowed = {
170
- "asr": {"enabled", "base_url", "endpoint", "model", "language", "api_key", "timeout_seconds"},
170
+ "asr": {"enabled", "base_url", "endpoint", "model", "language", "silence_ms", "min_speech_ms", "vad_threshold", "api_key", "timeout_seconds"},
171
171
  "tts": {"enabled", "base_url", "endpoint", "voices_endpoint", "model", "voice", "language", "ref_audio", "ref_text", "response_format", "speed", "auto_speak", "api_key", "timeout_seconds"},
172
172
  }
173
173
  if key not in allowed[service]:
@@ -178,6 +178,12 @@ class SpeechHttpController:
178
178
  return max(1, min(3600, int(value)))
179
179
  if key == "speed":
180
180
  return max(0.25, min(4.0, float(value)))
181
+ if key == "silence_ms":
182
+ return max(250, min(3000, int(value)))
183
+ if key == "min_speech_ms":
184
+ return max(100, min(2000, int(value)))
185
+ if key == "vad_threshold":
186
+ return max(0.005, min(0.2, float(value)))
181
187
  text = str(value or "").strip()
182
188
  if key == "ref_audio":
183
189
  if len(text) > 14_000_000:
@@ -192,12 +192,12 @@ def render_web_chat_page(
192
192
  <button class="primary" id="sendButton" type="submit">Send</button>
193
193
  </div>
194
194
  <div class="composer-actions">
195
- <button class="attach-button" id="micButton" type="button">Start voice input</button>
195
+ <button class="attach-button" id="micButton" type="button">Start live voice</button>
196
196
  <button class="attach-button" id="attachButton" type="button">Attach files</button>
197
197
  <input id="fileInput" type="file" multiple>
198
198
  <div class="attachment-tray" id="attachmentTray" aria-live="polite"></div>
199
199
  </div>
200
- <div class="hint">Enter sends. Shift+Enter inserts a new line. The active coding-agent session handles the message, so its configured tools and MCP servers remain available. If replies stay queued, restart Ciel Runtime so the session wake bridge wraps the terminal.</div>
200
+ <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
201
  </form>
202
202
  </main>
203
203
  </div>
@@ -210,6 +210,9 @@ def render_web_chat_page(
210
210
  <div class="settings-grid">
211
211
  <label class="check"><input id="asrEnabled" type="checkbox"> Enable STT</label>
212
212
  <label>Language<input id="asrLanguage" placeholder="auto"></label>
213
+ <label>End silence (ms)<input id="asrSilenceMs" type="number" min="250" max="3000" step="50"></label>
214
+ <label>Minimum speech (ms)<input id="asrMinSpeechMs" type="number" min="100" max="2000" step="50"></label>
215
+ <label>VAD threshold<input id="asrVadThreshold" type="number" min="0.005" max="0.2" step="0.001"></label>
213
216
  <label class="wide">Tailscale base URL<input id="asrBaseUrl" placeholder="http://ciel-asr:8000"></label>
214
217
  <label class="wide">Model<input id="asrModel" placeholder="Qwen/Qwen3-ASR-0.6B"></label>
215
218
  <label class="wide">Remote bearer token<input id="asrApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
@@ -300,14 +303,22 @@ def render_web_chat_page(
300
303
  let eventSource = null;
301
304
  let selectedFiles = [];
302
305
  let speechConfig = {{asr: {{enabled: false}}, tts: {{enabled: false, auto_speak: false}}}};
303
- let mediaRecorder = null;
304
306
  let mediaStream = null;
305
- let recordingChunks = [];
306
307
  let audioContext = null;
307
308
  let audioInput = null;
308
309
  let audioProcessor = null;
309
- let pcmChunks = [];
310
- let voiceRecording = false;
310
+ let liveVoiceEnabled = false;
311
+ let vadSpeechActive = false;
312
+ let vadSpeechChunks = [];
313
+ let vadPreRollChunks = [];
314
+ let vadSpeechStartedAt = 0;
315
+ let vadLastVoiceAt = 0;
316
+ let vadVoicedSamples = 0;
317
+ let vadNoiseFloor = 0.006;
318
+ let liveTranscriptionQueue = Promise.resolve();
319
+ let activeSpeechAudio = null;
320
+ let activeSpeechUrl = '';
321
+ let speechGenerationController = null;
311
322
  let pendingTtsReferenceAudio = '';
312
323
  function setState(text, cls = '') {{
313
324
  statePill.textContent = text;
@@ -527,7 +538,7 @@ def render_web_chat_page(
527
538
  addBubble(roleForMessage(message), text, mode, message.id);
528
539
  if (mode !== 'prepend' && message.sender_id !== 'web-user') {{
529
540
  setState('reply received', 'ok');
530
- if (speechConfig.tts && speechConfig.tts.enabled && speechConfig.tts.auto_speak) speakText(text);
541
+ if (speechConfig.tts && speechConfig.tts.enabled && (speechConfig.tts.auto_speak || liveVoiceEnabled)) speakText(text);
531
542
  }}
532
543
  }}
533
544
  function formatBytes(bytes) {{
@@ -592,6 +603,9 @@ def render_web_chat_page(
592
603
  document.getElementById('asrBaseUrl').value = asr.base_url || '';
593
604
  document.getElementById('asrModel').value = asr.model || '';
594
605
  document.getElementById('asrLanguage').value = asr.language || 'auto';
606
+ document.getElementById('asrSilenceMs').value = asr.silence_ms || 900;
607
+ document.getElementById('asrMinSpeechMs').value = asr.min_speech_ms || 300;
608
+ document.getElementById('asrVadThreshold').value = asr.vad_threshold || 0.018;
595
609
  document.getElementById('asrApiKey').value = '';
596
610
  document.getElementById('asrApiKey').placeholder = asr.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
597
611
  document.getElementById('ttsEnabled').checked = Boolean(tts.enabled);
@@ -618,7 +632,7 @@ def render_web_chat_page(
618
632
  document.getElementById('tailscaleAsrHostname').value = tailscale.asr_hostname || 'ciel-asr';
619
633
  document.getElementById('tailscaleTtsHostname').value = tailscale.tts_hostname || 'ciel-tts';
620
634
  micButton.disabled = !asr.enabled;
621
- micButton.title = asr.enabled ? 'Record speech and transcribe it' : 'Enable STT in Speech Settings first';
635
+ micButton.title = asr.enabled ? 'Continuously detect, transcribe, and send speech' : 'Enable STT in Speech Settings first';
622
636
  }}
623
637
  async function loadSpeechConfig() {{
624
638
  const response = await fetch('/ca/speech/config', {{headers: {{'accept': 'application/json'}}}});
@@ -635,6 +649,9 @@ def render_web_chat_page(
635
649
  base_url: document.getElementById('asrBaseUrl').value,
636
650
  model: document.getElementById('asrModel').value,
637
651
  language: document.getElementById('asrLanguage').value,
652
+ silence_ms: Number(document.getElementById('asrSilenceMs').value || 900),
653
+ min_speech_ms: Number(document.getElementById('asrMinSpeechMs').value || 300),
654
+ vad_threshold: Number(document.getElementById('asrVadThreshold').value || 0.018),
638
655
  api_key: document.getElementById('asrApiKey').value,
639
656
  }},
640
657
  tts: {{
@@ -671,38 +688,67 @@ def render_web_chat_page(
671
688
  setSpeechForm(data);
672
689
  return data;
673
690
  }}
691
+ function stopActiveSpeech() {{
692
+ if (speechGenerationController) speechGenerationController.abort();
693
+ speechGenerationController = null;
694
+ if (activeSpeechAudio) {{
695
+ activeSpeechAudio.pause();
696
+ activeSpeechAudio.currentTime = 0;
697
+ }}
698
+ activeSpeechAudio = null;
699
+ if (activeSpeechUrl) URL.revokeObjectURL(activeSpeechUrl);
700
+ activeSpeechUrl = '';
701
+ }}
674
702
  async function speakText(text) {{
675
703
  if (!speechConfig.tts || !speechConfig.tts.enabled) {{
676
704
  setState('TTS disabled', 'error');
677
705
  return;
678
706
  }}
707
+ if (liveVoiceEnabled && vadSpeechActive) return;
708
+ stopActiveSpeech();
709
+ const controller = new AbortController();
710
+ speechGenerationController = controller;
679
711
  try {{
680
712
  setState('generating speech');
681
713
  const response = await fetch('/v1/audio/speech', {{
682
714
  method: 'POST',
683
715
  headers: {{'content-type': 'application/json'}},
684
- 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'}})
716
+ 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
+ signal: controller.signal,
685
718
  }});
686
719
  if (!response.ok) throw new Error(await response.text() || `HTTP ${{response.status}}`);
687
720
  const blob = await response.blob();
688
- const url = URL.createObjectURL(blob);
689
- const audio = new Audio(url);
690
- audio.addEventListener('ended', () => URL.revokeObjectURL(url), {{once: true}});
691
- audio.addEventListener('error', () => URL.revokeObjectURL(url), {{once: true}});
721
+ if (controller.signal.aborted) return;
722
+ speechGenerationController = null;
723
+ activeSpeechUrl = URL.createObjectURL(blob);
724
+ activeSpeechAudio = new Audio(activeSpeechUrl);
725
+ const audio = activeSpeechAudio;
726
+ const cleanup = () => {{
727
+ if (activeSpeechAudio === audio) {{
728
+ activeSpeechAudio = null;
729
+ if (activeSpeechUrl) URL.revokeObjectURL(activeSpeechUrl);
730
+ activeSpeechUrl = '';
731
+ if (liveVoiceEnabled) setState('listening', 'ok');
732
+ }}
733
+ }};
734
+ audio.addEventListener('ended', cleanup, {{once: true}});
735
+ audio.addEventListener('error', cleanup, {{once: true}});
692
736
  await audio.play();
693
737
  setState('speaking', 'ok');
694
738
  }} catch (err) {{
739
+ if (err && err.name === 'AbortError') return;
740
+ speechGenerationController = null;
695
741
  setState('TTS error', 'error');
696
742
  addBubble('system', 'TTS failed: ' + String(err && err.message ? err.message : err));
697
743
  }}
698
744
  }}
699
- async function transcribeRecording(blob) {{
745
+ async function transcribeRecording(blob, populatePrompt = true) {{
700
746
  setState('transcribing');
701
747
  const audio_base64 = await fileToBase64(blob);
702
748
  const response = await fetch('/v1/audio/transcriptions', {{
703
749
  method: 'POST',
704
750
  headers: {{'content-type': 'application/json', 'accept': 'application/json'}},
705
- body: JSON.stringify({{audio_base64, filename: 'web-chat-recording.webm', content_type: blob.type || 'audio/webm', model: speechConfig.asr.model, language: speechConfig.asr.language}})
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}})
706
752
  }});
707
753
  const text = await response.text();
708
754
  let data = {{}};
@@ -710,9 +756,12 @@ def render_web_chat_page(
710
756
  if (!response.ok) throw new Error((data.error && (data.error.message || data.error)) || text || `HTTP ${{response.status}}`);
711
757
  const transcriptText = String(data.text || data.transcript || '').trim();
712
758
  if (!transcriptText) throw new Error('ASR returned no transcript');
713
- prompt.value = prompt.value ? prompt.value + ' ' + transcriptText : transcriptText;
714
- prompt.focus();
759
+ if (populatePrompt) {{
760
+ prompt.value = prompt.value ? prompt.value + ' ' + transcriptText : transcriptText;
761
+ prompt.focus();
762
+ }}
715
763
  setState('transcribed', 'ok');
764
+ return transcriptText;
716
765
  }}
717
766
  function encodePcmWav(chunks, sampleRate) {{
718
767
  const sampleCount = chunks.reduce((total, chunk) => total + chunk.length, 0);
@@ -742,63 +791,119 @@ def render_web_chat_page(
742
791
  }}));
743
792
  return new Blob([buffer], {{type: 'audio/wav'}});
744
793
  }}
745
- async function finishVoiceInput(blob) {{
746
- if (mediaStream) mediaStream.getTracks().forEach(track => track.stop());
747
- mediaStream = null;
748
- micButton.textContent = 'Start voice input';
749
- micButton.classList.remove('recording');
750
- try {{ await transcribeRecording(blob); }} catch (err) {{ setState('STT error', 'error'); addBubble('system', 'STT failed: ' + String(err && err.message ? err.message : err)); }}
794
+ function resetVadUtterance() {{
795
+ vadSpeechActive = false;
796
+ vadSpeechChunks = [];
797
+ vadSpeechStartedAt = 0;
798
+ vadLastVoiceAt = 0;
799
+ vadVoicedSamples = 0;
800
+ }}
801
+ function queueLiveUtterance(chunks, sampleRate) {{
802
+ if (!chunks.length) return;
803
+ const blob = encodePcmWav(chunks, sampleRate);
804
+ liveTranscriptionQueue = liveTranscriptionQueue.then(async () => {{
805
+ const transcriptText = await transcribeRecording(blob, false);
806
+ setState('sending voice');
807
+ await sendMessage(transcriptText, []);
808
+ if (liveVoiceEnabled) setState('listening', 'ok');
809
+ }}).catch(err => {{
810
+ setState('STT error', 'error');
811
+ addBubble('system', 'STT failed: ' + String(err && err.message ? err.message : err));
812
+ }});
813
+ }}
814
+ function finishVadUtterance() {{
815
+ const chunks = vadSpeechChunks.slice();
816
+ const sampleRate = audioContext ? audioContext.sampleRate : 48000;
817
+ resetVadUtterance();
818
+ vadPreRollChunks = [];
819
+ queueLiveUtterance(chunks, sampleRate);
820
+ }}
821
+ function processVadFrame(event) {{
822
+ if (!liveVoiceEnabled || !audioContext) return;
823
+ const chunk = new Float32Array(event.inputBuffer.getChannelData(0));
824
+ let sumSquares = 0;
825
+ for (let index = 0; index < chunk.length; index += 1) sumSquares += chunk[index] * chunk[index];
826
+ const rms = Math.sqrt(sumSquares / Math.max(1, chunk.length));
827
+ const configuredThreshold = Number((speechConfig.asr && speechConfig.asr.vad_threshold) || 0.018);
828
+ const threshold = Math.max(configuredThreshold, Math.min(0.08, vadNoiseFloor * 2.8));
829
+ const voiceDetected = rms >= threshold;
830
+ const now = performance.now();
831
+ if (!vadSpeechActive) {{
832
+ if (!voiceDetected) {{
833
+ vadNoiseFloor = Math.max(0.002, Math.min(0.03, vadNoiseFloor * 0.98 + rms * 0.02));
834
+ vadPreRollChunks.push(chunk);
835
+ const maxPreRollSamples = audioContext.sampleRate * 0.25;
836
+ let preRollSamples = vadPreRollChunks.reduce((total, item) => total + item.length, 0);
837
+ while (preRollSamples > maxPreRollSamples && vadPreRollChunks.length > 1) preRollSamples -= vadPreRollChunks.shift().length;
838
+ return;
839
+ }}
840
+ vadSpeechActive = true;
841
+ vadSpeechStartedAt = now;
842
+ vadLastVoiceAt = now;
843
+ vadVoicedSamples = chunk.length;
844
+ vadSpeechChunks = vadPreRollChunks.concat([chunk]);
845
+ vadPreRollChunks = [];
846
+ stopActiveSpeech();
847
+ setState('hearing speech', 'ok');
848
+ return;
849
+ }}
850
+ vadSpeechChunks.push(chunk);
851
+ if (voiceDetected) {{
852
+ vadLastVoiceAt = now;
853
+ vadVoicedSamples += chunk.length;
854
+ }}
855
+ const silenceMs = Number((speechConfig.asr && speechConfig.asr.silence_ms) || 900);
856
+ const minSpeechMs = Number((speechConfig.asr && speechConfig.asr.min_speech_ms) || 300);
857
+ const voicedMs = vadVoicedSamples * 1000 / audioContext.sampleRate;
858
+ const utteranceMs = now - vadSpeechStartedAt;
859
+ if (now - vadLastVoiceAt >= silenceMs) {{
860
+ if (voicedMs >= minSpeechMs) finishVadUtterance();
861
+ else resetVadUtterance();
862
+ }} else if (utteranceMs >= 30000) {{
863
+ finishVadUtterance();
864
+ }}
751
865
  }}
752
866
  async function startVoiceInput() {{
753
867
  if (!navigator.mediaDevices) throw new Error('This browser does not support microphone recording');
754
- mediaStream = await navigator.mediaDevices.getUserMedia({{audio: true}});
755
868
  const AudioContextClass = window.AudioContext || window.webkitAudioContext;
756
- if (AudioContextClass) {{
757
- audioContext = new AudioContextClass();
758
- if (audioContext.state === 'suspended') await audioContext.resume();
759
- audioInput = audioContext.createMediaStreamSource(mediaStream);
760
- audioProcessor = audioContext.createScriptProcessor(4096, 1, 1);
761
- pcmChunks = [];
762
- audioProcessor.onaudioprocess = event => pcmChunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
763
- audioInput.connect(audioProcessor);
764
- audioProcessor.connect(audioContext.destination);
765
- }} else if (window.MediaRecorder) {{
766
- recordingChunks = [];
767
- mediaRecorder = new MediaRecorder(mediaStream);
768
- mediaRecorder.addEventListener('dataavailable', event => {{ if (event.data && event.data.size) recordingChunks.push(event.data); }});
769
- mediaRecorder.addEventListener('stop', async () => {{
770
- const blob = new Blob(recordingChunks, {{type: mediaRecorder.mimeType || 'audio/webm'}});
771
- await finishVoiceInput(blob);
772
- }}, {{once: true}});
773
- mediaRecorder.start();
774
- }} else {{
775
- mediaStream.getTracks().forEach(track => track.stop());
776
- mediaStream = null;
777
- throw new Error('This browser does not support microphone recording');
778
- }}
779
- voiceRecording = true;
780
- micButton.textContent = 'Stop and transcribe';
869
+ if (!AudioContextClass) throw new Error('Live voice requires Web Audio support');
870
+ mediaStream = await navigator.mediaDevices.getUserMedia({{audio: {{echoCancellation: true, noiseSuppression: true, autoGainControl: true}}}});
871
+ audioContext = new AudioContextClass();
872
+ if (audioContext.state === 'suspended') await audioContext.resume();
873
+ audioInput = audioContext.createMediaStreamSource(mediaStream);
874
+ audioProcessor = audioContext.createScriptProcessor(2048, 1, 1);
875
+ resetVadUtterance();
876
+ vadPreRollChunks = [];
877
+ vadNoiseFloor = 0.006;
878
+ liveVoiceEnabled = true;
879
+ audioProcessor.onaudioprocess = processVadFrame;
880
+ audioInput.connect(audioProcessor);
881
+ audioProcessor.connect(audioContext.destination);
882
+ micButton.textContent = 'Stop live voice';
781
883
  micButton.classList.add('recording');
782
- setState('recording', 'error');
884
+ setState('listening', 'ok');
783
885
  }}
784
886
  async function stopVoiceInput() {{
785
- if (!voiceRecording) return;
786
- voiceRecording = false;
787
- if (audioProcessor && audioContext) {{
788
- const sampleRate = audioContext.sampleRate;
789
- audioProcessor.onaudioprocess = null;
790
- audioInput.disconnect();
791
- audioProcessor.disconnect();
792
- await audioContext.close();
793
- const blob = encodePcmWav(pcmChunks, sampleRate);
794
- audioContext = null;
795
- audioInput = null;
796
- audioProcessor = null;
797
- pcmChunks = [];
798
- await finishVoiceInput(blob);
799
- }} else if (mediaRecorder && mediaRecorder.state !== 'inactive') {{
800
- mediaRecorder.stop();
887
+ if (!liveVoiceEnabled) return;
888
+ liveVoiceEnabled = false;
889
+ if (vadSpeechActive && audioContext) {{
890
+ const minSpeechMs = Number((speechConfig.asr && speechConfig.asr.min_speech_ms) || 300);
891
+ if (vadVoicedSamples * 1000 / audioContext.sampleRate >= minSpeechMs) finishVadUtterance();
801
892
  }}
893
+ resetVadUtterance();
894
+ vadPreRollChunks = [];
895
+ if (audioProcessor) {{ audioProcessor.onaudioprocess = null; audioProcessor.disconnect(); }}
896
+ if (audioInput) audioInput.disconnect();
897
+ if (audioContext) await audioContext.close();
898
+ if (mediaStream) mediaStream.getTracks().forEach(track => track.stop());
899
+ audioProcessor = null;
900
+ audioInput = null;
901
+ audioContext = null;
902
+ mediaStream = null;
903
+ stopActiveSpeech();
904
+ micButton.textContent = 'Start live voice';
905
+ micButton.classList.remove('recording');
906
+ setState('ready');
802
907
  }}
803
908
  async function uploadAttachment(file) {{
804
909
  const content = await fileToBase64(file);
@@ -993,7 +1098,7 @@ def render_web_chat_page(
993
1098
  }});
994
1099
  attachButton.addEventListener('click', () => fileInput.click());
995
1100
  micButton.addEventListener('click', async () => {{
996
- if (voiceRecording) {{
1101
+ if (liveVoiceEnabled) {{
997
1102
  await stopVoiceInput();
998
1103
  return;
999
1104
  }}
@@ -24,6 +24,10 @@ MOSS-TTS-Nano is a voice-cloning model without built-in speakers. Deployment con
24
24
 
25
25
  Colab sessions are ephemeral. Re-run the bootstrap after a runtime reset. The workers are reachable only by devices in the same tailnet unless an administrator separately enables Tailscale Funnel.
26
26
 
27
+ ## Live voice
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.
30
+
27
31
  ## API surface
28
32
 
29
33
  - `GET|POST /ca/speech/config`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/ciel-runtime",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
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",