grafeo-memory 0.2.0__tar.gz → 0.2.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.
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/CHANGELOG.md +36 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/PKG-INFO +16 -4
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/README.md +15 -3
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/pyproject.toml +1 -1
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/manager.py +57 -27
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/mcp/resources.py +13 -11
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/search/graph.py +16 -15
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/search/vector.py +2 -2
- grafeo_memory-0.2.1/tests/conftest.py +21 -0
- grafeo_memory-0.2.1/tests/test_concurrency.py +119 -0
- grafeo_memory-0.2.1/tests/test_db_injection.py +117 -0
- grafeo_memory-0.2.1/tests/test_error_surfacing.py +105 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_extractor.py +32 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_manager.py +163 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_mcp_tools.py +62 -0
- grafeo_memory-0.2.1/tests/test_multi_session.py +80 -0
- grafeo_memory-0.2.1/tests/test_multi_tenant.py +203 -0
- grafeo_memory-0.2.1/tests/test_persistence_lifecycle.py +97 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_reconciler.py +53 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_search_quality.py +46 -0
- grafeo_memory-0.2.0/tests/conftest.py +0 -7
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/.env.example +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/.github/workflows/ci.yml +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/.github/workflows/pypi.yml +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/.gitignore +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/CONTRIBUTING.md +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/LICENCE +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/coverage.xml +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/async_memory.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/importance_scoring.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/mistral_quickstart.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/multimodal_vision.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/procedural_memory.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/quickstart.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/topology_boost.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/usage_tracking.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/__init__.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/__main__.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/_compat.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/_tracing.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/cli.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/embedding.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/extraction/__init__.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/extraction/entities.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/extraction/facts.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/history.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/mcp/__init__.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/mcp/prompts.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/mcp/server.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/mcp/tools.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/messages.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/prompts.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/protocol.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/reconciliation/__init__.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/reconciliation/memories.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/reconciliation/relations.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/reranker.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/schemas.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/scoring.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/search/__init__.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/temporal.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/types.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/vision.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/__init__.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/mock_llm.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_cli.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_config_validation.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_coverage_gaps.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_explain.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_history.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_instrumentation.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_messages.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_multi_hop.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_new_features.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_reranker.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_scoring.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_stats.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_temporal.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_topology_scoring.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_types.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_usage.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_vision.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/uv.lock +0 -0
|
@@ -5,6 +5,42 @@ All notable changes to grafeo-memory are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.1] - 2026-04-07
|
|
9
|
+
|
|
10
|
+
Test quality, error surfacing, documentation improvements, and shared database support.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Shared database support**: `MemoryManager` and `AsyncMemoryManager` accept an optional `db=` keyword argument to inject an existing `GrafeoDB` instance instead of creating one internally. `close()` respects ownership and leaves externally provided databases open ([#18](https://github.com/GrafeoDB/grafeo-memory/issues/18), [#19](https://github.com/GrafeoDB/grafeo-memory/pull/19), thanks [@Michaelzag](https://github.com/Michaelzag))
|
|
15
|
+
- **Multi-tenant safety**: all internal operations are now scoped to Memory and Entity nodes, making it safe to share a database with non-memory data
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **Graph metrics pollution**: `_recompute_graph_metrics()` now writes `_pagerank`, `_betweenness`, `_community` only to Memory/Entity nodes instead of every node in the database
|
|
20
|
+
- **Unlabeled Cypher queries**: `temporal_chain()` and `_get_node_relations()` now require `:Memory` labels on match patterns, preventing traversal from foreign nodes
|
|
21
|
+
- **N+1 entity lookup**: `_find_or_create_entity` and `graph_search` replaced global `find_nodes_by_property` + Python filtering with single label-scoped Cypher queries, reducing FFI crossings
|
|
22
|
+
- **Stats data leak**: `_stats_impl()` no longer exposes raw `db.info()`, returning only memory-scoped counts. MCP `memory://stats` resource now delegates to the manager's scoped stats
|
|
23
|
+
- **Error surfacing tests**: broken embedder on add/search now verified to raise, not silently return empty results
|
|
24
|
+
- **Persistence lifecycle tests**: write, close, reopen round-trip with `tmp_path`
|
|
25
|
+
- **Multi-session regression tests**: sequential open/close/open guards against event loop corruption
|
|
26
|
+
- **Concurrency tests**: concurrent async adds for same user (lock verification) and different users (isolation)
|
|
27
|
+
- **Reconciliation boundary test**: controlled embeddings at threshold boundary verify UPDATE triggers
|
|
28
|
+
- **History ordering test**: LEADS_TO chain verified for session-order retrieval
|
|
29
|
+
- **Delete cascade test**: orphaned entity behavior after memory deletion documented
|
|
30
|
+
- **Memory type filter test**: search with `memory_type` filter verified
|
|
31
|
+
- **Summarize test**: non-empty summary from related memories
|
|
32
|
+
- **MCP tool smoke tests**: all tool functions imported and called with mock context
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- `test_search_with_broken_embedder` now uses `pytest.raises` instead of silently passing
|
|
37
|
+
- Concurrency tests use `async with` context manager for guaranteed cleanup
|
|
38
|
+
- Error surfacing tests wrapped in `try/finally` for manager cleanup
|
|
39
|
+
- Shared `make_manager` helper extracted to `conftest.py`
|
|
40
|
+
- README: added model compatibility table (OpenAI, Anthropic, Mistral, Groq, Google)
|
|
41
|
+
- README: documented reconciliation threshold tuning and `history()` return type
|
|
42
|
+
- 435 tests passing
|
|
43
|
+
|
|
8
44
|
## [0.2.0] - 2026-03-26
|
|
9
45
|
|
|
10
46
|
Temporal reasoning, multi-hop search and engine integration for better long running memory performance.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: grafeo-memory
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: AI memory layer powered by GrafeoDB's hybrid graph and vector database
|
|
5
5
|
Project-URL: Homepage, https://github.com/GrafeoDB/grafeo-memory
|
|
6
6
|
Project-URL: Repository, https://github.com/GrafeoDB/grafeo-memory
|
|
@@ -183,6 +183,18 @@ MemoryManager("google-gla:gemini-2.0-flash", config, embedder=embedder)
|
|
|
183
183
|
|
|
184
184
|
Both accept an optional `model` parameter to override the default.
|
|
185
185
|
|
|
186
|
+
### Which model works best?
|
|
187
|
+
|
|
188
|
+
| Provider | Combined extraction | Individual extraction | Notes |
|
|
189
|
+
| --- | --- | --- | --- |
|
|
190
|
+
| OpenAI (gpt-4o-mini) | Works | Works | Recommended default |
|
|
191
|
+
| Anthropic (claude-sonnet) | Works | Works | Untested in CI |
|
|
192
|
+
| Mistral (mistral-small) | Falls back | Works | 3 API calls instead of 1 |
|
|
193
|
+
| Groq | Unknown | Unknown | Needs testing |
|
|
194
|
+
| Google | Unknown | Unknown | Needs testing |
|
|
195
|
+
|
|
196
|
+
grafeo-memory tries a single "combined" LLM call first (facts + entities + relations in one schema). If the model cannot handle the combined schema, it falls back to separate calls automatically. Mistral models typically trigger this fallback, which means 3 API calls per `add()` instead of 1. The fallback is silent (logged at DEBUG level, no traceback printed).
|
|
197
|
+
|
|
186
198
|
## Custom Embeddings
|
|
187
199
|
|
|
188
200
|
Implement the `EmbeddingClient` protocol to use any embedding provider:
|
|
@@ -306,7 +318,7 @@ config = MemoryConfig(instrument=InstrumentationSettings(
|
|
|
306
318
|
- `.delete(memory_id)` → `bool`
|
|
307
319
|
- `.delete_all(user_id=None)` → `int` (count deleted)
|
|
308
320
|
- `.summarize(user_id=None, *, preserve_recent=5, batch_size=20)` → `AddResult`
|
|
309
|
-
- `.history(memory_id)` → `list[HistoryEntry]
|
|
321
|
+
- `.history(memory_id)` → `list[HistoryEntry]`: returns change history sorted by timestamp (oldest first)
|
|
310
322
|
- `.set_importance(memory_id, importance)` → `bool`
|
|
311
323
|
- `.close()`: close the database
|
|
312
324
|
|
|
@@ -318,7 +330,7 @@ Use as a context manager: `with MemoryManager(...) as memory:`. Multiple session
|
|
|
318
330
|
- `user_id`: default user scope (default `"default"`)
|
|
319
331
|
- `session_id`: default session scope
|
|
320
332
|
- `agent_id`: default agent scope
|
|
321
|
-
- `reconciliation_threshold`: minimum similarity for reconciliation candidates (default 0.
|
|
333
|
+
- `reconciliation_threshold`: minimum cosine similarity (0.0 to 1.0) for a memory to be considered a reconciliation candidate during `add()`. Lower values find more candidates, higher values require closer matches. At 0.3 (default), loosely related memories are considered. At 0.7+, only very similar memories trigger UPDATE/DELETE decisions
|
|
322
334
|
- `search_min_score`: minimum score for search results, 0.0 returns everything (default 0.0)
|
|
323
335
|
- `agreement_bonus`: score boost when both vector and graph find the same memory (default 0.1)
|
|
324
336
|
- `embedding_dimensions`: vector dimensions (default 1536)
|
|
@@ -340,7 +352,7 @@ Use as a context manager: `with MemoryManager(...) as memory:`. Multiple session
|
|
|
340
352
|
- **`SearchResponse`**: list subclass of `SearchResult`, with `.usage` for LLM token counts
|
|
341
353
|
- **`MemoryEvent`**: `.action` (ADD/UPDATE/DELETE/NONE), `.memory_id`, `.text`, `.old_text`
|
|
342
354
|
- **`SearchResult`**: `.memory_id`, `.text`, `.score`, `.user_id`, `.metadata`, `.relations`, `.memory_type`
|
|
343
|
-
- **`HistoryEntry`**: `.event
|
|
355
|
+
- **`HistoryEntry`**: `.event` (ADD/UPDATE/DELETE), `.old_text`, `.new_text`, `.timestamp` (epoch ms), `.actor_id`, `.role`. Returned sorted by timestamp ascending (oldest first)
|
|
344
356
|
|
|
345
357
|
### Iteration
|
|
346
358
|
|
|
@@ -145,6 +145,18 @@ MemoryManager("google-gla:gemini-2.0-flash", config, embedder=embedder)
|
|
|
145
145
|
|
|
146
146
|
Both accept an optional `model` parameter to override the default.
|
|
147
147
|
|
|
148
|
+
### Which model works best?
|
|
149
|
+
|
|
150
|
+
| Provider | Combined extraction | Individual extraction | Notes |
|
|
151
|
+
| --- | --- | --- | --- |
|
|
152
|
+
| OpenAI (gpt-4o-mini) | Works | Works | Recommended default |
|
|
153
|
+
| Anthropic (claude-sonnet) | Works | Works | Untested in CI |
|
|
154
|
+
| Mistral (mistral-small) | Falls back | Works | 3 API calls instead of 1 |
|
|
155
|
+
| Groq | Unknown | Unknown | Needs testing |
|
|
156
|
+
| Google | Unknown | Unknown | Needs testing |
|
|
157
|
+
|
|
158
|
+
grafeo-memory tries a single "combined" LLM call first (facts + entities + relations in one schema). If the model cannot handle the combined schema, it falls back to separate calls automatically. Mistral models typically trigger this fallback, which means 3 API calls per `add()` instead of 1. The fallback is silent (logged at DEBUG level, no traceback printed).
|
|
159
|
+
|
|
148
160
|
## Custom Embeddings
|
|
149
161
|
|
|
150
162
|
Implement the `EmbeddingClient` protocol to use any embedding provider:
|
|
@@ -268,7 +280,7 @@ config = MemoryConfig(instrument=InstrumentationSettings(
|
|
|
268
280
|
- `.delete(memory_id)` → `bool`
|
|
269
281
|
- `.delete_all(user_id=None)` → `int` (count deleted)
|
|
270
282
|
- `.summarize(user_id=None, *, preserve_recent=5, batch_size=20)` → `AddResult`
|
|
271
|
-
- `.history(memory_id)` → `list[HistoryEntry]
|
|
283
|
+
- `.history(memory_id)` → `list[HistoryEntry]`: returns change history sorted by timestamp (oldest first)
|
|
272
284
|
- `.set_importance(memory_id, importance)` → `bool`
|
|
273
285
|
- `.close()`: close the database
|
|
274
286
|
|
|
@@ -280,7 +292,7 @@ Use as a context manager: `with MemoryManager(...) as memory:`. Multiple session
|
|
|
280
292
|
- `user_id`: default user scope (default `"default"`)
|
|
281
293
|
- `session_id`: default session scope
|
|
282
294
|
- `agent_id`: default agent scope
|
|
283
|
-
- `reconciliation_threshold`: minimum similarity for reconciliation candidates (default 0.
|
|
295
|
+
- `reconciliation_threshold`: minimum cosine similarity (0.0 to 1.0) for a memory to be considered a reconciliation candidate during `add()`. Lower values find more candidates, higher values require closer matches. At 0.3 (default), loosely related memories are considered. At 0.7+, only very similar memories trigger UPDATE/DELETE decisions
|
|
284
296
|
- `search_min_score`: minimum score for search results, 0.0 returns everything (default 0.0)
|
|
285
297
|
- `agreement_bonus`: score boost when both vector and graph find the same memory (default 0.1)
|
|
286
298
|
- `embedding_dimensions`: vector dimensions (default 1536)
|
|
@@ -302,7 +314,7 @@ Use as a context manager: `with MemoryManager(...) as memory:`. Multiple session
|
|
|
302
314
|
- **`SearchResponse`**: list subclass of `SearchResult`, with `.usage` for LLM token counts
|
|
303
315
|
- **`MemoryEvent`**: `.action` (ADD/UPDATE/DELETE/NONE), `.memory_id`, `.text`, `.old_text`
|
|
304
316
|
- **`SearchResult`**: `.memory_id`, `.text`, `.score`, `.user_id`, `.metadata`, `.relations`, `.memory_type`
|
|
305
|
-
- **`HistoryEntry`**: `.event
|
|
317
|
+
- **`HistoryEntry`**: `.event` (ADD/UPDATE/DELETE), `.old_text`, `.new_text`, `.timestamp` (epoch ms), `.actor_id`, `.role`. Returned sorted by timestamp ascending (oldest first)
|
|
306
318
|
|
|
307
319
|
### Iteration
|
|
308
320
|
|
|
@@ -58,6 +58,7 @@ class _MemoryCore:
|
|
|
58
58
|
*,
|
|
59
59
|
embedder: EmbeddingClient,
|
|
60
60
|
reranker: object | None = None,
|
|
61
|
+
db: GrafeoDBProtocol | None = None,
|
|
61
62
|
):
|
|
62
63
|
import grafeo
|
|
63
64
|
|
|
@@ -67,7 +68,10 @@ class _MemoryCore:
|
|
|
67
68
|
self._reranker = reranker
|
|
68
69
|
|
|
69
70
|
self._db: GrafeoDBProtocol
|
|
70
|
-
|
|
71
|
+
self._db_external = db is not None
|
|
72
|
+
if db is not None:
|
|
73
|
+
self._db = db
|
|
74
|
+
elif self._config.db_path:
|
|
71
75
|
self._db = grafeo.GrafeoDB(self._config.db_path)
|
|
72
76
|
else:
|
|
73
77
|
self._db = grafeo.GrafeoDB()
|
|
@@ -84,6 +88,14 @@ class _MemoryCore:
|
|
|
84
88
|
Agent.instrument_all(self._config.instrument if self._config.instrument is not True else True)
|
|
85
89
|
|
|
86
90
|
def _ensure_indexes(self) -> None:
|
|
91
|
+
"""Create indexes for efficient memory queries.
|
|
92
|
+
|
|
93
|
+
Vector and text indexes are label-scoped to Memory nodes. Property
|
|
94
|
+
indexes (user_id, created_at, memory_type, name) are database-wide
|
|
95
|
+
because GrafeoDB's ``create_property_index`` does not support
|
|
96
|
+
label-scoped indexing. In a shared database these indexes will also
|
|
97
|
+
cover non-memory nodes — harmless but slightly wasteful.
|
|
98
|
+
"""
|
|
87
99
|
vp = self._config.vector_property
|
|
88
100
|
tp = self._config.text_property
|
|
89
101
|
dims = self._config.embedding_dimensions
|
|
@@ -108,7 +120,7 @@ class _MemoryCore:
|
|
|
108
120
|
except Exception:
|
|
109
121
|
logger.debug("Text index creation deferred or already exists")
|
|
110
122
|
|
|
111
|
-
# Property indexes for fast filtered lookups
|
|
123
|
+
# Property indexes for fast filtered lookups (database-wide; see docstring).
|
|
112
124
|
for prop in ("user_id", "created_at", "memory_type"):
|
|
113
125
|
with contextlib.suppress(Exception):
|
|
114
126
|
self._db.create_property_index(prop)
|
|
@@ -130,10 +142,15 @@ class _MemoryCore:
|
|
|
130
142
|
def close(self) -> None:
|
|
131
143
|
"""Close the database connection.
|
|
132
144
|
|
|
145
|
+
If the database was externally provided via the ``db`` parameter,
|
|
146
|
+
it is **not** closed here — the caller owns its lifecycle.
|
|
147
|
+
|
|
133
148
|
The async runner is intentionally NOT closed here — it is shared across
|
|
134
149
|
sessions and cleaned up automatically at process exit via atexit. Closing
|
|
135
150
|
it per-session corrupts httpx/anyio transport state on Windows.
|
|
136
151
|
"""
|
|
152
|
+
if self._db_external:
|
|
153
|
+
return
|
|
137
154
|
if hasattr(self._db, "close"):
|
|
138
155
|
self._db.close() # ty: ignore[call-non-callable]
|
|
139
156
|
|
|
@@ -1162,7 +1179,7 @@ class _MemoryCore:
|
|
|
1162
1179
|
|
|
1163
1180
|
if direction in ("forward", "both"):
|
|
1164
1181
|
query = (
|
|
1165
|
-
f"MATCH (m)-[:
|
|
1182
|
+
f"MATCH (m:{MEMORY_LABEL})-[:{LEADS_TO_EDGE}*1..{max_depth}]->(rest:{MEMORY_LABEL}) "
|
|
1166
1183
|
f"WHERE id(m) = $mid{user_filter} "
|
|
1167
1184
|
f"RETURN id(rest), rest.text, rest.created_at, rest.session_id "
|
|
1168
1185
|
f"ORDER BY rest.created_at"
|
|
@@ -1192,7 +1209,7 @@ class _MemoryCore:
|
|
|
1192
1209
|
|
|
1193
1210
|
if direction in ("backward", "both"):
|
|
1194
1211
|
query = (
|
|
1195
|
-
f"MATCH (rest:
|
|
1212
|
+
f"MATCH (rest:{MEMORY_LABEL})-[:{LEADS_TO_EDGE}*1..{max_depth}]->(m:{MEMORY_LABEL}) "
|
|
1196
1213
|
f"WHERE id(m) = $mid{user_filter} "
|
|
1197
1214
|
f"RETURN id(rest), rest.text, rest.created_at, rest.session_id "
|
|
1198
1215
|
f"ORDER BY rest.created_at"
|
|
@@ -1247,6 +1264,10 @@ class _MemoryCore:
|
|
|
1247
1264
|
Runs pagerank, betweenness centrality, and louvain community detection.
|
|
1248
1265
|
Results are stored as node properties for fast access during search scoring.
|
|
1249
1266
|
Only runs when enable_graph_algorithms is True and the graph has changed.
|
|
1267
|
+
|
|
1268
|
+
Only Memory and Entity nodes are updated. Algorithm computation may span
|
|
1269
|
+
the full graph (GrafeoDB API limitation), so scores can be influenced by
|
|
1270
|
+
non-memory topology when using a shared database.
|
|
1250
1271
|
"""
|
|
1251
1272
|
if not self._config.enable_graph_algorithms or not self._graph_dirty:
|
|
1252
1273
|
return
|
|
@@ -1256,21 +1277,32 @@ class _MemoryCore:
|
|
|
1256
1277
|
logger.debug("Graph algorithms not available on this db instance")
|
|
1257
1278
|
return
|
|
1258
1279
|
|
|
1280
|
+
# Collect IDs of nodes we own so we only write to Memory/Entity nodes.
|
|
1281
|
+
owned_ids: set[int] = set()
|
|
1282
|
+
for node_id, _ in self._db.get_nodes_by_label(MEMORY_LABEL):
|
|
1283
|
+
owned_ids.add(node_id)
|
|
1284
|
+
for node_id, _ in self._db.get_nodes_by_label(ENTITY_LABEL):
|
|
1285
|
+
owned_ids.add(node_id)
|
|
1286
|
+
if not owned_ids:
|
|
1287
|
+
return
|
|
1288
|
+
|
|
1259
1289
|
algos = self._db.algorithms
|
|
1260
1290
|
|
|
1261
1291
|
try:
|
|
1262
1292
|
ranks = algos.pagerank(0.85, 100, 1e-6) # ty: ignore[unresolved-attribute]
|
|
1263
1293
|
for node_id, score in ranks.items():
|
|
1264
|
-
|
|
1265
|
-
|
|
1294
|
+
if node_id in owned_ids:
|
|
1295
|
+
with contextlib.suppress(Exception):
|
|
1296
|
+
self._db.set_node_property(node_id, "_pagerank", score)
|
|
1266
1297
|
except Exception:
|
|
1267
1298
|
logger.debug("pagerank computation failed", exc_info=True)
|
|
1268
1299
|
|
|
1269
1300
|
try:
|
|
1270
1301
|
centrality = algos.betweenness_centrality(True) # ty: ignore[unresolved-attribute]
|
|
1271
1302
|
for node_id, score in centrality.items():
|
|
1272
|
-
|
|
1273
|
-
|
|
1303
|
+
if node_id in owned_ids:
|
|
1304
|
+
with contextlib.suppress(Exception):
|
|
1305
|
+
self._db.set_node_property(node_id, "_betweenness", score)
|
|
1274
1306
|
except Exception:
|
|
1275
1307
|
logger.debug("betweenness_centrality computation failed", exc_info=True)
|
|
1276
1308
|
|
|
@@ -1278,8 +1310,9 @@ class _MemoryCore:
|
|
|
1278
1310
|
result = algos.louvain(1.0) # ty: ignore[unresolved-attribute]
|
|
1279
1311
|
communities = result.get("communities", {}) if isinstance(result, dict) else {}
|
|
1280
1312
|
for node_id, community_id in communities.items():
|
|
1281
|
-
|
|
1282
|
-
|
|
1313
|
+
if node_id in owned_ids:
|
|
1314
|
+
with contextlib.suppress(Exception):
|
|
1315
|
+
self._db.set_node_property(node_id, "_community", community_id)
|
|
1283
1316
|
except Exception:
|
|
1284
1317
|
logger.debug("louvain community detection failed", exc_info=True)
|
|
1285
1318
|
|
|
@@ -1324,19 +1357,17 @@ class _MemoryCore:
|
|
|
1324
1357
|
|
|
1325
1358
|
def _find_or_create_entity(self, entity: Entity, user_id: str) -> int:
|
|
1326
1359
|
try:
|
|
1327
|
-
|
|
1360
|
+
rows = self._db.execute(
|
|
1361
|
+
f"MATCH (e:{ENTITY_LABEL}) WHERE e.name = $name AND e.user_id = $uid RETURN id(e)",
|
|
1362
|
+
{"name": entity.name, "uid": user_id},
|
|
1363
|
+
)
|
|
1364
|
+
for row in rows:
|
|
1365
|
+
if isinstance(row, dict):
|
|
1366
|
+
vals = list(row.values())
|
|
1367
|
+
if vals:
|
|
1368
|
+
return int(vals[0])
|
|
1328
1369
|
except Exception:
|
|
1329
1370
|
logger.warning("_find_or_create_entity: lookup failed for %r", entity.name, exc_info=True)
|
|
1330
|
-
nodes = []
|
|
1331
|
-
|
|
1332
|
-
for nid in nodes:
|
|
1333
|
-
node = self._db.get_node(nid)
|
|
1334
|
-
if node is None:
|
|
1335
|
-
continue
|
|
1336
|
-
props = _get_props(node)
|
|
1337
|
-
labels = node.labels if hasattr(node, "labels") else []
|
|
1338
|
-
if ENTITY_LABEL in labels and props.get("user_id") == user_id:
|
|
1339
|
-
return nid
|
|
1340
1371
|
|
|
1341
1372
|
node = self._db.create_node(
|
|
1342
1373
|
[ENTITY_LABEL],
|
|
@@ -1594,11 +1625,6 @@ class _MemoryCore:
|
|
|
1594
1625
|
except Exception:
|
|
1595
1626
|
pass
|
|
1596
1627
|
|
|
1597
|
-
try:
|
|
1598
|
-
db_info = self._db.info()
|
|
1599
|
-
except Exception:
|
|
1600
|
-
db_info = {}
|
|
1601
|
-
|
|
1602
1628
|
total = semantic + procedural + episodic
|
|
1603
1629
|
return MemoryStats(
|
|
1604
1630
|
total_memories=total,
|
|
@@ -1607,7 +1633,11 @@ class _MemoryCore:
|
|
|
1607
1633
|
episodic_count=episodic,
|
|
1608
1634
|
entity_count=entity_count,
|
|
1609
1635
|
relation_count=relation_count,
|
|
1610
|
-
db_info=
|
|
1636
|
+
db_info={
|
|
1637
|
+
"memory_node_count": total,
|
|
1638
|
+
"entity_node_count": entity_count,
|
|
1639
|
+
"relation_edge_count": relation_count,
|
|
1640
|
+
},
|
|
1611
1641
|
)
|
|
1612
1642
|
|
|
1613
1643
|
def _set_importance_impl(self, memory_id: str, importance: float) -> bool:
|
|
@@ -32,15 +32,17 @@ async def memory_config(ctx: Context[ServerSession, AppContext]) -> str:
|
|
|
32
32
|
|
|
33
33
|
@mcp.resource("memory://stats")
|
|
34
34
|
async def memory_stats(ctx: Context[ServerSession, AppContext]) -> str:
|
|
35
|
-
"""Memory system statistics: node/edge counts
|
|
35
|
+
"""Memory system statistics: node/edge counts scoped to memory data."""
|
|
36
36
|
manager = ctx.request_context.lifespan_context.manager
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
stats = manager._stats_impl()
|
|
38
|
+
return json.dumps(
|
|
39
|
+
{
|
|
40
|
+
"total_memories": stats.total_memories,
|
|
41
|
+
"semantic_count": stats.semantic_count,
|
|
42
|
+
"procedural_count": stats.procedural_count,
|
|
43
|
+
"episodic_count": stats.episodic_count,
|
|
44
|
+
"entity_count": stats.entity_count,
|
|
45
|
+
"relation_count": stats.relation_count,
|
|
46
|
+
},
|
|
47
|
+
default=str,
|
|
48
|
+
)
|
|
@@ -79,26 +79,27 @@ def graph_search(
|
|
|
79
79
|
seen_memory_ids: set[str] = set()
|
|
80
80
|
|
|
81
81
|
for entity in entities:
|
|
82
|
+
# Find Entity nodes by name, scoped to the Entity label and user.
|
|
83
|
+
entity_nids: list[int] = []
|
|
82
84
|
try:
|
|
83
|
-
|
|
85
|
+
rows = db.execute(
|
|
86
|
+
f"MATCH (e:{ENTITY_LABEL}) WHERE e.name = $name AND e.user_id = $uid RETURN id(e)",
|
|
87
|
+
{"name": entity.name, "uid": user_id},
|
|
88
|
+
)
|
|
89
|
+
entity_nids = [int(next(iter(r.values()))) for r in rows if isinstance(r, dict) and r]
|
|
84
90
|
except Exception:
|
|
85
|
-
logger.warning("graph_search:
|
|
86
|
-
continue
|
|
91
|
+
logger.warning("graph_search: entity lookup failed for %r", entity.name, exc_info=True)
|
|
87
92
|
|
|
88
|
-
#
|
|
89
|
-
if not
|
|
93
|
+
# Case-insensitive fallback
|
|
94
|
+
if not entity_nids:
|
|
90
95
|
with contextlib.suppress(Exception):
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
continue
|
|
97
|
-
props = _get_props(node)
|
|
98
|
-
labels = node.labels if hasattr(node, "labels") else []
|
|
99
|
-
if ENTITY_LABEL not in labels or props.get("user_id") != user_id:
|
|
100
|
-
continue
|
|
96
|
+
rows = db.execute(
|
|
97
|
+
f"MATCH (e:{ENTITY_LABEL}) WHERE toLower(e.name) = $name AND e.user_id = $uid RETURN id(e)",
|
|
98
|
+
{"name": entity.name.lower(), "uid": user_id},
|
|
99
|
+
)
|
|
100
|
+
entity_nids = [int(next(iter(r.values()))) for r in rows if isinstance(r, dict) and r]
|
|
101
101
|
|
|
102
|
+
for entity_nid in entity_nids:
|
|
102
103
|
# Traverse HAS_ENTITY edges back to Memory nodes
|
|
103
104
|
try:
|
|
104
105
|
query_str = (
|
|
@@ -377,12 +377,12 @@ def _matches_filters(props: dict, filters: dict) -> bool:
|
|
|
377
377
|
|
|
378
378
|
def _get_node_relations(db: GrafeoDBProtocol, node_id: int) -> list[dict]:
|
|
379
379
|
"""Get relations for a memory node by traversing HAS_ENTITY and RELATION edges."""
|
|
380
|
-
from ..types import ENTITY_LABEL, HAS_ENTITY_EDGE, RELATION_EDGE
|
|
380
|
+
from ..types import ENTITY_LABEL, HAS_ENTITY_EDGE, MEMORY_LABEL, RELATION_EDGE
|
|
381
381
|
|
|
382
382
|
relations: list[dict] = []
|
|
383
383
|
try:
|
|
384
384
|
query = (
|
|
385
|
-
f"MATCH (m)-[:{HAS_ENTITY_EDGE}]->(e:{ENTITY_LABEL})"
|
|
385
|
+
f"MATCH (m:{MEMORY_LABEL})-[:{HAS_ENTITY_EDGE}]->(e:{ENTITY_LABEL})"
|
|
386
386
|
f"-[r:{RELATION_EDGE}]->(t:{ENTITY_LABEL}) "
|
|
387
387
|
f"WHERE id(m) = $nid RETURN e.name, r.relation_type, t.name"
|
|
388
388
|
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Pytest configuration for grafeo-memory tests."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# Add tests directory to sys.path so mock_llm can be imported
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
8
|
+
|
|
9
|
+
from mock_llm import MockEmbedder, make_test_model
|
|
10
|
+
|
|
11
|
+
from grafeo_memory import MemoryConfig, MemoryManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_manager(outputs, db_path=None, dims=16, **config_kwargs):
|
|
15
|
+
"""Create a MemoryManager with mock model and specified db_path."""
|
|
16
|
+
model = make_test_model(outputs)
|
|
17
|
+
embedder = MockEmbedder(dims)
|
|
18
|
+
defaults = {"db_path": db_path, "user_id": "test_user", "embedding_dimensions": dims}
|
|
19
|
+
defaults.update(config_kwargs)
|
|
20
|
+
config = MemoryConfig(**defaults) # type: ignore[invalid-argument-type]
|
|
21
|
+
return MemoryManager(model, config, embedder=embedder)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Tests for concurrent add operations.
|
|
2
|
+
|
|
3
|
+
Covers T7 (concurrent add for same user) and T8 (concurrent add for different users).
|
|
4
|
+
|
|
5
|
+
Uses AsyncMemoryManager with asyncio.gather to test true concurrency,
|
|
6
|
+
since the sync MemoryManager uses a shared asyncio.Runner that cannot
|
|
7
|
+
handle concurrent calls from multiple threads.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
|
|
14
|
+
from mock_llm import MockEmbedder, make_test_model
|
|
15
|
+
|
|
16
|
+
from grafeo_memory import AsyncMemoryManager, MemoryConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _make_async_manager(dims=16, **config_kwargs):
|
|
20
|
+
"""Create an AsyncMemoryManager with mock model and in-memory GrafeoDB."""
|
|
21
|
+
model = make_test_model([])
|
|
22
|
+
embedder = MockEmbedder(dims)
|
|
23
|
+
defaults = {"db_path": None, "user_id": "test_user", "embedding_dimensions": dims}
|
|
24
|
+
defaults.update(config_kwargs)
|
|
25
|
+
config = MemoryConfig(**defaults) # type: ignore[invalid-argument-type]
|
|
26
|
+
return AsyncMemoryManager(model, config, embedder=embedder)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestConcurrentAddSameUser:
|
|
30
|
+
"""T7: concurrent add() calls for the same user_id should not corrupt data."""
|
|
31
|
+
|
|
32
|
+
def test_concurrent_adds_no_data_loss(self):
|
|
33
|
+
async def _run():
|
|
34
|
+
async with _make_async_manager() as manager:
|
|
35
|
+
user = "shared_user"
|
|
36
|
+
tasks = []
|
|
37
|
+
for thread_id in (1, 2):
|
|
38
|
+
for i in range(5):
|
|
39
|
+
tasks.append(manager.add(f"thread {thread_id} fact {i}", user_id=user, infer=False))
|
|
40
|
+
await asyncio.gather(*tasks)
|
|
41
|
+
|
|
42
|
+
memories = await manager.get_all(user_id=user)
|
|
43
|
+
# Both "threads" added 5 facts each, so we expect 10 total
|
|
44
|
+
assert len(memories) == 10
|
|
45
|
+
texts = sorted(m.text for m in memories)
|
|
46
|
+
# Verify no duplicates or missing entries
|
|
47
|
+
assert len(set(texts)) == 10
|
|
48
|
+
|
|
49
|
+
asyncio.run(_run())
|
|
50
|
+
|
|
51
|
+
def test_concurrent_adds_all_texts_present(self):
|
|
52
|
+
async def _run():
|
|
53
|
+
async with _make_async_manager() as manager:
|
|
54
|
+
user = "shared_user"
|
|
55
|
+
texts_to_add = [f"concurrent fact {i}" for i in range(10)]
|
|
56
|
+
tasks = [manager.add(t, user_id=user, infer=False) for t in texts_to_add]
|
|
57
|
+
await asyncio.gather(*tasks)
|
|
58
|
+
|
|
59
|
+
memories = await manager.get_all(user_id=user)
|
|
60
|
+
stored_texts = {m.text for m in memories}
|
|
61
|
+
for t in texts_to_add:
|
|
62
|
+
assert t in stored_texts, f"Missing: {t}"
|
|
63
|
+
|
|
64
|
+
asyncio.run(_run())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestConcurrentAddDifferentUsers:
|
|
68
|
+
"""T8: concurrent adds for different users should not contaminate each other."""
|
|
69
|
+
|
|
70
|
+
def test_user_isolation_under_concurrency(self):
|
|
71
|
+
async def _run():
|
|
72
|
+
async with _make_async_manager() as manager:
|
|
73
|
+
|
|
74
|
+
async def add_for_user(user_id: str):
|
|
75
|
+
for i in range(5):
|
|
76
|
+
await manager.add(f"{user_id} fact {i}", user_id=user_id, infer=False)
|
|
77
|
+
|
|
78
|
+
await asyncio.gather(add_for_user("alice"), add_for_user("bob"))
|
|
79
|
+
|
|
80
|
+
alice_mems = await manager.get_all(user_id="alice")
|
|
81
|
+
bob_mems = await manager.get_all(user_id="bob")
|
|
82
|
+
|
|
83
|
+
assert len(alice_mems) == 5
|
|
84
|
+
assert len(bob_mems) == 5
|
|
85
|
+
|
|
86
|
+
# No cross-contamination
|
|
87
|
+
alice_texts = {m.text for m in alice_mems}
|
|
88
|
+
bob_texts = {m.text for m in bob_mems}
|
|
89
|
+
assert alice_texts.isdisjoint(bob_texts)
|
|
90
|
+
|
|
91
|
+
# Verify all alice facts mention alice
|
|
92
|
+
for t in alice_texts:
|
|
93
|
+
assert t.startswith("alice")
|
|
94
|
+
for t in bob_texts:
|
|
95
|
+
assert t.startswith("bob")
|
|
96
|
+
|
|
97
|
+
asyncio.run(_run())
|
|
98
|
+
|
|
99
|
+
def test_three_users_concurrent(self):
|
|
100
|
+
async def _run():
|
|
101
|
+
async with _make_async_manager() as manager:
|
|
102
|
+
|
|
103
|
+
async def add_for_user(user_id: str):
|
|
104
|
+
for i in range(3):
|
|
105
|
+
await manager.add(f"{user_id} item {i}", user_id=user_id, infer=False)
|
|
106
|
+
|
|
107
|
+
await asyncio.gather(
|
|
108
|
+
add_for_user("alice"),
|
|
109
|
+
add_for_user("bob"),
|
|
110
|
+
add_for_user("carol"),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
for user_id in ("alice", "bob", "carol"):
|
|
114
|
+
mems = await manager.get_all(user_id=user_id)
|
|
115
|
+
assert len(mems) == 3
|
|
116
|
+
for m in mems:
|
|
117
|
+
assert m.text.startswith(user_id)
|
|
118
|
+
|
|
119
|
+
asyncio.run(_run())
|