adk-perseus-vault-memory 0.3.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,46 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: '3.12'
18
+
19
+ - name: Install build
20
+ run: pip install build==1.2.2.post1
21
+
22
+ - name: Build
23
+ run: python -m build
24
+
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: dist
28
+ path: dist/
29
+
30
+ publish:
31
+ needs: build
32
+ runs-on: ubuntu-latest
33
+ environment:
34
+ name: pypi
35
+ url: https://pypi.org/p/adk-perseus-vault-memory
36
+ permissions:
37
+ id-token: write
38
+ steps:
39
+ - uses: actions/download-artifact@v4
40
+ with:
41
+ name: dist
42
+ path: dist/
43
+
44
+ - name: Publish to PyPI
45
+ uses: pypa/gh-action-pypi-publish@release/v1
46
+
@@ -0,0 +1,28 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ${{ matrix.os }}
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ os: [ubuntu-latest, windows-latest, macos-latest]
16
+ python-version: ['3.10', '3.12']
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+
24
+ - name: Install
25
+ run: pip install -e ".[test]"
26
+
27
+ - name: Run tests
28
+ run: python -m pytest tests/ -q
@@ -0,0 +1,7 @@
1
+ dist/
2
+ *.egg-info/
3
+ __pycache__/
4
+ *.pyc
5
+ .env
6
+ .vault/
7
+ .mimir/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Perseus Computing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: adk-perseus-vault-memory
3
+ Version: 0.3.0
4
+ Summary: Persistent, local, encrypted cross-session memory for Google ADK agents — backed by Perseus Vault.
5
+ Project-URL: Homepage, https://github.com/Perseus-Computing-LLC/adk-mimir-memory
6
+ Project-URL: Repository, https://github.com/Perseus-Computing-LLC/adk-mimir-memory
7
+ Project-URL: Bug Tracker, https://github.com/Perseus-Computing-LLC/adk-mimir-memory/issues
8
+ Project-URL: Perseus Vault, https://github.com/Perseus-Computing-LLC/perseus-vault
9
+ Author-email: Thomas Connally <51974392+tcconnally@users.noreply.github.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: adk,agent-memory,google-adk,mcp,memory,perseus-vault
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: google-adk>=1.0.0
24
+ Requires-Dist: typing-extensions>=4.4.0
25
+ Provides-Extra: perseus
26
+ Requires-Dist: perseus-ctx>=1.0.10; extra == 'perseus'
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=7.0; extra == 'test'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # ADK Perseus Vault Memory
32
+
33
+ Persistent, local, encrypted cross-session memory for [Google ADK](https://github.com/google/adk-python) agents — backed by [Perseus Vault](https://github.com/Perseus-Computing-LLC/perseus-vault) (formerly "Mimir"/"Mneme").
34
+
35
+ ## Why Perseus Vault?
36
+
37
+ | Backend | Dependencies | Encryption | Hybrid Search | Local |
38
+ |---|---|---|---|---|
39
+ | **InMemoryMemoryService** | None | ❌ | ❌ | ✅ |
40
+ | **VertexAiMemoryBankService** | GCP + Gemini | ❌ | Gemini-driven | ❌ |
41
+ | **VertexAiRagMemoryService** | GCP + RAG | ❌ | GCP vector | ❌ |
42
+ | **PerseusVaultMemoryService** | **Single binary** | **✅ AES-256** | **✅ BM25+FTS5+Dense** | **✅** |
43
+
44
+ - **Zero cloud dependencies** — a single Rust binary, SQLite database, fully local
45
+ - **AES-256-GCM encryption** at rest — your memory data stays private
46
+ - **Hybrid search** — BM25 keyword + FTS5 + dense vector search
47
+ - **30+ MCP tools** — remember, recall, synthesize, benchmark, federate, and more
48
+ - **Ebbinghaus confidence decay** — memories fade naturally, important ones persist
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ pip install adk-perseus-vault-memory
54
+ ```
55
+
56
+ This package requires the `perseus-vault` binary. Download it from:
57
+ https://github.com/Perseus-Computing-LLC/perseus-vault/releases
58
+
59
+ Or build from source:
60
+ ```bash
61
+ cargo install perseus-vault
62
+ ```
63
+
64
+ ## Quick Start
65
+
66
+ ```python
67
+ from google.adk.agents import Agent
68
+ from google.adk.runners import Runner
69
+ from google.adk.sessions import InMemorySessionService
70
+ from adk_perseus_vault_memory import PerseusVaultMemoryService
71
+
72
+ agent = Agent(
73
+ name="my_agent",
74
+ model="gemini-2.5-flash",
75
+ instruction="You are a helpful assistant with persistent memory.",
76
+ )
77
+
78
+ # The memory service is configured on the Runner, not on the Agent.
79
+ runner = Runner(
80
+ agent=agent,
81
+ app_name="my_app",
82
+ session_service=InMemorySessionService(),
83
+ memory_service=PerseusVaultMemoryService(db_path="~/.adk/vault.db"),
84
+ )
85
+ ```
86
+
87
+ That's it. Sessions, events, and explicit memories are now persisted across restarts.
88
+
89
+ ### Configuration
90
+
91
+ ```python
92
+ # Custom database location
93
+ PerseusVaultMemoryService(db_path="/data/agent_memory.db")
94
+
95
+ # Custom perseus-vault binary path (if not on $PATH)
96
+ PerseusVaultMemoryService(vault_binary="/usr/local/bin/perseus-vault")
97
+
98
+ # Both
99
+ PerseusVaultMemoryService(
100
+ db_path="/data/agent_memory.db",
101
+ vault_binary="/usr/local/bin/perseus-vault",
102
+ )
103
+ ```
104
+
105
+ ## Perseus Live Context (Optional)
106
+
107
+ This package also includes a drop-in agent with live workspace awareness via [Perseus](https://github.com/Perseus-Computing-LLC/perseus):
108
+
109
+ ```python
110
+ from google.adk.runners import Runner
111
+ from google.adk.sessions import InMemorySessionService
112
+ from adk_perseus_vault_memory.perseus_context import perseus_context_agent
113
+
114
+ # The agent resolves @file, @search, @memory directives at inference time.
115
+ # Bind it on the Runner; run_async takes no `agent` argument.
116
+ runner = Runner(
117
+ agent=perseus_context_agent,
118
+ app_name="my_app",
119
+ session_service=InMemorySessionService(),
120
+ )
121
+ runner.run_async(
122
+ user_id="user",
123
+ session_id="session",
124
+ new_message=types.Content(role="user", parts=[types.Part.from_text(
125
+ text="What does the README say about deployment?"
126
+ )]),
127
+ )
128
+ ```
129
+
130
+ ```bash
131
+ pip install adk-perseus-vault-memory[perseus] # installs perseus-ctx
132
+ ```
133
+
134
+ Set directives via session state:
135
+ ```python
136
+ session = await runner.session_service.create_session(
137
+ app_name="my_app",
138
+ user_id="user",
139
+ state={
140
+ "_perseus_directives": "@file AGENTS.md @file README.md @memory deployment",
141
+ "_perseus_workspace": "/path/to/project",
142
+ },
143
+ )
144
+ ```
145
+
146
+ ## How It Works
147
+
148
+ ```
149
+ ┌─────────────┐ JSON-RPC (MCP stdio) ┌────────────────┐
150
+ │ ADK Agent │ ──────────────────────────▶ │ Perseus Vault │
151
+ │ (Python) │ ◀────────────────────────── │ (Rust) │
152
+ └─────────────┘ └───────┬────────┘
153
+
154
+ SQLite + FTS5
155
+ (AES-256-GCM)
156
+ ```
157
+
158
+ The `PerseusVaultMemoryService` spawns a `perseus-vault` subprocess and communicates via JSON-RPC over stdin/stdout (MCP stdio transport). Each `add_session_to_memory`, `add_memory`, and `search_memory` call translates to a Perseus Vault MCP tool invocation.
159
+
160
+ ## License
161
+
162
+ MIT — see [Perseus Vault](https://github.com/Perseus-Computing-LLC/perseus-vault) and [Perseus](https://github.com/Perseus-Computing-LLC/perseus) for the backing services.
@@ -0,0 +1,132 @@
1
+ # ADK Perseus Vault Memory
2
+
3
+ Persistent, local, encrypted cross-session memory for [Google ADK](https://github.com/google/adk-python) agents — backed by [Perseus Vault](https://github.com/Perseus-Computing-LLC/perseus-vault) (formerly "Mimir"/"Mneme").
4
+
5
+ ## Why Perseus Vault?
6
+
7
+ | Backend | Dependencies | Encryption | Hybrid Search | Local |
8
+ |---|---|---|---|---|
9
+ | **InMemoryMemoryService** | None | ❌ | ❌ | ✅ |
10
+ | **VertexAiMemoryBankService** | GCP + Gemini | ❌ | Gemini-driven | ❌ |
11
+ | **VertexAiRagMemoryService** | GCP + RAG | ❌ | GCP vector | ❌ |
12
+ | **PerseusVaultMemoryService** | **Single binary** | **✅ AES-256** | **✅ BM25+FTS5+Dense** | **✅** |
13
+
14
+ - **Zero cloud dependencies** — a single Rust binary, SQLite database, fully local
15
+ - **AES-256-GCM encryption** at rest — your memory data stays private
16
+ - **Hybrid search** — BM25 keyword + FTS5 + dense vector search
17
+ - **30+ MCP tools** — remember, recall, synthesize, benchmark, federate, and more
18
+ - **Ebbinghaus confidence decay** — memories fade naturally, important ones persist
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pip install adk-perseus-vault-memory
24
+ ```
25
+
26
+ This package requires the `perseus-vault` binary. Download it from:
27
+ https://github.com/Perseus-Computing-LLC/perseus-vault/releases
28
+
29
+ Or build from source:
30
+ ```bash
31
+ cargo install perseus-vault
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```python
37
+ from google.adk.agents import Agent
38
+ from google.adk.runners import Runner
39
+ from google.adk.sessions import InMemorySessionService
40
+ from adk_perseus_vault_memory import PerseusVaultMemoryService
41
+
42
+ agent = Agent(
43
+ name="my_agent",
44
+ model="gemini-2.5-flash",
45
+ instruction="You are a helpful assistant with persistent memory.",
46
+ )
47
+
48
+ # The memory service is configured on the Runner, not on the Agent.
49
+ runner = Runner(
50
+ agent=agent,
51
+ app_name="my_app",
52
+ session_service=InMemorySessionService(),
53
+ memory_service=PerseusVaultMemoryService(db_path="~/.adk/vault.db"),
54
+ )
55
+ ```
56
+
57
+ That's it. Sessions, events, and explicit memories are now persisted across restarts.
58
+
59
+ ### Configuration
60
+
61
+ ```python
62
+ # Custom database location
63
+ PerseusVaultMemoryService(db_path="/data/agent_memory.db")
64
+
65
+ # Custom perseus-vault binary path (if not on $PATH)
66
+ PerseusVaultMemoryService(vault_binary="/usr/local/bin/perseus-vault")
67
+
68
+ # Both
69
+ PerseusVaultMemoryService(
70
+ db_path="/data/agent_memory.db",
71
+ vault_binary="/usr/local/bin/perseus-vault",
72
+ )
73
+ ```
74
+
75
+ ## Perseus Live Context (Optional)
76
+
77
+ This package also includes a drop-in agent with live workspace awareness via [Perseus](https://github.com/Perseus-Computing-LLC/perseus):
78
+
79
+ ```python
80
+ from google.adk.runners import Runner
81
+ from google.adk.sessions import InMemorySessionService
82
+ from adk_perseus_vault_memory.perseus_context import perseus_context_agent
83
+
84
+ # The agent resolves @file, @search, @memory directives at inference time.
85
+ # Bind it on the Runner; run_async takes no `agent` argument.
86
+ runner = Runner(
87
+ agent=perseus_context_agent,
88
+ app_name="my_app",
89
+ session_service=InMemorySessionService(),
90
+ )
91
+ runner.run_async(
92
+ user_id="user",
93
+ session_id="session",
94
+ new_message=types.Content(role="user", parts=[types.Part.from_text(
95
+ text="What does the README say about deployment?"
96
+ )]),
97
+ )
98
+ ```
99
+
100
+ ```bash
101
+ pip install adk-perseus-vault-memory[perseus] # installs perseus-ctx
102
+ ```
103
+
104
+ Set directives via session state:
105
+ ```python
106
+ session = await runner.session_service.create_session(
107
+ app_name="my_app",
108
+ user_id="user",
109
+ state={
110
+ "_perseus_directives": "@file AGENTS.md @file README.md @memory deployment",
111
+ "_perseus_workspace": "/path/to/project",
112
+ },
113
+ )
114
+ ```
115
+
116
+ ## How It Works
117
+
118
+ ```
119
+ ┌─────────────┐ JSON-RPC (MCP stdio) ┌────────────────┐
120
+ │ ADK Agent │ ──────────────────────────▶ │ Perseus Vault │
121
+ │ (Python) │ ◀────────────────────────── │ (Rust) │
122
+ └─────────────┘ └───────┬────────┘
123
+
124
+ SQLite + FTS5
125
+ (AES-256-GCM)
126
+ ```
127
+
128
+ The `PerseusVaultMemoryService` spawns a `perseus-vault` subprocess and communicates via JSON-RPC over stdin/stdout (MCP stdio transport). Each `add_session_to_memory`, `add_memory`, and `search_memory` call translates to a Perseus Vault MCP tool invocation.
129
+
130
+ ## License
131
+
132
+ MIT — see [Perseus Vault](https://github.com/Perseus-Computing-LLC/perseus-vault) and [Perseus](https://github.com/Perseus-Computing-LLC/perseus) for the backing services.
@@ -0,0 +1,16 @@
1
+ """ADK Perseus Vault Memory Service — persistent, local, encrypted cross-session memory.
2
+
3
+ Perseus Vault (github.com/Perseus-Computing-LLC/perseus-vault) is an
4
+ open-source (MIT) persistent memory engine with 30+ MCP tools, FTS5 + dense
5
+ hybrid search, and optional AES-256-GCM encryption. This service talks to the
6
+ Perseus Vault binary via JSON-RPC over stdin/stdout (MCP stdio transport).
7
+
8
+ Requirements:
9
+ A ``perseus-vault`` binary must be on ``$PATH`` or passed explicitly via
10
+ ``vault_binary``. Download from:
11
+ https://github.com/Perseus-Computing-LLC/perseus-vault/releases
12
+ """
13
+
14
+ from .perseus_vault_memory_service import PerseusVaultMemoryService
15
+
16
+ __all__ = ["PerseusVaultMemoryService"]
@@ -0,0 +1,97 @@
1
+ """Agent that uses Perseus for live workspace context resolution.
2
+
3
+ Perseus (github.com/Perseus-Computing-LLC/perseus) is an open-source (MIT)
4
+ live context engine that resolves workspace state at inference time. Instead
5
+ of baking static instructions into prompts, agents use Perseus directives
6
+ (``@file``, ``@search``, ``@memory``, etc.) to pull in exactly what they
7
+ need.
8
+
9
+ This module provides a drop-in agent demonstrating the pattern:
10
+ 1. A ``before_agent_callback`` resolves Perseus context before each run.
11
+ 2. The resolved context is injected into the agent's instruction template.
12
+ 3. The agent knows about workspace state without it being hardcoded.
13
+
14
+ Usage::
15
+
16
+ from adk_perseus_vault_memory.perseus_context import perseus_context_agent
17
+
18
+ # Use as a standalone agent
19
+ runner.run_async(
20
+ user_id="user",
21
+ session_id="session",
22
+ new_message=types.Content(...),
23
+ agent=perseus_context_agent,
24
+ )
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import subprocess
31
+
32
+ from google.adk.agents import Agent
33
+ from google.adk.agents.callback_context import CallbackContext
34
+
35
+ _PERSEUS_BINARY = os.environ.get("PERSEUS_BINARY", "perseus")
36
+
37
+
38
+ def resolve_perseus_context(callback_context: CallbackContext) -> None:
39
+ """Resolves Perseus directives and stores the result in agent state.
40
+
41
+ Called before each agent run. Reads directives from a state key
42
+ (defaulting to a sensible set) and resolves them via the Perseus CLI.
43
+ """
44
+ directives = callback_context.state.get(
45
+ "_perseus_directives",
46
+ "@file AGENTS.md @file README.md",
47
+ )
48
+
49
+ workspace = callback_context.state.get("_perseus_workspace", os.getcwd())
50
+
51
+ try:
52
+ result = subprocess.run(
53
+ [_PERSEUS_BINARY, "resolve", directives],
54
+ capture_output=True,
55
+ text=True,
56
+ timeout=15,
57
+ cwd=workspace,
58
+ )
59
+ if result.returncode == 0 and result.stdout.strip():
60
+ callback_context.state["_perseus_context"] = result.stdout.strip()
61
+ else:
62
+ callback_context.state["_perseus_context"] = (
63
+ f"(Perseus: no context resolved for directives: {directives})"
64
+ )
65
+ except FileNotFoundError:
66
+ callback_context.state["_perseus_context"] = (
67
+ "(Perseus CLI not installed. Install with: pip install perseus-ctx)"
68
+ )
69
+ except subprocess.TimeoutExpired:
70
+ callback_context.state["_perseus_context"] = (
71
+ "(Perseus resolution timed out)"
72
+ )
73
+
74
+
75
+ perseus_context_agent = Agent(
76
+ name="perseus_context_agent",
77
+ description=(
78
+ "Agent with live workspace context via Perseus. Knows about "
79
+ "project files, git state, and workspace structure without "
80
+ "hardcoded instructions."
81
+ ),
82
+ before_agent_callback=resolve_perseus_context,
83
+ instruction="""\
84
+ You are a helpful assistant with live context about the current workspace.
85
+
86
+ The following context was resolved by Perseus from the workspace files
87
+ and state. Use it to answer questions accurately.
88
+
89
+ --- BEGIN PERSEUS CONTEXT ---
90
+ {_perseus_context}
91
+ --- END PERSEUS CONTEXT ---
92
+
93
+ Use this context to give grounded, file-aware answers. If the context
94
+ is empty or unavailable, let the user know and fall back to general
95
+ knowledge.
96
+ """,
97
+ )
@@ -0,0 +1,533 @@
1
+ """Perseus Vault persistent memory service for ADK.
2
+
3
+ Perseus Vault (github.com/Perseus-Computing-LLC/perseus-vault) is an
4
+ open-source (MIT) persistent memory engine with 30+ MCP tools, FTS5 + dense
5
+ hybrid search, and optional AES-256-GCM encryption. This service talks to the
6
+ Perseus Vault binary via JSON-RPC over stdin/stdout (MCP stdio transport).
7
+
8
+ Requirements:
9
+ A ``perseus-vault`` binary must be on ``$PATH`` or passed explicitly via
10
+ ``vault_binary``. Build from source or download a pre-built binary from
11
+ the Perseus Vault releases page.
12
+
13
+ Usage::
14
+
15
+ from adk_perseus_vault_memory import PerseusVaultMemoryService
16
+ from google.adk.runners import Runner
17
+
18
+ # The memory service is configured on the Runner (not on the Agent).
19
+ runner = Runner(
20
+ agent=my_agent,
21
+ app_name="my_app",
22
+ session_service=my_session_service,
23
+ memory_service=PerseusVaultMemoryService(db_path="~/.adk/vault.db"),
24
+ )
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import atexit
31
+ import json
32
+ import logging
33
+ import os
34
+ import queue
35
+ import shutil
36
+ import subprocess
37
+ import threading
38
+ import time
39
+ from collections.abc import Mapping
40
+ from collections.abc import Sequence
41
+ from datetime import datetime, timezone
42
+ from typing import TYPE_CHECKING
43
+
44
+ from typing_extensions import override
45
+
46
+ from google.adk.memory.base_memory_service import BaseMemoryService
47
+ from google.adk.memory.base_memory_service import SearchMemoryResponse
48
+ from google.adk.memory.memory_entry import MemoryEntry
49
+ from google.genai import types
50
+
51
+ if TYPE_CHECKING:
52
+ from google.adk.events.event import Event
53
+ from google.adk.sessions.session import Session
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ _VAULT_CATEGORY = "adk-memory"
58
+
59
+
60
+ def _format_timestamp(timestamp: float) -> str:
61
+ """Formats a unix timestamp as a UTC ISO 8601 string."""
62
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat()
63
+
64
+
65
+ class PerseusVaultMemoryService(BaseMemoryService):
66
+ """Persistent memory service backed by Perseus Vault.
67
+
68
+ Talks to a local ``perseus-vault`` binary via JSON-RPC (MCP stdio). Stores
69
+ session events as structured entities and supports keyword (FTS5) search
70
+ across sessions.
71
+
72
+ This class is thread-safe.
73
+
74
+ Attributes:
75
+ db_path: Filesystem path to the Perseus Vault SQLite database.
76
+ vault_binary: Path or name of the ``perseus-vault`` executable.
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ db_path: str = "~/.adk/vault.db",
82
+ vault_binary: str = "perseus-vault",
83
+ timeout_s: float = 30.0,
84
+ ):
85
+ """Initializes the Perseus Vault memory service.
86
+
87
+ Args:
88
+ db_path: Path to the Perseus Vault database file. Defaults to
89
+ ``~/.adk/vault.db``.
90
+ vault_binary: Name or absolute path of the ``perseus-vault``
91
+ executable. Defaults to ``perseus-vault`` (resolved from
92
+ ``$PATH``).
93
+ timeout_s: Maximum time to wait for any single Perseus Vault RPC
94
+ response. Guards against a hung subprocess blocking the agent
95
+ forever.
96
+
97
+ Raises:
98
+ RuntimeError: If the ``perseus-vault`` binary cannot be found or the
99
+ subprocess fails to start.
100
+ """
101
+ self.db_path = os.path.expanduser(db_path)
102
+ self._timeout_s = timeout_s
103
+
104
+ # Resolve the perseus-vault binary.
105
+ if os.path.isabs(vault_binary):
106
+ self._vault_binary = vault_binary
107
+ else:
108
+ resolved = shutil.which(vault_binary)
109
+ if resolved is None:
110
+ raise RuntimeError(
111
+ f"perseus-vault binary not found on $PATH (looked for "
112
+ f"'{vault_binary}'). Install Perseus Vault from "
113
+ "https://github.com/Perseus-Computing-LLC/perseus-vault "
114
+ "or pass the absolute path via vault_binary=."
115
+ )
116
+ self._vault_binary = resolved
117
+
118
+ # Ensure the database directory exists.
119
+ os.makedirs(os.path.dirname(self.db_path) or ".", exist_ok=True)
120
+
121
+ # Start the Perseus Vault MCP stdio subprocess. stderr is discarded:
122
+ # nothing drains it, so a chatty server filling the OS pipe buffer would
123
+ # block on its stderr write while we wait on stdout (a two-pipe
124
+ # deadlock).
125
+ self._proc = subprocess.Popen(
126
+ [self._vault_binary, "--db", self.db_path],
127
+ stdin=subprocess.PIPE,
128
+ stdout=subprocess.PIPE,
129
+ stderr=subprocess.DEVNULL,
130
+ text=True,
131
+ )
132
+ self._lock = threading.Lock()
133
+ self._request_id = 0
134
+
135
+ # Background reader: pump stdout lines into a queue so _rpc can wait with a
136
+ # timeout and correlate responses by id, rather than blocking forever on a
137
+ # bare readline().
138
+ self._recv: queue.Queue = queue.Queue()
139
+ proc_stdout = self._proc.stdout
140
+
141
+ def _pump() -> None:
142
+ try:
143
+ for line in proc_stdout:
144
+ self._recv.put(line)
145
+ except Exception:
146
+ pass
147
+ finally:
148
+ self._recv.put(None) # EOF sentinel
149
+
150
+ self._reader = threading.Thread(target=_pump, daemon=True)
151
+ self._reader.start()
152
+
153
+ # Initialize the MCP session, then send the required initialized
154
+ # notification (per the MCP spec) before any tools/call.
155
+ self._rpc(
156
+ "initialize",
157
+ {
158
+ "protocolVersion": "2024-11-05",
159
+ "capabilities": {},
160
+ "clientInfo": {
161
+ "name": "adk-perseus-vault-memory-service",
162
+ "version": "1.0",
163
+ },
164
+ },
165
+ )
166
+ self._notify("notifications/initialized", {})
167
+
168
+ # Clean up the subprocess on exit.
169
+ atexit.register(self._close)
170
+
171
+ def _close(self) -> None:
172
+ """Terminates the Perseus Vault subprocess."""
173
+ try:
174
+ self._proc.terminate()
175
+ self._proc.wait(timeout=5)
176
+ except Exception:
177
+ try:
178
+ self._proc.kill()
179
+ except Exception:
180
+ pass
181
+
182
+ def _next_id(self) -> int:
183
+ self._request_id += 1
184
+ return self._request_id
185
+
186
+ def _rpc(self, method: str, params: object) -> dict:
187
+ """Sends a JSON-RPC request to Perseus Vault and returns the result dict.
188
+
189
+ Args:
190
+ method: The MCP method name (e.g. ``tools/call``).
191
+ params: The method parameters.
192
+
193
+ Returns:
194
+ The ``result`` field of the JSON-RPC response.
195
+
196
+ Raises:
197
+ RuntimeError: If the RPC returns an error.
198
+ """
199
+ with self._lock:
200
+ req_id = self._next_id()
201
+ req = {
202
+ "jsonrpc": "2.0",
203
+ "id": req_id,
204
+ "method": method,
205
+ "params": params,
206
+ }
207
+ payload = json.dumps(req, default=str)
208
+ try:
209
+ self._proc.stdin.write(payload + "\n")
210
+ self._proc.stdin.flush()
211
+ except (BrokenPipeError, OSError) as e:
212
+ raise RuntimeError(
213
+ f"Perseus Vault subprocess communication failed: {e}. "
214
+ "The perseus-vault process may have crashed."
215
+ ) from e
216
+
217
+ # Wait for the reply with this id, honoring a deadline. Skip
218
+ # notifications (no id) and replies to other ids. The lock is held
219
+ # for the whole exchange so request/response pairs never interleave.
220
+ deadline = time.monotonic() + self._timeout_s
221
+ while True:
222
+ remaining = deadline - time.monotonic()
223
+ if remaining <= 0:
224
+ raise RuntimeError(
225
+ f"Perseus Vault RPC '{method}' timed out after "
226
+ f"{self._timeout_s}s."
227
+ )
228
+ try:
229
+ raw = self._recv.get(timeout=remaining)
230
+ except queue.Empty:
231
+ raise RuntimeError(
232
+ f"Perseus Vault RPC '{method}' timed out after "
233
+ f"{self._timeout_s}s."
234
+ )
235
+ if raw is None:
236
+ raise RuntimeError(
237
+ "Perseus Vault subprocess closed its output "
238
+ "(it may have crashed)."
239
+ )
240
+ raw = raw.strip()
241
+ if not raw:
242
+ continue
243
+ try:
244
+ resp = json.loads(raw)
245
+ except json.JSONDecodeError:
246
+ continue # non-JSON noise on stdout
247
+ if resp.get("id") != req_id:
248
+ continue # notification or a stale/other reply
249
+
250
+ if "error" in resp:
251
+ err = resp["error"]
252
+ raise RuntimeError(
253
+ f"Perseus Vault RPC error [{err.get('code')}]: "
254
+ f"{err.get('message')}"
255
+ )
256
+ return resp.get("result", {})
257
+
258
+ def _notify(self, method: str, params: object) -> None:
259
+ """Sends a JSON-RPC notification (no id, no response expected)."""
260
+ payload = json.dumps({"jsonrpc": "2.0", "method": method, "params": params})
261
+ with self._lock:
262
+ try:
263
+ self._proc.stdin.write(payload + "\n")
264
+ self._proc.stdin.flush()
265
+ except (BrokenPipeError, OSError):
266
+ pass
267
+
268
+ def _call_tool(self, name: str, arguments: dict) -> dict:
269
+ """Calls a Perseus Vault MCP tool and returns the ``structuredContent``."""
270
+ result = self._rpc(
271
+ "tools/call",
272
+ {"name": name, "arguments": arguments},
273
+ )
274
+ # MCP result is {content: [{type: "text", text: "..."}], structuredContent: {...}}
275
+ sc = result.get("structuredContent")
276
+ if sc is not None:
277
+ return sc
278
+ # Fallback: parse the text content
279
+ content = result.get("content", [])
280
+ if content:
281
+ try:
282
+ return json.loads(content[0].get("text", "{}"))
283
+ except (json.JSONDecodeError, IndexError, KeyError):
284
+ pass
285
+ return {}
286
+
287
+ @override
288
+ async def add_session_to_memory(self, session: Session) -> None:
289
+ """Stores all events from a session in Perseus Vault.
290
+
291
+ Each session is stored as a single Perseus Vault entity keyed by session
292
+ ID. Subsequent calls for the same session will update the stored events.
293
+ """
294
+ if not session.events:
295
+ return
296
+
297
+ events_data = []
298
+ for event in session.events:
299
+ if not event.content or not event.content.parts:
300
+ continue
301
+ parts = []
302
+ for part in event.content.parts:
303
+ if part.text:
304
+ parts.append({"text": part.text})
305
+ elif hasattr(part, "function_call") and part.function_call:
306
+ parts.append({
307
+ "function_call": {
308
+ "name": part.function_call.name,
309
+ "args": part.function_call.args,
310
+ }
311
+ })
312
+ elif hasattr(part, "function_response") and part.function_response:
313
+ parts.append({
314
+ "function_response": {
315
+ "name": part.function_response.name,
316
+ "response": str(part.function_response.response)[:2000],
317
+ }
318
+ })
319
+ if parts:
320
+ events_data.append({
321
+ "author": event.author,
322
+ "timestamp": event.timestamp,
323
+ "parts": parts,
324
+ })
325
+
326
+ if not events_data:
327
+ return
328
+
329
+ await asyncio.to_thread(
330
+ self._call_tool,
331
+ "perseus_vault_remember",
332
+ {
333
+ "category": _VAULT_CATEGORY,
334
+ "key": f"session:{session.app_name}:{session.user_id}:{session.id}",
335
+ "body_json": json.dumps({
336
+ "session_id": session.id,
337
+ "app_name": session.app_name,
338
+ "user_id": session.user_id,
339
+ "events": events_data,
340
+ "event_count": len(events_data),
341
+ }),
342
+ "tags": ["adk", "session", session.app_name],
343
+ },
344
+ )
345
+
346
+ @override
347
+ async def add_events_to_memory(
348
+ self,
349
+ *,
350
+ app_name: str,
351
+ user_id: str,
352
+ events: Sequence[Event],
353
+ session_id: str | None = None,
354
+ custom_metadata: Mapping[str, object] | None = None,
355
+ ) -> None:
356
+ """Adds a delta of events to Perseus Vault.
357
+
358
+ Events are appended to an existing session entity if one exists, or a
359
+ new entity is created. This is the recommended method for incremental
360
+ memory updates during long-running sessions.
361
+ """
362
+ _ = custom_metadata
363
+ events_data = []
364
+ for event in events:
365
+ if not event.content or not event.content.parts:
366
+ continue
367
+ parts = []
368
+ for part in event.content.parts:
369
+ if part.text:
370
+ parts.append({"text": part.text})
371
+ elif hasattr(part, "function_call") and part.function_call:
372
+ parts.append({
373
+ "function_call": {
374
+ "name": part.function_call.name,
375
+ "args": part.function_call.args,
376
+ }
377
+ })
378
+ if parts:
379
+ events_data.append({
380
+ "author": event.author,
381
+ "timestamp": event.timestamp,
382
+ "parts": parts,
383
+ })
384
+
385
+ if not events_data:
386
+ return
387
+
388
+ sid = session_id or "__unknown__"
389
+ delta_key = f"delta:{app_name}:{user_id}:{sid}:{int(time.time() * 1000)}"
390
+
391
+ await asyncio.to_thread(
392
+ self._call_tool,
393
+ "perseus_vault_remember",
394
+ {
395
+ "category": _VAULT_CATEGORY,
396
+ "key": delta_key,
397
+ "body_json": json.dumps({
398
+ "session_id": sid,
399
+ "app_name": app_name,
400
+ "user_id": user_id,
401
+ "events": events_data,
402
+ "event_count": len(events_data),
403
+ }),
404
+ "tags": ["adk", "delta", app_name],
405
+ },
406
+ )
407
+
408
+ @override
409
+ async def add_memory(
410
+ self,
411
+ *,
412
+ app_name: str,
413
+ user_id: str,
414
+ memories: Sequence[MemoryEntry],
415
+ custom_metadata: Mapping[str, object] | None = None,
416
+ ) -> None:
417
+ """Adds explicit memory entries directly to Perseus Vault.
418
+
419
+ Each MemoryEntry is stored as a separate entity tagged for the given
420
+ application and user.
421
+ """
422
+ _ = custom_metadata
423
+ for i, entry in enumerate(memories):
424
+ content_text = ""
425
+ if entry.content and entry.content.parts:
426
+ content_text = " ".join(
427
+ p.text for p in entry.content.parts if p.text
428
+ )
429
+
430
+ if not content_text:
431
+ continue
432
+
433
+ await asyncio.to_thread(
434
+ self._call_tool,
435
+ "perseus_vault_remember",
436
+ {
437
+ "category": _VAULT_CATEGORY,
438
+ "key": f"memory:{app_name}:{user_id}:{entry.id or i}",
439
+ "body_json": json.dumps({
440
+ "content": content_text,
441
+ "app_name": app_name,
442
+ "user_id": user_id,
443
+ "author": entry.author,
444
+ "timestamp": entry.timestamp,
445
+ "metadata": entry.custom_metadata,
446
+ }),
447
+ "tags": ["adk", "explicit", app_name],
448
+ },
449
+ )
450
+
451
+ @override
452
+ async def search_memory(
453
+ self,
454
+ *,
455
+ app_name: str,
456
+ user_id: str,
457
+ query: str,
458
+ ) -> SearchMemoryResponse:
459
+ """Searches Perseus Vault for memories matching the query.
460
+
461
+ Uses Perseus Vault's FTS5 keyword search, then enforces per-(app, user)
462
+ isolation in-process: Perseus Vault's recall OR's query terms together,
463
+ so scoping cannot be expressed by stuffing the app/user into the query
464
+ string (that both leaks other tenants' memories and dilutes relevance).
465
+ Instead the clean query is sent and every returned item is filtered to
466
+ the requesting ``app_name`` and ``user_id`` recorded in its body.
467
+
468
+ Args:
469
+ app_name: The application name for memory scope.
470
+ user_id: The user ID for memory scope.
471
+ query: The natural-language query to search for.
472
+
473
+ Returns:
474
+ A SearchMemoryResponse containing matching MemoryEntry objects.
475
+ """
476
+ # Over-fetch a little since results are post-filtered by tenant.
477
+ result = await asyncio.to_thread(
478
+ self._call_tool,
479
+ "perseus_vault_recall",
480
+ {
481
+ "query": query,
482
+ "limit": 50,
483
+ "category": _VAULT_CATEGORY,
484
+ },
485
+ )
486
+
487
+ response = SearchMemoryResponse()
488
+ items = result.get("items", [])
489
+ for item in items:
490
+ body = item.get("body_json", "{}")
491
+ try:
492
+ body_data = json.loads(body) if isinstance(body, str) else body
493
+ except json.JSONDecodeError:
494
+ body_data = {}
495
+ if not isinstance(body_data, dict):
496
+ continue
497
+
498
+ # Tenant isolation: never surface another app's or user's memory.
499
+ if (
500
+ body_data.get("app_name") != app_name
501
+ or body_data.get("user_id") != user_id
502
+ ):
503
+ continue
504
+
505
+ # Determine the best text content to surface.
506
+ content_text = body_data.get("content", "")
507
+ if not content_text:
508
+ events = body_data.get("events", [])
509
+ texts = []
510
+ for ev in events:
511
+ for part in ev.get("parts", []):
512
+ if part.get("text"):
513
+ texts.append(part["text"])
514
+ content_text = " | ".join(texts[:5]) if texts else ""
515
+
516
+ if not content_text:
517
+ continue
518
+
519
+ response.memories.append(
520
+ MemoryEntry(
521
+ content=types.Content(
522
+ role="model",
523
+ parts=[types.Part.from_text(text=content_text)],
524
+ ),
525
+ author=body_data.get("author") or "perseus-vault",
526
+ timestamp=body_data.get("timestamp")
527
+ or _format_timestamp(
528
+ item.get("created_at_unix_ms", 0) / 1000.0
529
+ ),
530
+ )
531
+ )
532
+
533
+ return response
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "adk-perseus-vault-memory"
7
+ version = "0.3.0"
8
+ authors = [
9
+ { name = "Thomas Connally", email = "51974392+tcconnally@users.noreply.github.com" },
10
+ ]
11
+ description = "Persistent, local, encrypted cross-session memory for Google ADK agents — backed by Perseus Vault."
12
+ readme = "README.md"
13
+ license = { text = "MIT" }
14
+ requires-python = ">=3.10"
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
24
+ "Intended Audience :: Developers",
25
+ ]
26
+ keywords = [
27
+ "google-adk",
28
+ "adk",
29
+ "memory",
30
+ "perseus-vault",
31
+ "agent-memory",
32
+ "mcp",
33
+ ]
34
+ dependencies = [
35
+ "google-adk>=1.0.0",
36
+ # override is used for the BaseMemoryService overrides; stdlib typing.override
37
+ # only exists on 3.12+, so the backport is required on 3.10/3.11.
38
+ "typing-extensions>=4.4.0",
39
+ ]
40
+
41
+ [project.optional-dependencies]
42
+ perseus = ["perseus-ctx>=1.0.10"]
43
+ test = ["pytest>=7.0"]
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/Perseus-Computing-LLC/adk-mimir-memory"
47
+ Repository = "https://github.com/Perseus-Computing-LLC/adk-mimir-memory"
48
+ "Bug Tracker" = "https://github.com/Perseus-Computing-LLC/adk-mimir-memory/issues"
49
+ "Perseus Vault" = "https://github.com/Perseus-Computing-LLC/perseus-vault"
@@ -0,0 +1,226 @@
1
+ """Tests for PerseusVaultMemoryService against a fake Perseus Vault MCP stdio server.
2
+
3
+ No real ``perseus-vault`` binary is required: ``subprocess.Popen`` is
4
+ monkeypatched to return an in-process fake that speaks JSON-RPC 2.0 over fake
5
+ stdin/stdout pipes and models Perseus Vault's recall OR-semantics, so these
6
+ exercise the real RPC, async, and tenant-isolation code paths.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import queue
14
+
15
+ import pytest
16
+
17
+ from google.genai import types
18
+ from google.adk.memory.memory_entry import MemoryEntry
19
+
20
+ import adk_perseus_vault_memory.perseus_vault_memory_service as svc_mod
21
+ from adk_perseus_vault_memory import PerseusVaultMemoryService
22
+
23
+
24
+ # ── Fake Perseus Vault MCP stdio server ─────────────────────────────────────
25
+
26
+
27
+ class _FakeStdin:
28
+ def __init__(self, on_line):
29
+ self._on_line = on_line
30
+
31
+ def write(self, s):
32
+ for line in s.splitlines():
33
+ if line.strip():
34
+ self._on_line(line)
35
+
36
+ def flush(self):
37
+ pass
38
+
39
+ def close(self):
40
+ pass
41
+
42
+
43
+ class _FakeStdout:
44
+ """Blocking, iterable line source fed by the fake server."""
45
+
46
+ def __init__(self):
47
+ self._q = queue.Queue()
48
+
49
+ def put(self, line):
50
+ self._q.put(line)
51
+
52
+ def __iter__(self):
53
+ return self
54
+
55
+ def __next__(self):
56
+ item = self._q.get()
57
+ if item is None:
58
+ raise StopIteration
59
+ return item
60
+
61
+ def close(self):
62
+ self._q.put(None)
63
+
64
+
65
+ class FakeVault:
66
+ """Minimal Popen-compatible fake of the Perseus Vault MCP stdio server.
67
+
68
+ Options:
69
+ answer_tools: if False, tools/call requests get no response (to test
70
+ the RPC timeout).
71
+ emit_notification_before_reply: if True, a JSON-RPC notification line is
72
+ emitted before every tools/call reply (to test id correlation).
73
+ """
74
+
75
+ def __init__(self, *, answer_tools=True, emit_notification_before_reply=False):
76
+ self.store = {} # (category, key) -> arguments dict
77
+ self.stdout = _FakeStdout()
78
+ self.stdin = _FakeStdin(self._handle)
79
+ self._alive = True
80
+ self._answer_tools = answer_tools
81
+ self._emit_notif = emit_notification_before_reply
82
+
83
+ # --- JSON-RPC handling ---
84
+ def _handle(self, line):
85
+ req = json.loads(line)
86
+ rid = req.get("id")
87
+ method = req.get("method")
88
+ if rid is None:
89
+ return # client notification, no response
90
+ if method == "initialize":
91
+ self._respond(rid, {"protocolVersion": "2024-11-05", "capabilities": {}})
92
+ return
93
+ if method == "tools/call":
94
+ if not self._answer_tools:
95
+ return # simulate a hung server
96
+ if self._emit_notif:
97
+ self.stdout.put(
98
+ json.dumps(
99
+ {"jsonrpc": "2.0", "method": "notifications/progress", "params": {}}
100
+ )
101
+ + "\n"
102
+ )
103
+ params = req["params"]
104
+ name = params["name"]
105
+ args = params["arguments"]
106
+ if name == "perseus_vault_remember":
107
+ self.store[(args["category"], args["key"])] = args
108
+ self._respond(rid, {"structuredContent": {"stored": True}})
109
+ elif name == "perseus_vault_recall":
110
+ q = args.get("query", "").lower().split()
111
+ cat = args.get("category")
112
+ items = []
113
+ for (c, _k), rec in self.store.items():
114
+ if cat and c != cat:
115
+ continue
116
+ body = rec["body_json"]
117
+ # Model Perseus Vault's OR semantics: match if ANY query word appears.
118
+ if any(w in body.lower() for w in q):
119
+ items.append({"body_json": body, "created_at_unix_ms": 0})
120
+ self._respond(rid, {"structuredContent": {"items": items}})
121
+ else:
122
+ self._respond(rid, {"structuredContent": {}})
123
+ return
124
+ self._respond(rid, {})
125
+
126
+ def _respond(self, rid, result):
127
+ self.stdout.put(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}) + "\n")
128
+
129
+ # --- Popen surface ---
130
+ def poll(self):
131
+ return None if self._alive else 0
132
+
133
+ def terminate(self):
134
+ self._alive = False
135
+ self.stdout.close()
136
+
137
+ def kill(self):
138
+ self._alive = False
139
+ self.stdout.close()
140
+
141
+ def wait(self, timeout=None):
142
+ return 0
143
+
144
+
145
+ def _make_service(monkeypatch, tmp_path, **fake_kwargs):
146
+ fake = FakeVault(**fake_kwargs)
147
+ monkeypatch.setattr(svc_mod.subprocess, "Popen", lambda *a, **k: fake)
148
+ db = tmp_path / "vault.db"
149
+ # Use a platform-absolute path so __init__ skips the $PATH lookup.
150
+ fake_bin = str(tmp_path / "fake-perseus-vault")
151
+ service = PerseusVaultMemoryService(
152
+ db_path=str(db), vault_binary=fake_bin, timeout_s=1.0
153
+ )
154
+ return service, fake
155
+
156
+
157
+ def _entry(text):
158
+ return MemoryEntry(
159
+ content=types.Content(role="user", parts=[types.Part.from_text(text=text)]),
160
+ author="user",
161
+ timestamp="2026-01-01T00:00:00+00:00",
162
+ )
163
+
164
+
165
+ # ── Tests ──────────────────────────────────────────────────────────────────
166
+
167
+
168
+ def test_init_completes_handshake(monkeypatch, tmp_path):
169
+ service, _fake = _make_service(monkeypatch, tmp_path)
170
+ assert service._request_id >= 1 # initialize consumed an id
171
+ service._close()
172
+
173
+
174
+ def test_tenant_isolation_search(monkeypatch, tmp_path):
175
+ """A search must never return another app's or user's memories, even though
176
+ Perseus Vault's recall OR-matches the shared query term."""
177
+ service, _fake = _make_service(monkeypatch, tmp_path)
178
+
179
+ asyncio.run(
180
+ service.add_memory(
181
+ app_name="app1", user_id="alice", memories=[_entry("alice likes turtles")]
182
+ )
183
+ )
184
+ asyncio.run(
185
+ service.add_memory(
186
+ app_name="app1", user_id="bob", memories=[_entry("bob likes turtles")]
187
+ )
188
+ )
189
+ asyncio.run(
190
+ service.add_memory(
191
+ app_name="app2", user_id="alice", memories=[_entry("alice in app2 turtles")]
192
+ )
193
+ )
194
+
195
+ resp = asyncio.run(
196
+ service.search_memory(app_name="app1", user_id="alice", query="turtles")
197
+ )
198
+ texts = [p.text for m in resp.memories for p in m.content.parts]
199
+ assert texts == ["alice likes turtles"], texts # only app1/alice
200
+ service._close()
201
+
202
+
203
+ def test_rpc_timeout_when_server_hangs(monkeypatch, tmp_path):
204
+ service, _fake = _make_service(monkeypatch, tmp_path, answer_tools=False)
205
+ with pytest.raises(RuntimeError, match="timed out"):
206
+ asyncio.run(
207
+ service.search_memory(app_name="a", user_id="u", query="anything")
208
+ )
209
+ service._close()
210
+
211
+
212
+ def test_id_correlation_skips_notifications(monkeypatch, tmp_path):
213
+ service, _fake = _make_service(
214
+ monkeypatch, tmp_path, emit_notification_before_reply=True
215
+ )
216
+ asyncio.run(
217
+ service.add_memory(
218
+ app_name="app", user_id="u", memories=[_entry("hello world")]
219
+ )
220
+ )
221
+ resp = asyncio.run(
222
+ service.search_memory(app_name="app", user_id="u", query="hello")
223
+ )
224
+ texts = [p.text for m in resp.memories for p in m.content.parts]
225
+ assert texts == ["hello world"], texts
226
+ service._close()