pipecat-memorysync 1.0.0__tar.gz → 1.1.0__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,38 @@
1
+ # Changelog
2
+
3
+ All notable changes to `pipecat-memorysync` are documented here.
4
+
5
+ ## 1.1.0 — 2026-08-29
6
+
7
+ - **Turn-complete capture.** Voice aggregators split one utterance across
8
+ several context messages at speech pauses; those fragments previously
9
+ stored as separate rows ("and", "dinner", …). Consecutive new user
10
+ fragments now merge into ONE verbatim turn, stored when the assistant
11
+ reply completes the turn; an in-progress utterance is flushed as one
12
+ merged turn at end of call. Assistant turns still store immediately,
13
+ and a failed store releases its fragments for retry on the next frame.
14
+ - Delta-only and idempotency guarantees unchanged; suite grows to 17
15
+ checks on Pipecat v1.8.1 (fragment merging, end-of-call tail flush,
16
+ failed-store retry).
17
+
18
+ ## 1.0.1 — 2026-08-29
19
+
20
+ - Metadata only: the source repository moved to
21
+ `https://github.com/memorysyncio/memorysync-pipecat`; project URLs updated.
22
+ No code changes.
23
+
24
+ ## 1.0.0 — 2026-08-29
25
+
26
+ Initial release.
27
+
28
+ - `MemorySyncMemoryService`, a `FrameProcessor` for the position between the
29
+ user context aggregator and the LLM service.
30
+ - Budgeted memory recall (default 1.2 s hard timeout) injected into every
31
+ `LLMContextFrame` as a guarded system message — a slow or unreachable
32
+ backend degrades to an unenriched frame, never a stalled reply.
33
+ - Delta-only capture with deterministic idempotency seeds: only turns not
34
+ seen before are persisted, with role fidelity for user and assistant.
35
+ - Injection exclusion: the injected memory block is never re-captured.
36
+ - Graceful `EndFrame` flush (bounded 3 s window) and `CancelFrame` salvage.
37
+ - Tested with Pipecat v1.8.1 through `pipecat.tests.utils.run_test`
38
+ (14-test suite).
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pipecat-memorysync
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: MemorySync for Pipecat: budgeted memory recall that never stalls a voice reply, delta-only conversation persistence with idempotency seeds, and a pipeline that cannot be broken by a memory outage.
5
5
  Project-URL: Homepage, https://docs.memorysync.io/guides/pipecat
6
6
  Project-URL: Documentation, https://docs.memorysync.io/guides/pipecat
7
- Project-URL: Repository, https://github.com/Rafay121/memorysync-plugins
7
+ Project-URL: Repository, https://github.com/memorysyncio/memorysync-pipecat
8
+ Project-URL: Changelog, https://github.com/memorysyncio/memorysync-pipecat/blob/main/CHANGELOG.md
8
9
  Author-email: MemorySync <support@memorysync.io>
9
10
  License-Expression: MIT
10
11
  Keywords: agents,long-term-memory,memory,memorysync,pipecat,voice
@@ -21,14 +22,22 @@ Requires-Dist: httpx<1,>=0.25
21
22
  Requires-Dist: pipecat-ai<2,>=1.0.0
22
23
  Description-Content-Type: text/markdown
23
24
 
24
- # pipecat-memorysync
25
+ # memorysync-pipecat
25
26
 
