pipecat-memorysync 1.0.1__tar.gz → 1.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,51 @@
1
+ # Changelog
2
+
3
+ All notable changes to `pipecat-memorysync` are documented here.
4
+
5
+ ## 1.1.1 — 2026-08-29
6
+
7
+ - **Immediate, loss-proof capture.** 1.1.0's turn-complete merging
8
+ deferred the current utterance until the turn completed — on a browser
9
+ disconnect that deferral raced the brief cancel salvage window and
10
+ could lose the newest (usually most important) turn over slow
11
+ networks. Capture is immediate again: every new user/assistant message
12
+ is in flight the moment its frame passes, BEFORE the LLM replies.
13
+ Junk filtering and fact extraction now happen server-side (the
14
+ platform's low-value gate + conversational ingestion), so client-side
15
+ merging is unnecessary. Suite: 17 checks on Pipecat v1.8.1, including
16
+ the exact disconnect sequence that previously lost data.
17
+
18
+ ## 1.1.0 — 2026-08-29
19
+
20
+ - **Turn-complete capture.** Voice aggregators split one utterance across
21
+ several context messages at speech pauses; those fragments previously
22
+ stored as separate rows ("and", "dinner", …). Consecutive new user
23
+ fragments now merge into ONE verbatim turn, stored when the assistant
24
+ reply completes the turn; an in-progress utterance is flushed as one
25
+ merged turn at end of call. Assistant turns still store immediately,
26
+ and a failed store releases its fragments for retry on the next frame.
27
+ - Delta-only and idempotency guarantees unchanged; suite grows to 17
28
+ checks on Pipecat v1.8.1 (fragment merging, end-of-call tail flush,
29
+ failed-store retry).
30
+
31
+ ## 1.0.1 — 2026-08-29
32
+
33
+ - Metadata only: the source repository moved to
34
+ `https://github.com/memorysyncio/memorysync-pipecat`; project URLs updated.
35
+ No code changes.
36
+
37
+ ## 1.0.0 — 2026-08-29
38
+
39
+ Initial release.
40
+
41
+ - `MemorySyncMemoryService`, a `FrameProcessor` for the position between the
42
+ user context aggregator and the LLM service.
43
+ - Budgeted memory recall (default 1.2 s hard timeout) injected into every
44
+ `LLMContextFrame` as a guarded system message — a slow or unreachable
45
+ backend degrades to an unenriched frame, never a stalled reply.
46
+ - Delta-only capture with deterministic idempotency seeds: only turns not
47
+ seen before are persisted, with role fidelity for user and assistant.
48
+ - Injection exclusion: the injected memory block is never re-captured.
49
+ - Graceful `EndFrame` flush (bounded 3 s window) and `CancelFrame` salvage.
50
+ - Tested with Pipecat v1.8.1 through `pipecat.tests.utils.run_test`
51
+ (14-test suite).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pipecat-memorysync
3
- Version: 1.0.1
3
+ Version: 1.1.1
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
@@ -81,6 +81,12 @@ enriched or not, on time.
81
81
  - **Budgeted recall.** Enrichment runs under a hard timeout (default
82
82
  **1.2 s**). A slow or dead memory backend means an unenriched frame, never a
83
83
  stalled voice reply.
84
+ - **Immediate, loss-proof capture.** Every new message is sent the moment
85
+ its frame passes — the current utterance is in flight BEFORE the LLM
86
+ replies, so a disconnect can never lose it. Junk filtering and fact
87
+ extraction happen server-side (the platform's low-value gate +
88
+ conversational ingestion): the memory store receives curated facts,
89
+ not transcript clutter.
84
90
  - **Delta-only capture.** Only messages *not seen before* are stored, tracked
85
91
  by deterministic idempotency seeds. Growing a 50-message context does not
86
92
  re-store 50 messages per turn.
@@ -57,6 +57,12 @@ enriched or not, on time.
57
57
  - **Budgeted recall.** Enrichment runs under a hard timeout (default
58
58
  **1.2 s**). A slow or dead memory backend means an unenriched frame, never a
59
59
  stalled voice reply.
60
+ - **Immediate, loss-proof capture.** Every new message is sent the moment
61
+ its frame passes — the current utterance is in flight BEFORE the LLM
62
+ replies, so a disconnect can never lose it. Junk filtering and fact
63
+ extraction happen server-side (the platform's low-value gate +
64
+ conversational ingestion): the memory store receives curated facts,
65
+ not transcript clutter.
60
66
  - **Delta-only capture.** Only messages *not seen before* are stored, tracked
61
67
  by deterministic idempotency seeds. Growing a 50-message context does not
62
68
  re-store 50 messages per turn.
@@ -1,3 +1,3 @@
1
1
  """Version for pipecat-memorysync. Single source of truth."""
2
2
 
3
- __version__ = "1.0.1"
3
+ __version__ = "1.1.1"
@@ -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 immediate and delta-only.** Every NEW user/assistant
11
+ message is sent to the platform the moment it appears in a context
12
+ frame — the current utterance is already in flight BEFORE the LLM
13
+ replies, so a disconnect can never lose it. Junk filtering and fact
14
+ extraction are the platform's job (the low-value gate + conversational
15
+ ingestion), not the client's. Only new messages are sent each frame,
16
+ with cross-adapter fnv1a64 idempotency seeds.
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,9 @@ class MemorySyncMemoryService(FrameProcessor):
137
141
  self.params = params
