@oneciel-ai/ciel-runtime 0.2.7 → 0.2.8

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.
package/ciel_runtime.py CHANGED
@@ -4388,8 +4388,8 @@ _CHANNEL_TRANSCRIPT_SCOPE: dict[str, Any] = {'runtime': '', 'started_at': 0.0, '
4388
4388
  _CHANNEL_STDIN_RECOVERY_CACHE: dict[str, Any] = {'checked_at': 0.0, 'last_id': None, 'marker': None, 'recovered_last_id': None}
4389
4389
  def channel_transcript_repository() -> ChannelTranscriptRepository: return channel_wake_context().transcript_repository()
4390
4390
 
4391
- def _set_channel_transcript_scope(runtime: str, *, started_at: float | None = None, codex_home: Path | None = None) -> None:
4392
- channel_wake_context().set_transcript_scope(runtime, started_at=started_at, codex_home=codex_home)
4391
+ def _set_channel_transcript_scope(runtime: str, *, started_at: float | None = None, codex_home: Path | None = None, cwd: Path | None = None) -> None:
4392
+ channel_wake_context().set_transcript_scope(runtime, started_at=started_at, codex_home=codex_home, cwd=cwd)
4393
4393
 
4394
4394
  def _channel_transcript_roots() -> tuple[tuple[Path, str], ...]: return channel_wake_context().transcript_roots()
4395
4395
  def _latest_claude_transcript_path(ttl_seconds: float = 2.0) -> Path | None: return channel_wake_context().latest_transcript_path(ttl_seconds)
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable
6
6
  from dataclasses import dataclass
7
+ from datetime import datetime
8
+ import json
7
9
  from pathlib import Path
8
10
  from typing import Any
9
11
 
@@ -21,6 +23,7 @@ class ChannelTranscriptRepository:
21
23
  *,
22
24
  started_at: float | None = None,
23
25
  codex_home: Path | None = None,
26
+ cwd: Path | None = None,
24
27
  ) -> None:
25
28
  self.scope["runtime"] = str(runtime or "").strip().casefold()
26
29
  self.scope["started_at"] = (
@@ -31,6 +34,7 @@ class ChannelTranscriptRepository:
31
34
  if codex_home is not None
32
35
  else None
33
36
  )
37
+ self.scope["cwd"] = Path(cwd).expanduser() if cwd is not None else None
34
38
  self.cache.clear()
35
39
  self.cache.update({"checked_at": 0.0, "path": None})
36
40
 
@@ -59,6 +63,8 @@ class ChannelTranscriptRepository:
59
63
  latest: Path | None = None
60
64
  latest_mtime = -1.0
61
65
  scope_started_at = float(self.scope.get("started_at") or 0.0)
66
+ runtime = str(self.scope.get("runtime") or "").strip().casefold()
67
+ scope_cwd = self._normalized_cwd(self.scope.get("cwd"))
62
68
  for root, pattern in self.roots():
63
69
  try:
64
70
  paths = root.glob(pattern)
@@ -71,6 +77,17 @@ class ChannelTranscriptRepository:
71
77
  continue
72
78
  if scope_started_at > 0 and mtime < scope_started_at - 1.0:
73
79
  continue
80
+ if runtime == "codex" and (scope_started_at > 0 or scope_cwd):
81
+ session_started_at, session_cwd = self._codex_session_identity(path)
82
+ if (
83
+ session_started_at is not None
84
+ and scope_started_at > 0
85
+ and session_started_at < scope_started_at - 1.0
86
+ ):
87
+ continue
88
+ normalized_session_cwd = self._normalized_cwd(session_cwd)
89
+ if scope_cwd and normalized_session_cwd and normalized_session_cwd != scope_cwd:
90
+ continue
74
91
  if mtime > latest_mtime:
75
92
  latest = path
76
93
  latest_mtime = mtime
@@ -78,6 +95,36 @@ class ChannelTranscriptRepository:
78
95
  self.cache["path"] = latest
79
96
  return latest
80
97
 
98
+ @staticmethod
99
+ def _normalized_cwd(value: Any) -> str:
100
+ if value is None:
101
+ return ""
102
+ return str(value).strip().replace("\\", "/").rstrip("/").casefold()
103
+
104
+ @staticmethod
105
+ def _codex_session_identity(path: Path) -> tuple[float | None, str]:
106
+ try:
107
+ with path.open("r", encoding="utf-8", errors="replace") as stream:
108
+ record = json.loads(stream.readline(64 * 1024))
109
+ except (OSError, UnicodeError, ValueError, TypeError):
110
+ return None, ""
111
+ if not isinstance(record, dict):
112
+ return None, ""
113
+ payload = record.get("payload")
114
+ metadata = payload if isinstance(payload, dict) else {}
115
+ raw_timestamp = metadata.get("timestamp") or record.get("timestamp")
116
+ started_at: float | None = None
117
+ if isinstance(raw_timestamp, (int, float)):
118
+ started_at = float(raw_timestamp)
119
+ elif isinstance(raw_timestamp, str) and raw_timestamp.strip():
120
+ try:
121
+ started_at = datetime.fromisoformat(
122
+ raw_timestamp.strip().replace("Z", "+00:00")
123
+ ).timestamp()
124
+ except ValueError:
125
+ started_at = None
126
+ return started_at, str(metadata.get("cwd") or "")
127
+
81
128
  @staticmethod
82
129
  def read_tail_text(
83
130
  path: Path,
@@ -358,9 +358,10 @@ class ChannelWakeContext:
358
358
  *,
359
359
  started_at: float | None = None,
360
360
  codex_home: Path | None = None,
361
+ cwd: Path | None = None,
361
362
  ) -> None:
