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.
Files changed (83) hide show
  1. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/CHANGELOG.md +36 -0
  2. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/PKG-INFO +16 -4
  3. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/README.md +15 -3
  4. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/pyproject.toml +1 -1
  5. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/manager.py +57 -27
  6. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/mcp/resources.py +13 -11
  7. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/search/graph.py +16 -15
  8. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/search/vector.py +2 -2
  9. grafeo_memory-0.2.1/tests/conftest.py +21 -0
  10. grafeo_memory-0.2.1/tests/test_concurrency.py +119 -0
  11. grafeo_memory-0.2.1/tests/test_db_injection.py +117 -0
  12. grafeo_memory-0.2.1/tests/test_error_surfacing.py +105 -0
  13. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_extractor.py +32 -0
  14. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_manager.py +163 -0
  15. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_mcp_tools.py +62 -0
  16. grafeo_memory-0.2.1/tests/test_multi_session.py +80 -0
  17. grafeo_memory-0.2.1/tests/test_multi_tenant.py +203 -0
  18. grafeo_memory-0.2.1/tests/test_persistence_lifecycle.py +97 -0
  19. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_reconciler.py +53 -0
  20. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_search_quality.py +46 -0
  21. grafeo_memory-0.2.0/tests/conftest.py +0 -7
  22. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/.env.example +0 -0
  23. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/.github/workflows/ci.yml +0 -0
  24. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/.github/workflows/pypi.yml +0 -0
  25. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/.gitignore +0 -0
  26. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/CONTRIBUTING.md +0 -0
  27. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/LICENCE +0 -0
  28. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/coverage.xml +0 -0
  29. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/async_memory.py +0 -0
  30. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/importance_scoring.py +0 -0
  31. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/mistral_quickstart.py +0 -0
  32. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/multimodal_vision.py +0 -0
  33. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/procedural_memory.py +0 -0
  34. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/quickstart.py +0 -0
  35. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/topology_boost.py +0 -0
  36. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/examples/usage_tracking.py +0 -0
  37. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/__init__.py +0 -0
  38. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/__main__.py +0 -0
  39. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/_compat.py +0 -0
  40. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/_tracing.py +0 -0
  41. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/cli.py +0 -0
  42. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/embedding.py +0 -0
  43. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/extraction/__init__.py +0 -0
  44. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/extraction/entities.py +0 -0
  45. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/extraction/facts.py +0 -0
  46. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/history.py +0 -0
  47. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/mcp/__init__.py +0 -0
  48. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/mcp/prompts.py +0 -0
  49. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/mcp/server.py +0 -0
  50. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/mcp/tools.py +0 -0
  51. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/messages.py +0 -0
  52. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/prompts.py +0 -0
  53. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/protocol.py +0 -0
  54. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/reconciliation/__init__.py +0 -0
  55. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/reconciliation/memories.py +0 -0
  56. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/reconciliation/relations.py +0 -0
  57. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/reranker.py +0 -0
  58. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/schemas.py +0 -0
  59. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/scoring.py +0 -0
  60. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/search/__init__.py +0 -0
  61. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/temporal.py +0 -0
  62. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/types.py +0 -0
  63. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/src/grafeo_memory/vision.py +0 -0
  64. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/__init__.py +0 -0
  65. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/mock_llm.py +0 -0
  66. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_cli.py +0 -0
  67. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_config_validation.py +0 -0
  68. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_coverage_gaps.py +0 -0
  69. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_explain.py +0 -0
  70. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_history.py +0 -0
  71. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_instrumentation.py +0 -0
  72. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_messages.py +0 -0
  73. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_multi_hop.py +0 -0
  74. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_new_features.py +0 -0
  75. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_reranker.py +0 -0
  76. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_scoring.py +0 -0
  77. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_stats.py +0 -0
  78. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_temporal.py +0 -0
  79. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_topology_scoring.py +0 -0
  80. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_types.py +0 -0
  81. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_usage.py +0 -0
  82. {grafeo_memory-0.2.0 → grafeo_memory-0.2.1}/tests/test_vision.py +0 -0
  83. {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.0
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.3)
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`, `.old_text`, `.new_text`, `.timestamp`, `.actor_id`, `.role`
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.3)
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`, `.old_text`, `.new_text`, `.timestamp`, `.actor_id`, `.role`
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "grafeo-memory"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "AI memory layer powered by GrafeoDB's hybrid graph and vector database"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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
- if self._config.db_path:
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)-[:LEADS_TO*1..{max_depth}]->(rest:Memory) "
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:Memory)-[:LEADS_TO*1..{max_depth}]->(m) "
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
- with contextlib.suppress(Exception):
1265
- self._db.set_node_property(node_id, "_pagerank", score)
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
- with contextlib.suppress(Exception):
1273
- self._db.set_node_property(node_id, "_betweenness", score)
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
- with contextlib.suppress(Exception):
1282
- self._db.set_node_property(node_id, "_community", community_id)
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
- nodes = self._db.find_nodes_by_property("name", entity.name)
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=db_info if isinstance(db_info, dict) else {},
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, database info."""
35
+ """Memory system statistics: node/edge counts scoped to memory data."""
36
36
  manager = ctx.request_context.lifespan_context.manager
37
- db = manager._db
38
- try:
39
- info = db.info()
40
- except Exception:
41
- info = {}
42
- try:
43
- stats = db.detailed_stats() # ty: ignore[unresolved-attribute]
44
- except Exception:
45
- stats = {}
46
- return json.dumps({"db_info": info, "db_stats": stats}, default=str)
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
- node_ids = db.find_nodes_by_property("name", entity.name)
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: find_nodes_by_property failed for entity=%r", entity.name, exc_info=True)
86
- continue
91
+ logger.warning("graph_search: entity lookup failed for %r", entity.name, exc_info=True)
87
92
 
88
- # Also try case-insensitive match
89
- if not node_ids:
93
+ # Case-insensitive fallback
94
+ if not entity_nids:
90
95
  with contextlib.suppress(Exception):
91
- node_ids = db.find_nodes_by_property("name", entity.name.lower())
92
-
93
- for entity_nid in node_ids:
94
- node = db.get_node(entity_nid)
95
- if node is None:
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())