138
142
 
139
143
  self._stored_seeds: Set[str] = set()
144
+ # Message-level ledger: which context messages have been sent.
145
+ # A failed store releases its message so the next frame retries.
146
+ self._seen_messages: Set[str] = set()
140
147
  self._store_tasks: Set[asyncio.Task] = set()
141
148
  self._last_query: Optional[str] = None
142
149
 
@@ -237,10 +244,20 @@ class MemorySyncMemoryService(FrameProcessor):
237
244
  body = "\n".join(lines)
238
245
  return f"{self.params.system_prompt}\n{body}\n\n{CONTEXT_GUARD}"
239
246
 
240
- # ── capture: delta-only, background ────────────────────────────────
247
+ # ── capture: immediate, delta-only, background ─────────────────────
241
248
 
242
249
  def _capture_delta(self, context: Any) -> None:
243
- """Queue storage for messages NOT seen before. O(new), not O(all)."""
250
+ """Queue storage for NEW messages the moment they appear.
251
+
252
+ The platform owns junk filtering (the low-value gate) and fact
253
+ extraction (conversational ingestion), so the adapter's single
254
+ duty is delivering every turn reliably and EARLY. The current
255
+ user utterance is in the frame BEFORE the LLM replies — sending
256
+ it immediately means a mid-call disconnect can never lose it.
257
+ (1.1.0 deferred the current utterance until the turn completed;
258
+ that deferral raced the disconnect salvage window and could drop
259
+ the newest — usually most important — turn. Never again.)
260
+ """
244
261
  header = self.params.system_prompt
245
262
  for message in context.get_messages():
246
263
  role = message.get("role")
@@ -251,19 +268,33 @@ class MemorySyncMemoryService(FrameProcessor):
251
268
  continue # our own injection (user-role mode) never re-enters
252
269
  speaker_role = "human" if role == "user" else "ai"
253
270
  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:
271
+ key = f"{speaker_role}:{trimmed}"
272
+ if key in self._seen_messages:
256
273
  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:
274
+ self._seen_messages.add(key)
275
+ self._bound_seen()
276
+ self._queue_store(speaker_role, trimmed, [key])
277
+
278
+ def _bound_seen(self) -> None:
279
+ if len(self._seen_messages) > 4096:
280
+ self._seen_messages.clear()
281
+
282
+ def _queue_store(self, speaker_role: str, text: str, msg_keys: List[str]) -> None:
283
+ seed = f"{speaker_role}:{text}"
284
+ if seed in self._stored_seeds:
285
+ return
286
+ self._stored_seeds.add(seed)
287
+ if len(self._stored_seeds) > 4096:
288
+ self._stored_seeds.clear()
289
+ # Plain asyncio tasks, tracked locally: pipeline teardown must
290
+ # not cancel a persist mid-flight — _flush owns their fate.
291
+ task = asyncio.create_task(self._store_turn(speaker_role, text, seed, msg_keys))
292
+ self._store_tasks.add(task)
293
+ task.add_done_callback(self._store_tasks.discard)
294
+
295
+ async def _store_turn(
296
+ self, speaker_role: str, text: str, seed: str, msg_keys: List[str]
297
+ ) -> None:
267
298
  try:
268
299
  tenant = await self._api.resolve_tenant_id()
269
300
  await self._api.add_turn(
@@ -274,7 +305,11 @@ class MemorySyncMemoryService(FrameProcessor):
274
305
  metadata={"session_id": self.scope},
275
306
  )
276
307
  except Exception as exc: # noqa: BLE001
