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.
- pipecat_memorysync-1.1.0/CHANGELOG.md +38 -0
- {pipecat_memorysync-1.0.0 → pipecat_memorysync-1.1.0}/PKG-INFO +41 -6
- {pipecat_memorysync-1.0.0 → pipecat_memorysync-1.1.0}/README.md +38 -4
- pipecat_memorysync-1.1.0/examples/foundational.py +128 -0
- {pipecat_memorysync-1.0.0 → pipecat_memorysync-1.1.0}/pyproject.toml +2 -1
- {pipecat_memorysync-1.0.0 → pipecat_memorysync-1.1.0}/src/pipecat_memorysync/_version.py +1 -1
- {pipecat_memorysync-1.0.0 → pipecat_memorysync-1.1.0}/src/pipecat_memorysync/service.py +114 -20
- {pipecat_memorysync-1.0.0 → pipecat_memorysync-1.1.0}/tests/test_service.py +81 -1
- {pipecat_memorysync-1.0.0 → pipecat_memorysync-1.1.0}/.gitignore +0 -0
- {pipecat_memorysync-1.0.0 → pipecat_memorysync-1.1.0}/src/pipecat_memorysync/__init__.py +0 -0
- {pipecat_memorysync-1.0.0 → pipecat_memorysync-1.1.0}/src/pipecat_memorysync/_api.py +0 -0
- {pipecat_memorysync-1.0.0 → pipecat_memorysync-1.1.0}/tests/conftest.py +0 -0
|
@@ -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.
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
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"
|
|
@@ -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.**
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|