maximem-synap-pipecat 0.1.1__tar.gz

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,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: maximem-synap-pipecat
3
+ Version: 0.1.1
4
+ Summary: Synap memory integration for Pipecat — voice-pipeline context injection + conversation recording
5
+ Author: Synap Team
6
+ License-Expression: Apache-2.0
7
+ Keywords: synap,memory,pipecat,voice,ai,agents
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: maximem-synap>=0.2.0
11
+ Requires-Dist: synap-integrations-common>=0.1.0
12
+ Requires-Dist: pipecat-ai>=0.0.80
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.0; extra == "dev"
15
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
16
+
17
+ # synap-pipecat
18
+
19
+ Synap integration for [Pipecat](https://docs.pipecat.ai) — preload long-term memory into voice pipelines and record every turn back to Synap.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install synap-pipecat
25
+ ```
26
+
27
+ Requires `pipecat-ai>=0.0.80`, `maximem-synap>=0.2.0`.
28
+
29
+ ## Quickstart
30
+
31
+ ```python
32
+ from pipecat.pipeline.pipeline import Pipeline
33
+ from pipecat.processors.aggregators.llm_context import LLMContext
34
+ from pipecat.processors.aggregators.llm_response_universal import (
35
+ LLMContextAggregatorPair,
36
+ LLMUserAggregator,
37
+ LLMAssistantAggregator,
38
+ )
39
+ from maximem_synap import MaximemSynapSDK
40
+ from synap_pipecat import SynapMemoryProcessor, SynapRecorder
41
+
42
+ sdk = MaximemSynapSDK(api_key="sk-...")
43
+ context = LLMContext(messages=[{"role": "system", "content": "You are a helpful assistant."}])
44
+
45
+ user_agg = LLMUserAggregator(context=context)
46
+ asst_agg = LLMAssistantAggregator(context=context)
47
+
48
+ pipeline = Pipeline([
49
+ transport.input(),
50
+ stt,
51
+ SynapMemoryProcessor(sdk, user_id="alice", customer_id="acme"),
52
+ user_agg,
53
+ llm,
54
+ tts,
55
+ transport.output(),
56
+ asst_agg,
57
+ SynapRecorder(sdk, user_id="alice", customer_id="acme"),
58
+ ])
59
+ ```
60
+
61
+ ## Scope
62
+
63
+ - **`SynapMemoryProcessor`** — sits BEFORE the user context aggregator. On each `TranscriptionFrame`, fetches relevant context from Synap via `sdk.fetch()` and appends it as a system message to the shared `LLMContext`. The transcription frame itself flows through unchanged so the user aggregator can record it as the user turn.
64
+ - **`SynapRecorder`** — sits AFTER the assistant context aggregator. Buffers the latest user transcription and the streamed assistant response; on `LLMFullResponseEndFrame`, calls `sdk.conversation.record_message()` for both turns.
65
+
66
+ ## Error policy
67
+
68
+ - **Reads** (`SynapMemoryProcessor`) degrade gracefully — SDK failures log at `ERROR` and the frame continues downstream with no Synap context injected. A Synap blip must never break a live voice call.
69
+ - **Writes** (`SynapRecorder`) surface `SynapIntegrationError` via `wrap_sdk_errors_async`. The processor logs the failure and pushes an `ErrorFrame` upstream; the pipeline itself stays alive (errors propagate as frames, not exceptions).
70
+ - Non-matching frames pass through untouched in both processors — standard Pipecat `push_frame` contract.
71
+
72
+ ## Constructor args (both processors)
73
+
74
+ - `sdk`: a configured `MaximemSynapSDK`
75
+ - `user_id`: required — Synap memory is user-scoped
76
+ - `customer_id`: optional customer/org scope; empty string means customer-less
77
+ - `conversation_id`: optional explicit conversation id (auto-generated per process lifetime when absent, shared across both processors if you pass the same instance)
@@ -0,0 +1,61 @@
1
+ # synap-pipecat
2
+
3
+ Synap integration for [Pipecat](https://docs.pipecat.ai) — preload long-term memory into voice pipelines and record every turn back to Synap.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install synap-pipecat
9
+ ```
10
+
11
+ Requires `pipecat-ai>=0.0.80`, `maximem-synap>=0.2.0`.
12
+
13
+ ## Quickstart
14
+
15
+ ```python
16
+ from pipecat.pipeline.pipeline import Pipeline
17
+ from pipecat.processors.aggregators.llm_context import LLMContext
18
+ from pipecat.processors.aggregators.llm_response_universal import (
19
+ LLMContextAggregatorPair,
20
+ LLMUserAggregator,
21
+ LLMAssistantAggregator,
22
+ )
23
+ from maximem_synap import MaximemSynapSDK
24
+ from synap_pipecat import SynapMemoryProcessor, SynapRecorder
25
+
26
+ sdk = MaximemSynapSDK(api_key="sk-...")
27
+ context = LLMContext(messages=[{"role": "system", "content": "You are a helpful assistant."}])
28
+
29
+ user_agg = LLMUserAggregator(context=context)
30
+ asst_agg = LLMAssistantAggregator(context=context)
31
+
32
+ pipeline = Pipeline([
33
+ transport.input(),
34
+ stt,
35
+ SynapMemoryProcessor(sdk, user_id="alice", customer_id="acme"),
36
+ user_agg,
37
+ llm,
38
+ tts,
39
+ transport.output(),
40
+ asst_agg,
41
+ SynapRecorder(sdk, user_id="alice", customer_id="acme"),
42
+ ])
43
+ ```
44
+
45
+ ## Scope
46
+
47
+ - **`SynapMemoryProcessor`** — sits BEFORE the user context aggregator. On each `TranscriptionFrame`, fetches relevant context from Synap via `sdk.fetch()` and appends it as a system message to the shared `LLMContext`. The transcription frame itself flows through unchanged so the user aggregator can record it as the user turn.
48
+ - **`SynapRecorder`** — sits AFTER the assistant context aggregator. Buffers the latest user transcription and the streamed assistant response; on `LLMFullResponseEndFrame`, calls `sdk.conversation.record_message()` for both turns.
49
+
50
+ ## Error policy
51
+
52
+ - **Reads** (`SynapMemoryProcessor`) degrade gracefully — SDK failures log at `ERROR` and the frame continues downstream with no Synap context injected. A Synap blip must never break a live voice call.
53
+ - **Writes** (`SynapRecorder`) surface `SynapIntegrationError` via `wrap_sdk_errors_async`. The processor logs the failure and pushes an `ErrorFrame` upstream; the pipeline itself stays alive (errors propagate as frames, not exceptions).
54
+ - Non-matching frames pass through untouched in both processors — standard Pipecat `push_frame` contract.
55
+
56
+ ## Constructor args (both processors)
57
+
58
+ - `sdk`: a configured `MaximemSynapSDK`
59
+ - `user_id`: required — Synap memory is user-scoped
60
+ - `customer_id`: optional customer/org scope; empty string means customer-less
61
+ - `conversation_id`: optional explicit conversation id (auto-generated per process lifetime when absent, shared across both processors if you pass the same instance)
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: maximem-synap-pipecat
3
+ Version: 0.1.1
4
+ Summary: Synap memory integration for Pipecat — voice-pipeline context injection + conversation recording
5
+ Author: Synap Team
6
+ License-Expression: Apache-2.0
7
+ Keywords: synap,memory,pipecat,voice,ai,agents
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: maximem-synap>=0.2.0
11
+ Requires-Dist: synap-integrations-common>=0.1.0
12
+ Requires-Dist: pipecat-ai>=0.0.80
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.0; extra == "dev"
15
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
16
+
17
+ # synap-pipecat
18
+
19
+ Synap integration for [Pipecat](https://docs.pipecat.ai) — preload long-term memory into voice pipelines and record every turn back to Synap.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install synap-pipecat
25
+ ```
26
+
27
+ Requires `pipecat-ai>=0.0.80`, `maximem-synap>=0.2.0`.
28
+
29
+ ## Quickstart
30
+
31
+ ```python
32
+ from pipecat.pipeline.pipeline import Pipeline
33
+ from pipecat.processors.aggregators.llm_context import LLMContext
34
+ from pipecat.processors.aggregators.llm_response_universal import (
35
+ LLMContextAggregatorPair,
36
+ LLMUserAggregator,
37
+ LLMAssistantAggregator,
38
+ )
39
+ from maximem_synap import MaximemSynapSDK
40
+ from synap_pipecat import SynapMemoryProcessor, SynapRecorder
41
+
42
+ sdk = MaximemSynapSDK(api_key="sk-...")
43
+ context = LLMContext(messages=[{"role": "system", "content": "You are a helpful assistant."}])
44
+
45
+ user_agg = LLMUserAggregator(context=context)
46
+ asst_agg = LLMAssistantAggregator(context=context)
47
+
48
+ pipeline = Pipeline([
49
+ transport.input(),
50
+ stt,
51
+ SynapMemoryProcessor(sdk, user_id="alice", customer_id="acme"),
52
+ user_agg,
53
+ llm,
54
+ tts,
55
+ transport.output(),
56
+ asst_agg,
57
+ SynapRecorder(sdk, user_id="alice", customer_id="acme"),
58
+ ])
59
+ ```
60
+
61
+ ## Scope
62
+
63
+ - **`SynapMemoryProcessor`** — sits BEFORE the user context aggregator. On each `TranscriptionFrame`, fetches relevant context from Synap via `sdk.fetch()` and appends it as a system message to the shared `LLMContext`. The transcription frame itself flows through unchanged so the user aggregator can record it as the user turn.
64
+ - **`SynapRecorder`** — sits AFTER the assistant context aggregator. Buffers the latest user transcription and the streamed assistant response; on `LLMFullResponseEndFrame`, calls `sdk.conversation.record_message()` for both turns.
65
+
66
+ ## Error policy
67
+
68
+ - **Reads** (`SynapMemoryProcessor`) degrade gracefully — SDK failures log at `ERROR` and the frame continues downstream with no Synap context injected. A Synap blip must never break a live voice call.
69
+ - **Writes** (`SynapRecorder`) surface `SynapIntegrationError` via `wrap_sdk_errors_async`. The processor logs the failure and pushes an `ErrorFrame` upstream; the pipeline itself stays alive (errors propagate as frames, not exceptions).
70
+ - Non-matching frames pass through untouched in both processors — standard Pipecat `push_frame` contract.
71
+
72
+ ## Constructor args (both processors)
73
+
74
+ - `sdk`: a configured `MaximemSynapSDK`
75
+ - `user_id`: required — Synap memory is user-scoped
76
+ - `customer_id`: optional customer/org scope; empty string means customer-less
77
+ - `conversation_id`: optional explicit conversation id (auto-generated per process lifetime when absent, shared across both processors if you pass the same instance)
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ maximem_synap_pipecat.egg-info/PKG-INFO
4
+ maximem_synap_pipecat.egg-info/SOURCES.txt
5
+ maximem_synap_pipecat.egg-info/dependency_links.txt
6
+ maximem_synap_pipecat.egg-info/requires.txt
7
+ maximem_synap_pipecat.egg-info/top_level.txt
8
+ synap_pipecat/__init__.py
9
+ synap_pipecat/processors.py
@@ -0,0 +1,7 @@
1
+ maximem-synap>=0.2.0
2
+ synap-integrations-common>=0.1.0
3
+ pipecat-ai>=0.0.80
4
+
5
+ [dev]
6
+ pytest>=7.0
7
+ pytest-asyncio>=0.21
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "maximem-synap-pipecat"
7
+ version = "0.1.1"
8
+ description = "Synap memory integration for Pipecat — voice-pipeline context injection + conversation recording"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "Apache-2.0"
12
+ authors = [
13
+ {name = "Synap Team"}
14
+ ]
15
+ keywords = ["synap", "memory", "pipecat", "voice", "ai", "agents"]
16
+
17
+ dependencies = [
18
+ "maximem-synap>=0.2.0",
19
+ "synap-integrations-common>=0.1.0",
20
+ "pipecat-ai>=0.0.80",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest>=7.0",
26
+ "pytest-asyncio>=0.21",
27
+ ]
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["."]
31
+ include = ["synap_pipecat*"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,41 @@
1
+ """Synap integration for Pipecat voice pipelines.
2
+
3
+ Pipecat's pipeline is a chain of :class:`FrameProcessor` nodes that exchange
4
+ ``Frame`` objects. Long-term memory integrations plug in at two points:
5
+
6
+ 1. **Before the user context aggregator** — inject relevant memory as an
7
+ extra system message so the LLM sees it before generating its reply.
8
+ Provided here as :class:`SynapMemoryProcessor`.
9
+ 2. **After the assistant context aggregator** — capture the user's final
10
+ transcription and the assistant's streamed response so they can be
11
+ persisted to a long-term store. Provided here as :class:`SynapRecorder`.
12
+
13
+ Typical wiring::
14
+
15
+ from pipecat.pipeline.pipeline import Pipeline
16
+ from pipecat.processors.aggregators.llm_context import LLMContext
17
+ from pipecat.processors.aggregators.llm_response_universal import (
18
+ LLMUserAggregator, LLMAssistantAggregator,
19
+ )
20
+ from synap_pipecat import SynapMemoryProcessor, SynapRecorder
21
+
22
+ context = LLMContext(messages=[...])
23
+ user_agg = LLMUserAggregator(context=context)
24
+ asst_agg = LLMAssistantAggregator(context=context)
25
+
26
+ pipeline = Pipeline([
27
+ transport.input(), stt,
28
+ SynapMemoryProcessor(sdk, user_id="alice", customer_id="acme"),
29
+ user_agg, llm, tts, transport.output(), asst_agg,
30
+ SynapRecorder(sdk, user_id="alice", customer_id="acme"),
31
+ ])
32
+
33
+ See :class:`SynapMemoryProcessor` and :class:`SynapRecorder` for the full
34
+ frame-by-frame contract and error policy (reads degrade silently — a
35
+ Synap blip must never break a live call; writes surface as
36
+ ``SynapIntegrationError`` via ``ErrorFrame``).
37
+ """
38
+
39
+ from synap_pipecat.processors import SynapMemoryProcessor, SynapRecorder
40
+
41
+ __all__ = ["SynapMemoryProcessor", "SynapRecorder"]
@@ -0,0 +1,251 @@
1
+ """Synap memory processor + recorder for Pipecat pipelines.
2
+
3
+ Two :class:`FrameProcessor` subclasses:
4
+
5
+ - :class:`SynapMemoryProcessor` — READ path. Inserted BEFORE the user
6
+ context aggregator. On each :class:`TranscriptionFrame`, fetches Synap
7
+ context via ``sdk.fetch`` and inlines it as a system message into the
8
+ shared ``LLMContext`` so the LLM sees it before replying. The frame
9
+ itself is passed through unchanged (the user aggregator still needs to
10
+ see it to record the user turn).
11
+
12
+ - :class:`SynapRecorder` — WRITE path. Inserted AFTER the assistant
13
+ context aggregator. Buffers the most recent user transcription and the
14
+ streamed assistant text; on :class:`LLMFullResponseEndFrame` it records
15
+ both turns via ``sdk.conversation.record_message``.
16
+
17
+ Error policy (see README):
18
+ - Read failures degrade gracefully — log at ERROR and skip context
19
+ injection. A Synap blip must NEVER break a live voice call.
20
+ - Write failures surface as :class:`SynapIntegrationError` via
21
+ ``wrap_sdk_errors_async``. The processor pushes an ``ErrorFrame``
22
+ upstream so observability hooks fire, but swallows the exception
23
+ locally — Pipecat's own error contract is frames-not-raises.
24
+ - Non-matching frames pass through untouched.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import uuid
31
+ from typing import List, Optional
32
+
33
+ from maximem_synap import MaximemSynapSDK
34
+
35
+ from pipecat.frames.frames import (
36
+ ErrorFrame,
37
+ Frame,
38
+ LLMFullResponseEndFrame,
39
+ LLMTextFrame,
40
+ TranscriptionFrame,
41
+ )
42
+ from pipecat.processors.aggregators.llm_context import LLMContext
43
+ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
44
+
45
+ from synap_integrations_common import (
46
+ SynapIntegrationError,
47
+ wrap_sdk_errors_async,
48
+ )
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ _SYSTEM_MEMORY_PREAMBLE = (
54
+ "Relevant long-term memory about this user (from Synap):\n{body}\n"
55
+ "Use this context only when it helps answer the user's current turn."
56
+ )
57
+
58
+
59
+ class SynapMemoryProcessor(FrameProcessor):
60
+ """Injects Synap context into an ``LLMContext`` on each transcription.
61
+
62
+ Placement: BEFORE the user context aggregator in the pipeline. On each
63
+ :class:`TranscriptionFrame`, fetches user-scoped context from Synap
64
+ and appends it as a system message to the shared ``LLMContext``
65
+ before forwarding the frame downstream. If no ``LLMContext`` is
66
+ supplied at construction, the processor becomes a read-only tap —
67
+ frames still pass through, but no context is injected (useful if you
68
+ want to plug in a recorder without changing the prompt).
69
+
70
+ Args:
71
+ sdk: Configured :class:`MaximemSynapSDK`.
72
+ user_id: Required — Synap memory is user-scoped.
73
+ customer_id: Optional customer/org scope. Empty string means
74
+ customer-less.
75
+ context: Shared :class:`LLMContext` used by the user/assistant
76
+ aggregators. When ``None``, the processor is inert (no
77
+ injection) but still passes frames through.
78
+ mode: Synap fetch mode (``"accurate"`` or ``"fast"``).
79
+ max_results: Cap on ``sdk.fetch`` per turn.
80
+ include_conversation_context: Whether to request Synap's recent
81
+ conversation block alongside the semantic memory.
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ sdk: MaximemSynapSDK,
87
+ *,
88
+ user_id: str,
89
+ customer_id: str = "",
90
+ context: Optional[LLMContext] = None,
91
+ mode: str = "accurate",
92
+ max_results: int = 10,
93
+ include_conversation_context: bool = False,
94
+ name: Optional[str] = None,
95
+ **kwargs,
96
+ ) -> None:
97
+ if sdk is None:
98
+ raise ValueError("SynapMemoryProcessor requires a non-None sdk")
99
+ if not user_id:
100
+ raise ValueError("SynapMemoryProcessor requires a non-empty user_id")
101
+ super().__init__(name=name, **kwargs)
102
+ self.sdk = sdk
103
+ self.user_id = user_id
104
+ self.customer_id = customer_id
105
+ self._context = context
106
+ self.mode = mode
107
+ self.max_results = max_results
108
+ self.include_conversation_context = include_conversation_context
109
+
110
+ def set_context(self, context: LLMContext) -> None:
111
+ """Attach or swap the shared :class:`LLMContext` at runtime."""
112
+ self._context = context
113
+
114
+ async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
115
+ await super().process_frame(frame, direction)
116
+
117
+ if isinstance(frame, TranscriptionFrame) and frame.text:
118
+ await self._inject_context(frame.text)
119
+
120
+ await self.push_frame(frame, direction)
121
+
122
+ async def _inject_context(self, query: str) -> None:
123
+ if self._context is None:
124
+ return
125
+
126
+ try:
127
+ response = await self.sdk.fetch(
128
+ user_id=self.user_id,
129
+ customer_id=self.customer_id or None,
130
+ search_query=[query],
131
+ max_results=self.max_results,
132
+ mode=self.mode,
133
+ include_conversation_context=self.include_conversation_context,
134
+ )
135
+ except Exception as exc: # noqa: BLE001 — read-side graceful degrade
136
+ logger.error(
137
+ "SynapMemoryProcessor: sdk.fetch failed user_id=%s error=%s",
138
+ self.user_id, exc, exc_info=True,
139
+ )
140
+ return
141
+
142
+ formatted = getattr(response, "formatted_context", None) or ""
143
+ if not formatted.strip():
144
+ return # nothing to inject — pass through silently
145
+
146
+ self._context.add_message({
147
+ "role": "system",
148
+ "content": _SYSTEM_MEMORY_PREAMBLE.format(body=formatted.strip()),
149
+ })
150
+
151
+
152
+ class SynapRecorder(FrameProcessor):
153
+ """Records user + assistant turns to Synap at end-of-response.
154
+
155
+ Placement: AFTER the assistant context aggregator in the pipeline.
156
+ Buffers the most recent user transcription (from
157
+ :class:`TranscriptionFrame`) and the streamed assistant tokens (from
158
+ :class:`LLMTextFrame`); on :class:`LLMFullResponseEndFrame`, flushes
159
+ both turns via ``sdk.conversation.record_message`` and resets the
160
+ buffer.
161
+
162
+ Args:
163
+ sdk: Configured :class:`MaximemSynapSDK`.
164
+ user_id: Required — Synap conversations are user-scoped.
165
+ customer_id: Optional customer/org scope. Empty string means
166
+ customer-less.
167
+ conversation_id: Stable id for this call. Auto-generated per
168
+ processor lifetime when absent.
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ sdk: MaximemSynapSDK,
174
+ *,
175
+ user_id: str,
176
+ customer_id: str = "",
177
+ conversation_id: Optional[str] = None,
178
+ name: Optional[str] = None,
179
+ **kwargs,
180
+ ) -> None:
181
+ if sdk is None:
182
+ raise ValueError("SynapRecorder requires a non-None sdk")
183
+ if not user_id:
184
+ raise ValueError("SynapRecorder requires a non-empty user_id")
185
+ super().__init__(name=name, **kwargs)
186
+ self.sdk = sdk
187
+ self.user_id = user_id
188
+ self.customer_id = customer_id
189
+ self.conversation_id = conversation_id or f"pipecat-{uuid.uuid4().hex[:12]}"
190
+
191
+ self._user_buffer: Optional[str] = None
192
+ self._assistant_parts: List[str] = []
193
+
194
+ async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
195
+ await super().process_frame(frame, direction)
196
+
197
+ if isinstance(frame, TranscriptionFrame) and frame.text:
198
+ # Latest transcription wins — voice pipelines can emit several
199
+ # refinements before the LLM responds; only the last is the
200
+ # committed user turn.
201
+ self._user_buffer = frame.text
202
+ elif isinstance(frame, LLMTextFrame) and frame.text:
203
+ self._assistant_parts.append(frame.text)
204
+ elif isinstance(frame, LLMFullResponseEndFrame):
205
+ await self._flush()
206
+
207
+ await self.push_frame(frame, direction)
208
+
209
+ async def _flush(self) -> None:
210
+ user_text = self._user_buffer
211
+ assistant_text = "".join(self._assistant_parts).strip()
212
+ # Reset buffers before we await — the next turn may start flowing
213
+ # while we're still writing to Synap.
214
+ self._user_buffer = None
215
+ self._assistant_parts = []
216
+
217
+ if not user_text and not assistant_text:
218
+ return
219
+
220
+ try:
221
+ async with wrap_sdk_errors_async(
222
+ "pipecat.record_turn",
223
+ logger,
224
+ conversation_id=self.conversation_id,
225
+ user_id=self.user_id,
226
+ ):
227
+ if user_text:
228
+ await self.sdk.conversation.record_message(
229
+ conversation_id=self.conversation_id,
230
+ role="user",
231
+ content=user_text,
232
+ user_id=self.user_id,
233
+ customer_id=self.customer_id,
234
+ )
235
+ if assistant_text:
236
+ await self.sdk.conversation.record_message(
237
+ conversation_id=self.conversation_id,
238
+ role="assistant",
239
+ content=assistant_text,
240
+ user_id=self.user_id,
241
+ customer_id=self.customer_id,
242
+ )
243
+ except SynapIntegrationError as exc:
244
+ # Write failures surface as ErrorFrame (Pipecat's contract —
245
+ # exceptions inside process_frame would tear down the whole
246
+ # pipeline). The wrap_sdk_errors_async call has already
247
+ # logged the underlying cause.
248
+ await self.push_frame(
249
+ ErrorFrame(error=str(exc), exception=exc),
250
+ FrameDirection.UPSTREAM,
251
+ )