277
- self._stored_seeds.discard(seed) # the write never landed; retry later
308
+ # The write never landed: release both ledgers so the next
309
+ # frame re-captures this message and retries the store.
310
+ self._stored_seeds.discard(seed)
311
+ for key in msg_keys:
312
+ self._seen_messages.discard(key)
278
313
  logger.debug(f"memorysync: store failed: {exc}")
279
314
 
280
315
  # ── conveniences (outside the pipeline) ───────────────────────────
@@ -250,3 +250,61 @@ async def test_conveniences_answer(mock, make_service):
250
250
  async def test_missing_user_id_is_loud_at_construction(mock):
251
251
  with pytest.raises(ValueError, match="user_id"):
252
252
  MemorySyncMemoryService(api_key="ms_x", user_id="", transport=mock.transport())
253
+
254
+
255
+ # ── capture: immediate and loss-proof ─────────────────────────────────
256
+
257
+
258
+ async def test_current_utterance_stores_before_any_reply(mock, make_service):
259
+ """The user's words must be in flight the moment the frame passes —
260
+ BEFORE the LLM replies — so a disconnect can never lose them."""
261
+ service = make_service()
262
+ ctx = context_of([
263
+ {"role": "user", "content": "My age is twenty two and I completed my bachelor's."},
264
+ ])
265
+ await drive(service, ctx)
266
+ await drain(mock, 1)
267
+ assert [r["text"] for r in mock.rows] == [
268
+ "human: My age is twenty two and I completed my bachelor's."
269
+ ]
270
+
271
+
272
+ async def test_demo_disconnect_sequence_loses_nothing(mock, make_service):
273
+ """The exact sequence that lost data in 1.1.0: greeting frame, then a
274
+ frame carrying the reply + the important utterance, then immediate
275
+ CancelFrame (browser disconnect). Every message must already be
276
+ stored — nothing may depend on a post-cancel flush window."""
277
+ service = make_service()
278
+ f1 = context_of([{"role": "user", "content": "Hello there, anyone home?"}])
279
+ f2 = context_of([
280
+ {"role": "user", "content": "Hello there, anyone home?"},
281
+ {"role": "assistant", "content": "Hi! How can I help you today?"},
282
+ {"role": "user", "content": "My age is twenty two and I completed my bachelor's."},
283
+ ])
284
+ await drive(service, f1, f2) # run_test tears the pipeline down right after
285
+ await drain(mock, 3)
286
+ texts = sorted(r["text"] for r in mock.rows)
287
+ assert texts == [
288
+ "ai: Hi! How can I help you today?",
289
+ "human: Hello there, anyone home?",
290
+ "human: My age is twenty two and I completed my bachelor's.",
291
+ ]
292
+
293
+
294
+ async def test_failed_store_releases_message_for_retry(mock, make_service):
295
+ """A store that never landed must not consume its message — the next
296
+ frame re-captures and retries, converging on one row."""
297
+ service = make_service()
298
+ mock.fail_next = 503
299
+ turn = [
300
+ {"role": "user", "content": "remember that I fly out of Hyderabad"},
301
+ {"role": "assistant", "content": "Noted!"},
302
+ ]
303
+ await drive(service, context_of(turn))
304
+ await asyncio.sleep(0.3) # the 503 lands on exactly one of the two stores
305
+
306
+ await drive(service, context_of(turn)) # same context re-seen → retry
307
+ await drain(mock, 2)
308
+
309
+ texts = sorted(r["text"] for r in mock.rows)
310
+ assert texts == ["ai: Noted!", "human: remember that I fly out of Hyderabad"]
@@ -1,25 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to `pipecat-memorysync` are documented here.
4
-
5
- ## 1.0.1 — 2026-08-29
6
-
7
- - Metadata only: the source repository moved to
8
- `https://github.com/memorysyncio/memorysync-pipecat`; project URLs updated.
9
- No code changes.
10
-
11
- ## 1.0.0 — 2026-08-29
12
-
13
- Initial release.
14
-
15
- - `MemorySyncMemoryService`, a `FrameProcessor` for the position between the
16
- user context aggregator and the LLM service.
17
- - Budgeted memory recall (default 1.2 s hard timeout) injected into every
18
- `LLMContextFrame` as a guarded system message — a slow or unreachable
19
- backend degrades to an unenriched frame, never a stalled reply.
20
- - Delta-only capture with deterministic idempotency seeds: only turns not
21
- seen before are persisted, with role fidelity for user and assistant.
22
- - Injection exclusion: the injected memory block is never re-captured.
23
- - Graceful `EndFrame` flush (bounded 3 s window) and `CancelFrame` salvage.
24
- - Tested with Pipecat v1.8.1 through `pipecat.tests.utils.run_test`
25
- (14-test suite).