26
27
  [MemorySync](https://memorysync.io) for [Pipecat](https://github.com/pipecat-ai/pipecat) —
27
28
  long-term memory for voice pipelines that never stalls a reply and never
28
- re-stores what it already knows.
29
+ re-stores what it already knows. PyPI package: **`pipecat-memorysync`**.
30
+
31
+ Built and maintained by the [MemorySync](https://memorysync.io) team —
32
+ MemorySync is our product, and this integration is actively maintained
33
+ alongside it.
34
+
35
+ **Tested with Pipecat v1.8.1** (`pipecat-ai>=1.0.0,<2`).
29
36
 
30
37
  ```bash
31
38
  pip install pipecat-memorysync
39
+ # or
40
+ uv add pipecat-memorysync
32
41
  ```
33
42
 
34
43
  ## Where it sits
@@ -72,10 +81,13 @@ enriched or not, on time.
72
81
  - **Budgeted recall.** Enrichment runs under a hard timeout (default
73
82
  **1.2 s**). A slow or dead memory backend means an unenriched frame, never a
74
83
  stalled voice reply.
84
+ - **Turn-complete capture.** Voice aggregators split one utterance across
85
+ several context messages at speech pauses; consecutive fragments merge
86
+ into ONE stored turn when the assistant's reply completes it (the
87
+ in-progress tail flushes at end of call) — no per-fragment junk rows.
75
88
  - **Delta-only capture.** Only messages *not seen before* are stored, tracked
76
89
  by deterministic idempotency seeds. Growing a 50-message context does not
77
- re-store 50 messages per turn (a real flaw in some in-tree memory services,
78
- which re-send the entire context every frame — O(n²) writes per call).
90
+ re-store 50 messages per turn.
79
91
  - **Injection exclusion.** The memory block this service adds is never captured
80
92
  back as a new memory.
81
93
  - **Graceful end, salvaged abort.** On `EndFrame`, queued writes get a bounded
@@ -85,6 +97,25 @@ enriched or not, on time.
85
97
  - **Failure-proof.** HTTP errors, quota limits, and timeouts all degrade to
86
98
  "no memories this turn". Nothing propagates into the pipeline.
87
99
 
100
+ ## Running the example
101
+
102
+ A single-file voice agent that remembers callers across calls lives in
103
+ [`examples/foundational.py`](examples/foundational.py):
104
+
105
+ ```bash
106
+ uv add pipecat-memorysync "pipecat-ai[deepgram,cartesia,openai,silero,runner,webrtc]"
107
+
108
+ export MEMORYSYNC_API_KEY=ms_... # https://app.memorysync.io
109
+ export DEEPGRAM_API_KEY=...
110
+ export CARTESIA_API_KEY=...
111
+ export OPENAI_API_KEY=...
112
+
113
+ python examples/foundational.py
114
+ ```
115
+
116
+ Open `http://localhost:7860/client`, tell the bot your name and a preference,
117
+ hang up, and connect again — it remembers.
118
+
88
119
  ## Configuration (`InputParams`)
89
120
 
90
121
  ```python
@@ -128,6 +159,10 @@ python -m venv venv && venv/Scripts/pip install -e . pipecat-ai pytest pytest-as
128
159
  venv/Scripts/python -m pytest tests -q # 14 tests, run via pipecat's official test harness
129
160
  ```
130
161
 
162
+ ## Docs
163
+
164
+ Full guide: [docs.memorysync.io/guides/pipecat](https://docs.memorysync.io/guides/pipecat)
165
+
131
166
  ## License
132
167
 
133
168
  MIT
@@ -1,11 +1,19 @@
1
- # pipecat-memorysync
1
+ # memorysync-pipecat
2
2
 
3
3
  [MemorySync](https://memorysync.io) for [Pipecat](https://github.com/pipecat-ai/pipecat) —
4
4
  long-term memory for voice pipelines that never stalls a reply and never
5
- re-stores what it already knows.
5
+ re-stores what it already knows. PyPI package: **`pipecat-memorysync`**.
6
+
7
+ Built and maintained by the [MemorySync](https://memorysync.io) team —
8
+ MemorySync is our product, and this integration is actively maintained
9
+ alongside it.
10
+
11
+ **Tested with Pipecat v1.8.1** (`pipecat-ai>=1.0.0,<2`).
6
12
 
7
13
  ```bash
8
14
  pip install pipecat-memorysync
15
+ # or
16
+ uv add pipecat-memorysync
9
17
  ```
10
18
 
11
19
  ## Where it sits
@@ -49,10 +57,13 @@ enriched or not, on time.
49
57
  - **Budgeted recall.** Enrichment runs under a hard timeout (default
50
58
  **1.2 s**). A slow or dead memory backend means an unenriched frame, never a
51
59
  stalled voice reply.
60
+ - **Turn-complete capture.** Voice aggregators split one utterance across
61
+ several context messages at speech pauses; consecutive fragments merge
62
+ into ONE stored turn when the assistant's reply completes it (the
63
+ in-progress tail flushes at end of call) — no per-fragment junk rows.
52
64
  - **Delta-only capture.** Only messages *not seen before* are stored, tracked
53
65
  by deterministic idempotency seeds. Growing a 50-message context does not
54
- re-store 50 messages per turn (a real flaw in some in-tree memory services,
55
- which re-send the entire context every frame — O(n²) writes per call).
66
+ re-store 50 messages per turn.
56
67
  - **Injection exclusion.** The memory block this service adds is never captured
57
68
  back as a new memory.
58
69
  - **Graceful end, salvaged abort.** On `EndFrame`, queued writes get a bounded
@@ -62,6 +73,25 @@ enriched or not, on time.
62
73
  - **Failure-proof.** HTTP errors, quota limits, and timeouts all degrade to
63
74
  "no memories this turn". Nothing propagates into the pipeline.
64
75
 
76
+ ## Running the example
77
+
78
+ A single-file voice agent that remembers callers across calls lives in
79
+ [`examples/foundational.py`](examples/foundational.py):
80
+
81
+ ```bash
82
+ uv add pipecat-memorysync "pipecat-ai[deepgram,cartesia,openai,silero,runner,webrtc]"
83
+
84
+ export MEMORYSYNC_API_KEY=ms_... # https://app.memorysync.io
85
+ export DEEPGRAM_API_KEY=...
86
+ export CARTESIA_API_KEY=...
87
+ export OPENAI_API_KEY=...
88
+
89
+ python examples/foundational.py
90
+ ```
91
+
92
+ Open `http://localhost:7860/client`, tell the bot your name and a preference,
93
+ hang up, and connect again — it remembers.
94
+
65
95
  ## Configuration (`InputParams`)
66
96
 
67
97
  ```python
@@ -105,6 +135,10 @@ python -m venv venv && venv/Scripts/pip install -e . pipecat-ai pytest pytest-as
105
135
  venv/Scripts/python -m pytest tests -q # 14 tests, run via pipecat's official test harness
106
136
  ```
107
137
 
138
+ ## Docs
139
+
140
+ Full guide: [docs.memorysync.io/guides/pipecat](https://docs.memorysync.io/guides/pipecat)
141
+
108
142
  ## License
109
143
 
110
144
  MIT
@@ -0,0 +1,128 @@
1
+ #
2
+ # Foundational example: a voice agent that remembers callers across calls.
3
+ #
4
+ # MemorySyncMemoryService sits between the user context aggregator and the
5
+ # LLM: every LLMContextFrame is enriched with relevant long-term memories
6
+ # under a hard time budget, and new turns are persisted in the background.
7
+ # A slow or unreachable memory backend can never stall the voice reply.
8
+ #
9
+ # Run (choose any transport supported by the Pipecat runner):
10
+ #
11
+ # uv add pipecat-memorysync "pipecat-ai[deepgram,cartesia,openai,silero,runner,webrtc]"
12
+ # export MEMORYSYNC_API_KEY=ms_... # https://app.memorysync.io
13
+ # export DEEPGRAM_API_KEY=...
14
+ # export CARTESIA_API_KEY=...
15
+ # export OPENAI_API_KEY=...
16
+ # python foundational.py
17
+ #
18
+ # Then open http://localhost:7860/client, talk to the bot, tell it your
19
+ # name and a preference, hang up, and call again: it remembers.
20
+ #
21
+
22
+ import os
23
+
24
+ from dotenv import load_dotenv
25
+ from loguru import logger
26
+
27
+ from pipecat.audio.vad.silero import SileroVADAnalyzer
28
+ from pipecat.frames.frames import LLMRunFrame
29
+ from pipecat.pipeline.pipeline import Pipeline
30
+ from pipecat.pipeline.runner import PipelineRunner
31
+ from pipecat.pipeline.task import PipelineParams, PipelineTask
32
+ from pipecat.processors.aggregators.llm_context import LLMContext
33
+ from pipecat.processors.aggregators.llm_response_universal import (
34
+ LLMContextAggregatorPair,
35
+ LLMUserAggregatorParams,
36
+ )
37
+ from pipecat.runner.types import RunnerArguments
38
+ from pipecat.runner.utils import create_transport
39
+ from pipecat.services.cartesia.tts import CartesiaTTSService
40
+ from pipecat.services.deepgram.stt import DeepgramSTTService
41
+ from pipecat.services.openai.llm import OpenAILLMService
42
+ from pipecat.transports.base_transport import BaseTransport, TransportParams
43
+
44
+ from pipecat_memorysync import MemorySyncMemoryService
45
+
46
+ load_dotenv(override=True)
47
+
48
+ SYSTEM_INSTRUCTION = (
49
+ "You are a friendly voice assistant with long-term memory. "
50
+ "Relevant facts about the caller may appear as background memory context. "
51
+ "Use them naturally; never read them out verbatim. "
52
+ "Your answers are spoken aloud, so keep them short and conversational."
53
+ )
54
+
55
+ transport_params = {
56
+ "webrtc": lambda: TransportParams(audio_in_enabled=True, audio_out_enabled=True),
57
+ }
58
+
59
+
60
+ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
61
+ logger.info("Starting bot")
62
+
63
+ stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
64
+
65
+ tts = CartesiaTTSService(
66
+ api_key=os.environ["CARTESIA_API_KEY"],
67
+ voice_id="71a7ad14-091c-4e8e-a314-022ece01c121",
68
+ )
69
+
70
+ llm = OpenAILLMService(api_key=os.environ["OPENAI_API_KEY"])
71
+
72
+ # Long-term memory. In a real deployment, derive user_id from the
73
+ # caller's identity (phone number, account id, ...) so each caller
74
+ # gets their own memories.
75
+ memory = MemorySyncMemoryService(
76
+ api_key=os.environ["MEMORYSYNC_API_KEY"],
77
+ user_id=os.environ.get("MEMORYSYNC_USER_ID", "demo-caller"),
78
+ )
79
+
80
+ context = LLMContext([{"role": "system", "content": SYSTEM_INSTRUCTION}])
81
+ context_aggregator = LLMContextAggregatorPair(
82
+ context,
83
+ user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
84
+ )
85
+
86
+ pipeline = Pipeline(
87
+ [
88
+ transport.input(),
89
+ stt,
90
+ context_aggregator.user(),
91
+ memory, # enrich with memories + capture new turns, on budget
92
+ llm,
93
+ tts,
94
+ transport.output(),
95
+ context_aggregator.assistant(),
96
+ ]
97
+ )
98
+
99
+ task = PipelineTask(
100
+ pipeline,
101
+ params=PipelineParams(enable_metrics=True, enable_usage_metrics=True),
102
+ idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
103
+ )
104
+
105
+ @transport.event_handler("on_client_connected")
106
+ async def on_client_connected(transport, client):
107
+ logger.info("Client connected")
108
+ await task.queue_frames([LLMRunFrame()])
109
+
110
+ @transport.event_handler("on_client_disconnected")
111
+ async def on_client_disconnected(transport, client):
112
+ logger.info("Client disconnected")
113
+ await task.cancel()
114
+
115
+ runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
116
+ await runner.run(task)
117
+
118
+
119
+ async def bot(runner_args: RunnerArguments):
120
+ """Main bot entry point compatible with the Pipecat runner."""
121
+ transport = await create_transport(runner_args, transport_params)
122
+ await run_bot(transport, runner_args)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ from pipecat.runner.run import main
127
+
128
+ main()
@@ -29,7 +29,8 @@ dependencies = [
29
29
  [project.urls]
30
30
  Homepage = "https://docs.memorysync.io/guides/pipecat"
31
31
  Documentation = "https://docs.memorysync.io/guides/pipecat"
32
- Repository = "https://github.com/Rafay121/memorysync-plugins"
32
+ Repository = "https://github.com/memorysyncio/memorysync-pipecat"
33
+ Changelog = "https://github.com/memorysyncio/memorysync-pipecat/blob/main/CHANGELOG.md"
33
34
 
34
35
  [tool.hatch.version]
35
36
  path = "src/pipecat_memorysync/_version.py"
@@ -1,3 +1,3 @@
1
1
  """Version for pipecat-memorysync. Single source of truth."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.0"
@@ -7,9 +7,13 @@ predecessors don't:
7
7
  1. **Recall is budgeted.** Enrichment waits at most ``recall_timeout``
8
8
  seconds (default 1.2). On timeout or failure the context frame passes
9
9
  through unenriched — a voice reply is never stalled by a slow network.
10
- 2. **Capture is delta-only.** Each turn stores only the messages that are
11
- NEW since the last frame, verbatim, with cross-adapter fnv1a64
12
- idempotency seeds — not the whole conversation re-sent every turn.
10
+ 2. **Capture is turn-complete and delta-only.** Voice aggregators split
11
+ one utterance across several context messages at speech pauses; this
12
+ service merges consecutive new user fragments and stores them as ONE
13
+ verbatim turn once the assistant's reply marks the turn complete (the
14
+ in-progress tail is flushed at end of call). Only NEW messages are
15
+ considered each frame — never the whole conversation re-sent — and
16
+ every stored turn carries a cross-adapter fnv1a64 idempotency seed.
13
17
  3. **Nothing raises, nothing is dropped.** Every failure path logs and
14
18
  pushes the ORIGINAL frame through. The pipeline cannot stall and the
15
19
  LLM always gets its context.
@@ -137,6 +141,14 @@ class MemorySyncMemoryService(FrameProcessor):
137
141
  self.params = params
138
142
 
139
143
  self._stored_seeds: Set[str] = set()
144
+ # Message-level consumption ledger: which context messages have
145
+ # been merged into a stored turn. Distinct from _stored_seeds
146
+ # (which dedups at merged-turn granularity) so a failed store can
147
+ # release its messages for re-capture on the next frame.
148
+ self._seen_messages: Set[str] = set()
149
+ # Snapshot of the in-progress user utterance (fragments after the
150
+ # last assistant reply) — flushed as one turn at end of call.
151
+ self._tail_pending: List[tuple[str, str]] = []
140
152
  self._store_tasks: Set[asyncio.Task] = set()
141
153
  self._last_query: Optional[str] = None
142
154
 
@@ -237,11 +249,25 @@ class MemorySyncMemoryService(FrameProcessor):
237
249
  body = "\n".join(lines)
238
250
  return f"{self.params.system_prompt}\n{body}\n\n{CONTEXT_GUARD}"
239
251
 
240
- # ── capture: delta-only, background ────────────────────────────────
252
+ # ── capture: turn-complete, delta-only, background ─────────────────
241
253
 
242
254
  def _capture_delta(self, context: Any) -> None:
243
- """Queue storage for messages NOT seen before. O(new), not O(all)."""
255
+ """Queue storage for completed turns built from NEW messages only.
256
+
257
+ Voice aggregators append each speech fragment as its own user
258
+ message ("Do you know that my favorite", "dinner", "and", …).
259
+ Storing them individually floods memory with junk rows, so:
260
+
261
+ - consecutive NEW user messages merge into ONE turn, stored the
262
+ moment an assistant message follows them (turn completed);
263
+ - user messages after the last assistant reply are an in-progress
264
+ utterance — they stay pending (snapshotted for the end-of-call
265
+ flush) instead of being stored piecemeal;
266
+ - NEW assistant messages store immediately, flushing any pending
267
+ user group first so ordering survives.
268
+ """
244
269
  header = self.params.system_prompt
270
+ entries: List[tuple[str, str]] = [] # (speaker_role, trimmed_text)
245
271
  for message in context.get_messages():
246
272
  role = message.get("role")
247
273
  if role not in ("user", "assistant"):
@@ -251,19 +277,78 @@ class MemorySyncMemoryService(FrameProcessor):
251
277
  continue # our own injection (user-role mode) never re-enters
252
278
  speaker_role = "human" if role == "user" else "ai"
253
279
  trimmed = text if len(text) <= MAX_TURN_CHARS else text[:MAX_TURN_CHARS] + "…"
254
- seed = f"{speaker_role}:{trimmed}"
255
- if seed in self._stored_seeds:
256
- continue
257
- self._stored_seeds.add(seed)
258
- if len(self._stored_seeds) > 4096:
259
- self._stored_seeds.clear()
260
- # Plain asyncio tasks, tracked locally: pipeline teardown must
261
- # not cancel a persist mid-flight — _flush owns their fate.
262
- task = asyncio.create_task(self._store_turn(speaker_role, trimmed, seed))
263
- self._store_tasks.add(task)
264
- task.add_done_callback(self._store_tasks.discard)
265
-
266
- async def _store_turn(self, speaker_role: str, text: str, seed: str) -> None:
280
+ entries.append((speaker_role, trimmed))
281
+
282
+ last_ai = -1
283
+ for index, (speaker_role, _text) in enumerate(entries):
284
+ if speaker_role == "ai":
285
+ last_ai = index
286
+
287
+ group_texts: List[str] = []
288
+ group_keys: List[str] = []
289
+ for index, (speaker_role, text) in enumerate(entries):
290
+ key = f"{speaker_role}:{text}"
291
+ if speaker_role == "human":
292
+ if index > last_ai:
293
+ continue # in-progress utterance — tail snapshot below
294
+ if key in self._seen_messages:
295
+ continue
296
+ group_texts.append(text)
297
+ group_keys.append(key)
298
+ else:
299
+ if group_texts:
300
+ self._store_merged(group_texts, group_keys)
301
+ group_texts, group_keys = [], []
302
+ if key not in self._seen_messages:
303
+ self._seen_messages.add(key)
304
+ self._bound_seen()
305
+ self._queue_store("ai", text, [key])
306
+
307
+ self._tail_pending = [
308
+ (text, f"human:{text}")
309
+ for speaker_role, text in entries[last_ai + 1:]
310
+ if speaker_role == "human" and f"human:{text}" not in self._seen_messages
311
+ ]
312
+
313
+ def _store_merged(self, texts: List[str], keys: List[str]) -> None:
314
+ """One utterance from its fragments: mark consumed, merge, store."""
315
+ for key in keys:
316
+ self._seen_messages.add(key)
317
+ self._bound_seen()
318
+ merged = " ".join(texts)
319
+ if len(merged) > MAX_TURN_CHARS:
320
+ merged = merged[:MAX_TURN_CHARS] + "…"
321
+ self._queue_store("human", merged, keys)
322
+
323
+ def _flush_tail(self) -> None:
324
+ """Store the in-progress utterance (end-of-call, no reply coming)."""
325
+ if not self._tail_pending:
326
+ return
327
+ texts = [text for text, _key in self._tail_pending]
328
+ keys = [key for _text, key in self._tail_pending]
329
+ self._tail_pending = []
330
+ self._store_merged(texts, keys)
331
+
332
+ def _bound_seen(self) -> None:
333
+ if len(self._seen_messages) > 4096:
334
+ self._seen_messages.clear()
335
+
336
+ def _queue_store(self, speaker_role: str, text: str, msg_keys: List[str]) -> None:
337
+ seed = f"{speaker_role}:{text}"
338
+ if seed in self._stored_seeds:
339
+ return
340
+ self._stored_seeds.add(seed)
341
+ if len(self._stored_seeds) > 4096:
342
+ self._stored_seeds.clear()
343
+ # Plain asyncio tasks, tracked locally: pipeline teardown must
344
+ # not cancel a persist mid-flight — _flush owns their fate.
345
+ task = asyncio.create_task(self._store_turn(speaker_role, text, seed, msg_keys))
346
+ self._store_tasks.add(task)
347
+ task.add_done_callback(self._store_tasks.discard)
348
+
349
+ async def _store_turn(
350
+ self, speaker_role: str, text: str, seed: str, msg_keys: List[str]
351
+ ) -> None:
267
352
  try:
268
353
  tenant = await self._api.resolve_tenant_id()
269
354
  await self._api.add_turn(
@@ -274,7 +359,11 @@ class MemorySyncMemoryService(FrameProcessor):
274
359
  metadata={"session_id": self.scope},
275
360
  )
276
361
  except Exception as exc: # noqa: BLE001
277
- self._stored_seeds.discard(seed) # the write never landed; retry later
362
+ # The write never landed: release both ledgers so the next
363
+ # frame re-captures these messages and retries the store.
364
+ self._stored_seeds.discard(seed)
365
+ for key in msg_keys:
366
+ self._seen_messages.discard(key)
278
367
  logger.debug(f"memorysync: store failed: {exc}")
279
368
 
280
369
  # ── conveniences (outside the pipeline) ───────────────────────────
@@ -303,7 +392,12 @@ class MemorySyncMemoryService(FrameProcessor):
303
392
  # ── lifecycle ──────────────────────────────────────────────────────
304
393
 
305
394
  async def _flush(self, *, timeout: float) -> None:
306
- """Let queued stores land, bounded. Never raises."""
395
+ """Store the in-progress utterance, then let queued stores land,
396
+ bounded. Never raises."""
397
+ try:
398
+ self._flush_tail()
399
+ except Exception: # noqa: BLE001
400
+ pass
307
401
  pending = [t for t in self._store_tasks if not t.done()]
308
402
  if not pending:
309
403
  return
@@ -28,12 +28,20 @@ def context_of(messages: List[dict]) -> LLMContext:
28
28
 
29
29
 
30
30
  async def drive(service: Any, *contexts: LLMContext) -> None:
31
- """Send one LLMContextFrame per context through the real harness."""
31
+ """Send one LLMContextFrame per context through the real harness.
32
+
33
+ ``start_timeout`` defaults to 1s inside pipecat's harness, which the
34
+ FIRST test in the session routinely blows on a cold shared CI runner
35
+ (import machinery + task spin-up), failing it with a TimeoutError
36
+ before any frame flows. A generous ceiling costs nothing when the
37
+ pipeline starts fast — it is not a sleep — and removes the flake.
38
+ """
32
39
  frames = [LLMContextFrame(context=ctx) for ctx in contexts]
33
40
  await run_test(
34
41
  service,
35
42
  frames_to_send=frames,
36
43
  expected_down_frames=[LLMContextFrame] * len(frames),
44
+ start_timeout=30.0,
37
45
  )
38
46
 
39
47
 
@@ -242,3 +250,75 @@ async def test_conveniences_answer(mock, make_service):
242
250
  async def test_missing_user_id_is_loud_at_construction(mock):
243
251
  with pytest.raises(ValueError, match="user_id"):
244
252
  MemorySyncMemoryService(api_key="ms_x", user_id="", transport=mock.transport())
253
+
254
+
255
+ # ── capture: turn-complete merging (voice fragmenting) ────────────────
256
+
257
+
258
+ async def test_speech_fragments_merge_into_one_turn(mock, make_service):
259
+ """VAD pauses split one utterance across context messages — they must
260
+ store as ONE merged row when the assistant reply completes the turn,
261
+ never as per-fragment junk rows ("and", "dinner", …)."""
262
+ service = make_service()
263
+ fragments = [
264
+ "Do you know that my favorite",
265
+ "dinner",
266
+ "and",
267
+ "dinner is my favorite dish is biryani.",
268
+ ]
269
+ # The aggregator's view grows frame by frame while the user speaks…
270
+ growing = [
271
+ context_of([{"role": "user", "content": f} for f in fragments[: i + 1]])
272
+ for i in range(len(fragments))
273
+ ]
274
+ # …and the turn completes when the assistant replies.
275
+ completed = context_of(
276
+ [{"role": "user", "content": f} for f in fragments]
277
+ + [{"role": "assistant", "content": "Got it, biryani it is!"}]
278
+ )
279
+ await drive(service, *growing, completed)
280
+ await drain(mock, 2)
281
+
282
+ texts = sorted(r["text"] for r in mock.rows)
283
+ assert texts == [
284
+ "ai: Got it, biryani it is!",
285
+ "human: Do you know that my favorite dinner and dinner is my favorite dish is biryani.",
286
+ ]
287
+ assert mock.add_turn_calls() == 2, "four fragments + one reply = exactly two rows"
288
+
289
+
290
+ async def test_in_progress_utterance_flushes_once_at_end_of_call(mock, make_service):
291
+ """A call that ends mid-utterance (no reply coming) must not lose the
292
+ tail — and must store it merged, not per fragment."""
293
+ service = make_service()
294
+ ctx1 = context_of([{"role": "user", "content": "my name is Abdullah"}])
295
+ ctx2 = context_of([
296
+ {"role": "user", "content": "my name is Abdullah"},
297
+ {"role": "user", "content": "and my favorite color is black"},
298
+ ])
299
+ await drive(service, ctx1, ctx2) # run_test's EndFrame triggers the flush
300
+ await drain(mock, 1)
301
+
302
+ assert [r["text"] for r in mock.rows] == [
303
+ "human: my name is Abdullah and my favorite color is black"
304
+ ]
305
+ assert mock.add_turn_calls() == 1
306
+
307
+
308
+ async def test_failed_store_releases_fragments_for_retry(mock, make_service):
309
+ """A store that never landed must not consume its messages — the next
310
+ frame re-captures and retries, converging on one row."""
311
+ service = make_service()
312
+ mock.fail_next = 503
313
+ turn = [
314
+ {"role": "user", "content": "remember that I fly out of Hyderabad"},
315
+ {"role": "assistant", "content": "Noted!"},
316
+ ]
317
+ await drive(service, context_of(turn))
318
+ await asyncio.sleep(0.3) # the 503 lands on exactly one of the two stores
319
+
320
+ await drive(service, context_of(turn)) # same context re-seen → retry
321
+ await drain(mock, 2)
322
+
323
+ texts = sorted(r["text"] for r in mock.rows)
324
+ assert texts == ["ai: Noted!", "human: remember that I fly out of Hyderabad"]