362
363
  self.transcript_repository().set_scope(
363
- runtime, started_at=started_at, codex_home=codex_home
364
+ runtime, started_at=started_at, codex_home=codex_home, cwd=cwd
364
365
  )
365
366
 
366
367
  def transcript_roots(self) -> tuple[tuple[Path, str], ...]:
@@ -93,7 +93,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
93
93
  }
94
94
 
95
95
  APP_NAME = "Ciel Runtime"
96
- VERSION = "0.2.7"
96
+ VERSION = "0.2.8"
97
97
  CREDITS = "Credits: One Ciel LLC"
98
98
  PRELAUNCH_CANCEL = 10
99
99
  PRELAUNCH_LAUNCH_CODEX = 11
@@ -1000,6 +1000,7 @@ def run_codex(
1000
1000
  _set_channel_transcript_scope(
1001
1001
  "codex",
1002
1002
  codex_home=Path(env.get("CODEX_HOME") or (Path.home() / ".codex")),
1003
+ cwd=launch_cwd,
1003
1004
  )
1004
1005
  if not use_native_codex:
1005
1006
  start_codex_mcp_channel_sse_for_launch(cfg, codex_mcp_config, allowed_server_names=codex_channel_owned_names)
@@ -303,6 +303,11 @@ def render_web_chat_page(
303
303
  let mediaRecorder = null;
304
304
  let mediaStream = null;
305
305
  let recordingChunks = [];
306
+ let audioContext = null;
307
+ let audioInput = null;
308
+ let audioProcessor = null;
309
+ let pcmChunks = [];
310
+ let voiceRecording = false;
306
311
  let pendingTtsReferenceAudio = '';
307
312
  function setState(text, cls = '') {{
308
313
  statePill.textContent = text;
@@ -709,27 +714,91 @@ def render_web_chat_page(
709
714
  prompt.focus();
710
715
  setState('transcribed', 'ok');
711
716
  }}
717
+ function encodePcmWav(chunks, sampleRate) {{
718
+ const sampleCount = chunks.reduce((total, chunk) => total + chunk.length, 0);
719
+ const buffer = new ArrayBuffer(44 + sampleCount * 2);
720
+ const view = new DataView(buffer);
721
+ const writeAscii = (offset, value) => {{
722
+ for (let index = 0; index < value.length; index += 1) view.setUint8(offset + index, value.charCodeAt(index));
723
+ }};
724
+ writeAscii(0, 'RIFF');
725
+ view.setUint32(4, 36 + sampleCount * 2, true);
726
+ writeAscii(8, 'WAVE');
727
+ writeAscii(12, 'fmt ');
728
+ view.setUint32(16, 16, true);
729
+ view.setUint16(20, 1, true);
730
+ view.setUint16(22, 1, true);
731
+ view.setUint32(24, sampleRate, true);
732
+ view.setUint32(28, sampleRate * 2, true);
733
+ view.setUint16(32, 2, true);
734
+ view.setUint16(34, 16, true);
735
+ writeAscii(36, 'data');
736
+ view.setUint32(40, sampleCount * 2, true);
737
+ let offset = 44;
738
+ chunks.forEach(chunk => chunk.forEach(rawSample => {{
739
+ const sample = Math.max(-1, Math.min(1, rawSample));
740
+ view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
741
+ offset += 2;
742
+ }}));
743
+ return new Blob([buffer], {{type: 'audio/wav'}});
744
+ }}
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)); }}
751
+ }}
712
752
  async function startVoiceInput() {{
713
- if (!navigator.mediaDevices || !window.MediaRecorder) throw new Error('This browser does not support microphone recording');
753
+ if (!navigator.mediaDevices) throw new Error('This browser does not support microphone recording');
714
754
  mediaStream = await navigator.mediaDevices.getUserMedia({{audio: true}});
715
- recordingChunks = [];
716
- mediaRecorder = new MediaRecorder(mediaStream);
717
- mediaRecorder.addEventListener('dataavailable', event => {{ if (event.data && event.data.size) recordingChunks.push(event.data); }});
718
- mediaRecorder.addEventListener('stop', async () => {{
719
- const blob = new Blob(recordingChunks, {{type: mediaRecorder.mimeType || 'audio/webm'}});
720
- if (mediaStream) mediaStream.getTracks().forEach(track => track.stop());
755
+ 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());
721
776
  mediaStream = null;
722
- micButton.textContent = 'Start voice input';
723
- micButton.classList.remove('recording');
724
- try {{ await transcribeRecording(blob); }} catch (err) {{ setState('STT error', 'error'); addBubble('system', 'STT failed: ' + String(err && err.message ? err.message : err)); }}
725
- }}, {{once: true}});
726
- mediaRecorder.start();
777
+ throw new Error('This browser does not support microphone recording');
778
+ }}
779
+ voiceRecording = true;
727
780
  micButton.textContent = 'Stop and transcribe';
728
781
  micButton.classList.add('recording');
729
782
  setState('recording', 'error');
730
783
  }}
731
- function stopVoiceInput() {{
732
- if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();
784
+ 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();
801
+ }}
733
802
  }}
734
803
  async function uploadAttachment(file) {{
735
804
  const content = await fileToBase64(file);
@@ -924,8 +993,8 @@ def render_web_chat_page(
924
993
  }});
925
994
  attachButton.addEventListener('click', () => fileInput.click());
926
995
  micButton.addEventListener('click', async () => {{
927
- if (mediaRecorder && mediaRecorder.state !== 'inactive') {{
928
- stopVoiceInput();
996
+ if (voiceRecording) {{
997
+ await stopVoiceInput();
929
998
  return;
930
999
  }}
931
1000
  try {{ await startVoiceInput(); }} catch (err) {{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/ciel-runtime",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
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",