converse-sdk 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,37 @@
1
+ from .audio import (
2
+ chunk_audio,
3
+ f32le_to_float32,
4
+ float32_to_pcm16,
5
+ pcm16_to_float32,
6
+ to_ws_url,
7
+ )
8
+ from .recorder import TurnRecorder
9
+ from .session import (
10
+ DEFAULT_CHUNK_MS,
11
+ DEFAULT_SR,
12
+ OUTPUT_SR,
13
+ ConverseError,
14
+ ConverseMode,
15
+ RelayMode,
16
+ SessionMode,
17
+ ConverseSession,
18
+ SessionEvent,
19
+ )
20
+
21
+ __all__ = [
22
+ "ConverseError",
23
+ "ConverseMode",
24
+ "RelayMode",
25
+ "SessionMode",
26
+ "ConverseSession",
27
+ "SessionEvent",
28
+ "TurnRecorder",
29
+ "DEFAULT_CHUNK_MS",
30
+ "DEFAULT_SR",
31
+ "OUTPUT_SR",
32
+ "chunk_audio",
33
+ "f32le_to_float32",
34
+ "float32_to_pcm16",
35
+ "pcm16_to_float32",
36
+ "to_ws_url",
37
+ ]
converse_sdk/audio.py ADDED
@@ -0,0 +1,50 @@
1
+ """Audio codecs for the Converse wire protocol.
2
+
3
+ Uplink is PCM16 little-endian mono; downlink assistant audio is float32 little-endian mono.
4
+ WAV loading/resampling is deliberately out of scope — callers own file I/O.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from urllib.parse import urlparse
9
+
10
+ import numpy as np
11
+
12
+
13
+ def float32_to_pcm16(audio: np.ndarray) -> bytes:
14
+ clipped = np.clip(audio, -1.0, 1.0)
15
+ return (clipped * 32767.0).astype("<i2").tobytes()
16
+
17
+
18
+ def pcm16_to_float32(data: bytes) -> np.ndarray:
19
+ if not data:
20
+ return np.zeros(0, dtype=np.float32)
21
+ return (np.frombuffer(data, dtype="<i2").astype(np.float32)) / 32768.0
22
+
23
+
24
+ def f32le_to_float32(data: bytes) -> np.ndarray:
25
+ if not data:
26
+ return np.zeros(0, dtype=np.float32)
27
+ return np.frombuffer(data, dtype="<f4").astype(np.float32)
28
+
29
+
30
+ def chunk_audio(audio: np.ndarray, sr: int, chunk_ms: int) -> list[np.ndarray]:
31
+ """Slice a float32 waveform into fixed-size frames, zero-padding the tail."""
32
+ n = max(1, int(sr * chunk_ms / 1000))
33
+ chunks = []
34
+ for i in range(0, len(audio), n):
35
+ chunk = audio[i:i + n]
36
+ if len(chunk) < n:
37
+ padded = np.zeros(n, dtype=np.float32)
38
+ padded[: len(chunk)] = chunk
39
+ chunk = padded
40
+ chunks.append(chunk.astype(np.float32, copy=False))
41
+ return chunks
42
+
43
+
44
+ def to_ws_url(value: str) -> str:
45
+ parsed = urlparse(value)
46
+ if parsed.scheme in {"ws", "wss"}:
47
+ return value
48
+ if parsed.scheme in {"http", "https"}:
49
+ return ("wss://" if parsed.scheme == "https" else "ws://") + value.split("://", 1)[1]
50
+ return value
converse_sdk/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,89 @@
1
+ """Canonical assistant-audio bookkeeping over a ConverseSession event stream.
2
+
3
+ Encodes the two Converse-specific capture rules the eval runner established:
4
+ - `canceled` is a pre-audible rescind — the rescinded reply was never heard, so its audio must be
5
+ dropped and first-audio must re-latch on the reply the user actually hears.
6
+ - `interrupted` is a barge — the user heard the fragment, so captured audio keeps it.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ import numpy as np
13
+
14
+ from .session import OUTPUT_SR, SessionEvent
15
+
16
+
17
+ @dataclass
18
+ class TurnRecorder:
19
+ sr: int = OUTPUT_SR
20
+ audio_parts: list[np.ndarray] = field(default_factory=list)
21
+ # (arrival_ms, cumulative_samples) per audio frame, for mapping samples back to wall clock.
22
+ arrivals: list[tuple[float, int]] = field(default_factory=list)
23
+ first_audio_ms: float | None = None
24
+ turn_started_ms: float | None = None
25
+ done_ms: float | None = None
26
+ canceled_count: int = 0
27
+ interrupted_count: int = 0
28
+ _turn_audio_start: int = 0
29
+ _turn_arrival_start: int = 0
30
+
31
+ def feed(self, ev: SessionEvent) -> None:
32
+ if ev.type == "audio" and ev.audio is not None and len(ev.audio):
33
+ if self.first_audio_ms is None:
34
+ self.first_audio_ms = ev.t_ms
35
+ self.audio_parts.append(ev.audio)
36
+ total = (self.arrivals[-1][1] if self.arrivals else 0) + len(ev.audio)
37
+ self.arrivals.append((ev.t_ms, total))
38
+ elif ev.type == "turn":
39
+ self.turn_started_ms = ev.t_ms
40
+ elif ev.type == "done":
41
+ self.done_ms = ev.t_ms
42
+ elif ev.type == "canceled":
43
+ self.canceled_count += 1
44
+ # Hard clear of the CURRENT turn's audio only: rescinded pre-audible reply.
45
+ del self.audio_parts[self._turn_audio_start:]
46
+ del self.arrivals[self._turn_arrival_start:]
47
+ self.first_audio_ms = None
48
+ self.turn_started_ms = None
49
+ elif ev.type == "interrupted":
50
+ self.interrupted_count += 1 # keep audio: the user heard the fragment
51
+
52
+ def mark_turn_boundary(self) -> None:
53
+ """Call after a completed turn so a later `canceled` only clears the new turn's audio."""
54
+ self._turn_audio_start = len(self.audio_parts)
55
+ self._turn_arrival_start = len(self.arrivals)
56
+ self.first_audio_ms = None
57
+ self.turn_started_ms = None
58
+ self.done_ms = None
59
+
60
+ def waveform(self) -> np.ndarray:
61
+ """Captured assistant audio, gaplessly concatenated."""
62
+ if not self.audio_parts:
63
+ return np.zeros(0, dtype=np.float32)
64
+ return np.concatenate(self.audio_parts)
65
+
66
+ def aligned_waveform(self, *, end_ms: float | None = None) -> np.ndarray:
67
+ """Assistant audio placed on a wall-clock silence timeline.
68
+
69
+ Frames arrive in bursts ahead of playback, so each frame is placed at its arrival time or
70
+ immediately after the previous frame, whichever is later — the earliest moment it could
71
+ have been audible. This is the layout full-duplex benchmarks score against.
72
+
73
+ `end_ms` is a FLOOR, not a ceiling: audio already placed past it is never truncated
74
+ (captured audio the user heard must not be discarded), it only pads shorter timelines.
75
+ """
76
+ if not self.audio_parts:
77
+ n = int((end_ms or 0) / 1000 * self.sr)
78
+ return np.zeros(n, dtype=np.float32)
79
+ placements = []
80
+ cursor = 0
81
+ for (arrival_ms, _cum), part in zip(self.arrivals, self.audio_parts):
82
+ start = max(int(arrival_ms / 1000 * self.sr), cursor)
83
+ placements.append((start, part))
84
+ cursor = start + len(part)
85
+ end = max(cursor, int(end_ms / 1000 * self.sr) if end_ms is not None else 0)
86
+ timeline = np.zeros(end, dtype=np.float32)
87
+ for start, part in placements:
88
+ timeline[start:start + len(part)] = part
89
+ return timeline
@@ -0,0 +1,306 @@
1
+ """Headless duplex client for the Converse broker websocket protocol.
2
+
3
+ Mirrors the browser SDK's event vocabulary (sdk/browser/src/index.js): `ready`, `turn`, `asr`,
4
+ `utterance`, `done`, `interrupted`, `canceled`, `error`, `ack`, `playback_pause`,
5
+ `playback_resume`, `tool_call`, plus a synthetic `audio` event per binary frame. Every event is
6
+ stamped with `t_ms` — milliseconds since `connect()` began — so callers can reconstruct a
7
+ wall-clock-aligned view of the conversation without their own clock plumbing.
8
+
9
+ The client is a thin wire terminal: it does not decide turn boundaries, pacing policy, or what
10
+ `canceled`/`interrupted` mean for captured audio. `TurnRecorder` (recorder.py) implements the
11
+ canonical bookkeeping for callers that assemble waveforms.
12
+
13
+ Callers that perform REAL playback (telephony bridges, apps with a speaker) own the playback
14
+ clock and must report discards themselves: after an `interrupted` event, send
15
+ `send_client_event("playback_stopped", discarded_ms=<unplayed ms>, barge_seq=<from the event>)`
16
+ so the broker re-truncates its committed text to what the user actually heard (see
17
+ https://converse.trelis.com/docs/api/reference/#playback-contract). Without that report the broker assumes every
18
+ delivered fragment was heard — correct for headless recording/eval use, wrong for live playback.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import time
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, AsyncIterator
27
+
28
+ import numpy as np
29
+
30
+ from .audio import chunk_audio, f32le_to_float32, float32_to_pcm16, to_ws_url
31
+
32
+ DEFAULT_SR = 16_000
33
+ OUTPUT_SR = 16_000
34
+ DEFAULT_CHUNK_MS = 100
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ConverseMode:
39
+ """Converse-cascade capabilities. Relay-only fields cannot be expressed here."""
40
+
41
+ voice: str | None = None
42
+ instructions: str | None = None
43
+ tools: list[dict[str, Any]] | None = None
44
+ web_search: bool = False
45
+ flow: bool = False
46
+ greeting: str | bool | None = None # None=server default, False=disabled, str=custom opener
47
+
48
+ def __post_init__(self) -> None:
49
+ if self.voice is not None and not isinstance(self.voice, str):
50
+ raise TypeError("ConverseMode voice must be a string or None")
51
+ if self.instructions is not None and not isinstance(self.instructions, str):
52
+ raise TypeError("ConverseMode instructions must be a string or None")
53
+ if self.tools is not None and not isinstance(self.tools, list):
54
+ raise TypeError("ConverseMode tools must be a list or None")
55
+ if type(self.web_search) is not bool:
56
+ raise TypeError("ConverseMode web_search must be a boolean")
57
+ if type(self.flow) is not bool:
58
+ raise TypeError("ConverseMode flow must be a boolean")
59
+ if self.greeting is not None and self.greeting is not False \
60
+ and not isinstance(self.greeting, str):
61
+ raise ValueError("ConverseMode greeting must be a string, False, or None")
62
+
63
+ def to_wire(self) -> dict[str, Any]:
64
+ mode: dict[str, Any] = {"kind": "converse", "web_search": self.web_search}
65
+ for key, value in (("voice", self.voice), ("instructions", self.instructions),
66
+ ("tools", self.tools), ("greeting", self.greeting)):
67
+ if value is not None:
68
+ mode[key] = value
69
+ if self.flow:
70
+ mode["flow"] = True
71
+ return mode
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class RelayMode:
76
+ """External speech-to-speech relay capabilities; no Converse instructions or tools."""
77
+
78
+ provider: str
79
+ model: str | None = None
80
+ voice: str | None = None
81
+ web_search: bool = False
82
+
83
+ def __post_init__(self) -> None:
84
+ if not isinstance(self.provider, str) or not self.provider.strip():
85
+ raise ValueError("RelayMode provider is required")
86
+ if self.model is not None and not isinstance(self.model, str):
87
+ raise TypeError("RelayMode model must be a string or None")
88
+ if self.voice is not None and not isinstance(self.voice, str):
89
+ raise TypeError("RelayMode voice must be a string or None")
90
+ if type(self.web_search) is not bool:
91
+ raise TypeError("RelayMode web_search must be a boolean")
92
+
93
+ def to_wire(self) -> dict[str, Any]:
94
+ mode: dict[str, Any] = {
95
+ "kind": "relay", "provider": self.provider, "web_search": self.web_search,
96
+ }
97
+ if self.model is not None:
98
+ mode["model"] = self.model
99
+ if self.voice is not None:
100
+ mode["voice"] = self.voice
101
+ return mode
102
+
103
+
104
+ SessionMode = ConverseMode | RelayMode
105
+
106
+
107
+ @dataclass
108
+ class SessionEvent:
109
+ type: str
110
+ t_ms: float
111
+ data: dict[str, Any] = field(default_factory=dict)
112
+ audio: np.ndarray | None = None # float32 mono at OUTPUT_SR, only for type == "audio"
113
+
114
+
115
+ class ConverseError(RuntimeError):
116
+ """Raised when the broker reports {"type": "error"} during connect."""
117
+
118
+
119
+ class ConverseSession:
120
+ """One duplex broker session. Use `ConverseSession.connect(...)` to open.
121
+
122
+ Reading and writing are independent: `send_audio`/`stream_audio` feed the mic uplink while
123
+ `events()` yields broker events — full duplex, no phasing. The receive loop runs as a
124
+ background task from connect until the socket closes; events buffer in an unbounded queue.
125
+ """
126
+
127
+ def __init__(self, ws: Any, start_t: float) -> None:
128
+ self._ws = ws
129
+ self._start_t = start_t
130
+ self._queue: asyncio.Queue[SessionEvent | None] = asyncio.Queue()
131
+ self._reader: asyncio.Task | None = None
132
+ self._closed = False
133
+
134
+ # -- lifecycle -----------------------------------------------------------------
135
+
136
+ @classmethod
137
+ async def connect(
138
+ cls,
139
+ url: str,
140
+ *,
141
+ session_id: str | None = None,
142
+ sr: int = DEFAULT_SR,
143
+ api_key: str | None = None,
144
+ mode: SessionMode | None = None,
145
+ user: str | None = None,
146
+ timezone: str | None = None,
147
+ capabilities: list[str] | None = None,
148
+ connect_timeout_s: float = 15.0,
149
+ ) -> "ConverseSession":
150
+ import websockets
151
+
152
+ selected_mode = mode or ConverseMode()
153
+ if not isinstance(selected_mode, (ConverseMode, RelayMode)):
154
+ raise TypeError("mode must be ConverseMode or RelayMode")
155
+ start: dict[str, Any] = {
156
+ "type": "start",
157
+ "session_id": session_id or f"sdk-{int(time.time() * 1000)}",
158
+ "audio": {"sr": sr},
159
+ "mode": selected_mode.to_wire(),
160
+ }
161
+ if api_key is not None:
162
+ start["api_key"] = api_key
163
+ client = {key: value for key, value in (
164
+ ("user", user), ("timezone", timezone), ("capabilities", capabilities),
165
+ ) if value is not None}
166
+ if client:
167
+ start["client"] = client
168
+ start_json = json.dumps(start)
169
+ start_t = time.perf_counter()
170
+ ws = await websockets.connect(to_ws_url(url), max_size=None)
171
+ await ws.send(start_json)
172
+
173
+ session = cls(ws, start_t)
174
+ session._reader = asyncio.create_task(session._read_loop())
175
+ # Surface `ready` (or a pre-ready error) before returning, mirroring the browser client;
176
+ # the event is also re-queued so `events()` consumers see the full stream.
177
+ try:
178
+ async with asyncio.timeout(connect_timeout_s):
179
+ while True:
180
+ ev = await session._queue.get()
181
+ if ev is None:
182
+ raise ConverseError("connection closed before ready")
183
+ session._queue.put_nowait(ev)
184
+ if ev.type == "ready":
185
+ return session
186
+ if ev.type == "error":
187
+ raise ConverseError(ev.data.get("detail") or "broker error before ready")
188
+ except (ConverseError, TimeoutError):
189
+ await session.close()
190
+ raise
191
+
192
+ async def close(self) -> None:
193
+ if self._closed:
194
+ return
195
+ self._closed = True
196
+ try:
197
+ await self._ws.close()
198
+ finally:
199
+ if self._reader is not None:
200
+ self._reader.cancel()
201
+ try:
202
+ await self._reader
203
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
204
+ pass
205
+ self._queue.put_nowait(None)
206
+
207
+ async def __aenter__(self) -> "ConverseSession":
208
+ return self
209
+
210
+ async def __aexit__(self, *exc: Any) -> None:
211
+ await self.close()
212
+
213
+ # -- clock ---------------------------------------------------------------------
214
+
215
+ @property
216
+ def start_t(self) -> float:
217
+ """perf_counter anchor taken as connect() began; all t_ms values are relative to it."""
218
+ return self._start_t
219
+
220
+ def now_ms(self) -> float:
221
+ return round((time.perf_counter() - self._start_t) * 1000, 1)
222
+
223
+ # -- uplink --------------------------------------------------------------------
224
+
225
+ async def send_audio(self, chunk: np.ndarray | bytes) -> None:
226
+ """Send one mic frame. float32 arrays are encoded to PCM16; bytes pass through."""
227
+ data = chunk if isinstance(chunk, (bytes, bytearray)) else float32_to_pcm16(chunk)
228
+ await self._ws.send(data)
229
+
230
+ async def stream_audio(
231
+ self,
232
+ audio: np.ndarray,
233
+ *,
234
+ sr: int = DEFAULT_SR,
235
+ chunk_ms: int = DEFAULT_CHUNK_MS,
236
+ realtime: bool = True,
237
+ on_sent: Any | None = None,
238
+ ) -> list[float]:
239
+ """Stream a waveform as paced mic frames; returns the actual send time (ms) per chunk.
240
+
241
+ asyncio pacing drifts under load, so callers anchoring timing on the input must use the
242
+ returned actual send times, never `i * chunk_ms`.
243
+ """
244
+ send_times: list[float] = []
245
+ for chunk in chunk_audio(audio, sr, chunk_ms):
246
+ now = self.now_ms()
247
+ send_times.append(now)
248
+ await self.send_audio(chunk)
249
+ if on_sent is not None:
250
+ on_sent(now, chunk)
251
+ if realtime:
252
+ await asyncio.sleep(chunk_ms / 1000)
253
+ return send_times
254
+
255
+ # -- control messages ----------------------------------------------------------
256
+
257
+ async def _send_json(self, payload: dict[str, Any]) -> None:
258
+ await self._ws.send(json.dumps(payload))
259
+
260
+ async def reset(self) -> None:
261
+ await self._send_json({"type": "reset"})
262
+
263
+ async def send_tool_result(self, tool_id: str, content: Any) -> None:
264
+ """Resolve a call with JSON content.
265
+
266
+ Keep results compact: the server enforces its configured UTF-8 JSON byte ceiling and
267
+ replaces oversized content with a bounded truncation marker and preview.
268
+ """
269
+ await self._send_json({"type": "tool_result", "id": tool_id, "content": content})
270
+
271
+ async def send_tool_cancel(self, tool_id: str) -> None:
272
+ await self._send_json({"type": "tool_cancel", "id": tool_id})
273
+
274
+ async def send_tool_progress(self, tool_id: str, note: str) -> None:
275
+ """Report progress on an in-flight tool call (docs/client-tool-protocol.md §3): appends to the brain's
276
+ context so the next turn can speak to it; never resolves the call."""
277
+ await self._send_json({"type": "tool_progress", "id": tool_id, "note": note})
278
+
279
+ async def send_client_event(self, event: str, **fields: Any) -> None:
280
+ await self._send_json({"type": "client_event", "event": event, **fields})
281
+
282
+ # -- downlink ------------------------------------------------------------------
283
+
284
+ async def events(self) -> AsyncIterator[SessionEvent]:
285
+ """Yield broker events until the connection closes. Safe to consume from one task only."""
286
+ while True:
287
+ ev = await self._queue.get()
288
+ if ev is None:
289
+ return
290
+ yield ev
291
+
292
+ async def _read_loop(self) -> None:
293
+ try:
294
+ async for raw in self._ws:
295
+ if isinstance(raw, (bytes, bytearray)):
296
+ self._queue.put_nowait(SessionEvent(
297
+ type="audio", t_ms=self.now_ms(), audio=f32le_to_float32(bytes(raw))))
298
+ continue
299
+ msg = json.loads(raw)
300
+ typ = msg.get("type", "")
301
+ data = {k: v for k, v in msg.items() if k != "type"}
302
+ self._queue.put_nowait(SessionEvent(type=typ, t_ms=self.now_ms(), data=data))
303
+ except Exception: # noqa: BLE001 — closure (normal or abnormal) ends the stream
304
+ pass
305
+ finally:
306
+ self._queue.put_nowait(None)
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: converse-sdk
3
+ Version: 0.1.0
4
+ Summary: Headless Python SDK for the Trelis Converse realtime voice API.
5
+ Project-URL: Documentation, https://converse.trelis.com/docs/api/
6
+ Project-URL: Homepage, https://converse.trelis.com/
7
+ Author: Trelis Research
8
+ License-Expression: LicenseRef-Proprietary
9
+ Keywords: converse,realtime,speech,voice,voice-ai
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: numpy>=1.26
19
+ Requires-Dist: websockets>=12.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # converse-sdk
23
+
24
+ The headless Python SDK for the
25
+ [Trelis Converse realtime voice API](https://converse.trelis.com/docs/api/).
26
+ Use it for telephony bridges, services, evaluations and custom devices.
27
+
28
+ ```sh
29
+ uv add converse-sdk
30
+ ```
31
+
32
+ The session-loop fragment below assumes your media layer supplies `mic_frame` and `play_audio`,
33
+ and your application supplies `run_tool`:
34
+
35
+ ```python
36
+ import os
37
+
38
+ from converse_sdk import ConverseMode, ConverseSession
39
+
40
+ session = await ConverseSession.connect(
41
+ "wss://converse.trelis.com/ws",
42
+ api_key=os.environ["CONVERSE_API_KEY"],
43
+ mode=ConverseMode(
44
+ instructions="Help callers with their orders.",
45
+ greeting="Hello, how can I help?",
46
+ ),
47
+ )
48
+
49
+ async with session:
50
+ await session.send_audio(mic_frame) # PCM16 little-endian mono at 16 kHz
51
+
52
+ async for event in session.events():
53
+ if event.type == "audio":
54
+ play_audio(event.audio) # Float32 mono at 16 kHz
55
+ elif event.type == "tool_call":
56
+ result = await run_tool(event.data["name"], event.data["args"])
57
+ await session.send_tool_result(event.data["id"], result)
58
+ ```
59
+
60
+ The SDK deliberately does not own capture, playback, pacing or echo cancellation. Live playback
61
+ integrations must implement the
62
+ [playback contract](https://converse.trelis.com/docs/api/reference/#playback-contract).
63
+
64
+ See the [complete Python reference](https://converse.trelis.com/docs/api/reference/#python-sdk)
65
+ and the [Twilio bridge quickstart](https://converse.trelis.com/docs/api/reference/#twilio).
66
+
67
+ Proprietary software. All rights reserved.
@@ -0,0 +1,8 @@
1
+ converse_sdk/__init__.py,sha256=dAfTd2nlRmBS6Zu1JOFM6a65XF06MIM3Rp7n73Vm5TU,664
2
+ converse_sdk/audio.py,sha256=HJUr8VetVoSx5UNnmdOGY2KrbG8OzMq9NMh8V-_A_7E,1618
3
+ converse_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
+ converse_sdk/recorder.py,sha256=342EVHZ7kC5Ag7iqSYsx4pmnxMzzuD6OmgRufsjJgXU,3968
5
+ converse_sdk/session.py,sha256=Q6TRiTI37R-lVlmMQ0XNdCAeoFoBiF17ebFcWua_lps,12572
6
+ converse_sdk-0.1.0.dist-info/METADATA,sha256=nK6j2P3DkpFFJLMjL-rKdTIBAHNZwHbEz4qa6U44uBo,2378
7
+ converse_sdk-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ converse_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any