grafeo-memory 0.2.0__tar.gz → 0.2.2__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 (91) hide show
  1. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/CHANGELOG.md +80 -0
  2. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/PKG-INFO +16 -4
  3. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/README.md +15 -3
  4. grafeo_memory-0.2.2/examples/temporal_bench.py +129 -0
  5. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/pyproject.toml +1 -1
  6. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/__init__.py +17 -1
  7. grafeo_memory-0.2.2/src/grafeo_memory/communities.py +283 -0
  8. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/extraction/__init__.py +3 -0
  9. grafeo_memory-0.2.2/src/grafeo_memory/extraction/temporal.py +113 -0
  10. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/manager.py +514 -49
  11. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/resources.py +13 -11
  12. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/tools.py +5 -1
  13. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/prompts.py +52 -0
  14. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/schemas.py +27 -0
  15. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/search/graph.py +33 -19
  16. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/search/vector.py +8 -2
  17. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/temporal.py +14 -0
  18. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/types.py +56 -0
  19. grafeo_memory-0.2.2/tests/conftest.py +21 -0
  20. grafeo_memory-0.2.2/tests/test_bitemporal.py +380 -0
  21. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_cli.py +3 -1
  22. grafeo_memory-0.2.2/tests/test_communities.py +405 -0
  23. grafeo_memory-0.2.2/tests/test_concurrency.py +119 -0
  24. grafeo_memory-0.2.2/tests/test_db_injection.py +117 -0
  25. grafeo_memory-0.2.2/tests/test_episodes.py +356 -0
  26. grafeo_memory-0.2.2/tests/test_error_surfacing.py +105 -0
  27. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_extractor.py +32 -0
  28. grafeo_memory-0.2.2/tests/test_graph_name.py +338 -0
  29. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_manager.py +387 -0
  30. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_mcp_tools.py +62 -0
  31. grafeo_memory-0.2.2/tests/test_multi_session.py +80 -0
  32. grafeo_memory-0.2.2/tests/test_multi_tenant.py +208 -0
  33. grafeo_memory-0.2.2/tests/test_persistence_lifecycle.py +97 -0
  34. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_reconciler.py +53 -0
  35. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_search_quality.py +46 -0
  36. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_stats.py +36 -0
  37. grafeo_memory-0.2.2/tests/test_temporal_integration.py +123 -0
  38. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/uv.lock +1 -1
  39. grafeo_memory-0.2.0/tests/conftest.py +0 -7
  40. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/.env.example +0 -0
  41. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/.github/workflows/ci.yml +0 -0
  42. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/.github/workflows/pypi.yml +0 -0
  43. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/.gitignore +0 -0
  44. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/CONTRIBUTING.md +0 -0
  45. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/LICENCE +0 -0
  46. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/coverage.xml +0 -0
  47. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/async_memory.py +0 -0
  48. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/importance_scoring.py +0 -0
  49. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/mistral_quickstart.py +0 -0
  50. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/multimodal_vision.py +0 -0
  51. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/procedural_memory.py +0 -0
  52. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/quickstart.py +0 -0
  53. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/topology_boost.py +0 -0
  54. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/usage_tracking.py +0 -0
  55. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/__main__.py +0 -0
  56. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/_compat.py +0 -0
  57. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/_tracing.py +0 -0
  58. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/cli.py +0 -0
  59. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/embedding.py +0 -0
  60. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/extraction/entities.py +0 -0
  61. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/extraction/facts.py +0 -0
  62. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/history.py +0 -0
  63. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/__init__.py +0 -0
  64. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/prompts.py +0 -0
  65. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/server.py +0 -0
  66. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/messages.py +0 -0
  67. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/protocol.py +0 -0
  68. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/reconciliation/__init__.py +0 -0
  69. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/reconciliation/memories.py +0 -0
  70. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/reconciliation/relations.py +0 -0
  71. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/reranker.py +0 -0
  72. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/scoring.py +0 -0
  73. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/search/__init__.py +0 -0
  74. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/vision.py +0 -0
  75. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/__init__.py +0 -0
  76. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/mock_llm.py +0 -0
  77. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_config_validation.py +0 -0
  78. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_coverage_gaps.py +0 -0
  79. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_explain.py +0 -0
  80. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_history.py +0 -0
  81. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_instrumentation.py +0 -0
  82. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_messages.py +0 -0
  83. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_multi_hop.py +0 -0
  84. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_new_features.py +0 -0
  85. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_reranker.py +0 -0
  86. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_scoring.py +0 -0
  87. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_temporal.py +0 -0
  88. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_topology_scoring.py +0 -0
  89. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_types.py +0 -0
  90. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_usage.py +0 -0
  91. {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_vision.py +0 -0
@@ -5,6 +5,84 @@ 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.2] - 2026-04-08
9
+
10
+ Bi-temporal validity tracking, episode-based provenance, and community summaries for richer temporal knowledge graphs.
11
+
12
+ ### Added
13
+
14
+ - **Bi-temporal model** (opt-in): every Memory node can now carry `valid_at` (when the fact became true in reality) and `invalid_at` (when it ceased being true), orthogonal to the existing system-time properties `created_at`/`expired_at`. Enable with `enable_bitemporal=True`
15
+ - **Temporal annotation extraction**: new LLM step parses real-world dates from message context (ISO-8601, year-only, relative dates) and attaches them to extracted facts. New module `extraction/temporal.py` with `annotate_temporal_async()`
16
+ - **Point-in-time queries**: `search(query, point_in_time=epoch_ms)` filters to facts that were valid at a specific moment. Memories with no `valid_at` are included permissively (backward compatible)
17
+ - **Point-in-time hint detection**: `detect_temporal_hints()` now recognizes "as of", "at that time", "back then", "in YYYY" patterns and sets `point_in_time_hint=True` with automatic expired-memory inclusion
18
+ - **Episode provenance** (opt-in): `add()` creates `:Episode` nodes storing the raw message text, linked to produced memories via `PRODUCED` edges and to extracted entities via `MENTIONS` edges. Enable with `enable_episodes=True`
19
+ - **Session replay via episodes**: `NEXT_EPISODE` edges chain episodes within a run. When episodes are enabled, `LEADS_TO` edges between memories are no longer created (episodes handle session ordering)
20
+ - **`get_episodes()`**: retrieve all episodes for a user, optionally filtered by `session_id`
21
+ - **`get_provenance(memory_id)`**: trace any memory back to the raw episode that produced it
22
+ - **`episode_chain(episode_id, direction)`**: follow `NEXT_EPISODE` edges forward, backward, or both for session replay
23
+ - **Community summaries** (opt-in): after Louvain community detection, creates `:Community` nodes with LLM-generated `name` and `summary` for entity clusters (>= 2 members). Lazy materialization on next `search()`. Enable with `enable_community_summaries=True` (requires `enable_graph_algorithms=True`)
24
+ - **`get_communities()`**: retrieve all detected communities and their summaries
25
+ - **Community context in search**: relevant community summaries are discoverable via `get_community_context()` for entity-based lookups
26
+ - New node labels: `Episode`, `Community`
27
+ - New edge types: `PRODUCED`, `MENTIONS`, `NEXT_EPISODE`, `HAS_MEMBER`
28
+ - New config options: `enable_bitemporal`, `enable_episodes`, `enable_community_summaries`
29
+ - New types: `EpisodeResult`, `CommunityInfo`, `TemporalAnnotation`, `TemporalAnnotationOutput`, `CommunitySummaryOutput`
30
+ - New exports: `EPISODE_LABEL`, `PRODUCED_EDGE`, `MENTIONS_EDGE`, `NEXT_EPISODE_EDGE`, `COMMUNITY_LABEL`, `HAS_MEMBER_EDGE`, `EpisodeResult`, `CommunityInfo`
31
+ - New modules: `extraction/temporal.py`, `communities.py`
32
+ - New prompts: `TEMPORAL_ANNOTATION_SYSTEM/USER`, `COMMUNITY_SUMMARY_SYSTEM/USER`
33
+ - **`graph_name` scoping**: optional `graph_name` on `MemoryConfig` isolates memories and entities per subgraph when multiple callers share a database via `db=`. Stamped on all Memory and Entity creates, filtered on all read paths (`_build_filters`, `graph_search`, `temporal_chain`, session linking, `stats`). No behavior change when unset ([#23](https://github.com/GrafeoDB/grafeo-memory/issues/23), [#24](https://github.com/GrafeoDB/grafeo-memory/pull/24), thanks [@Michaelzag](https://github.com/Michaelzag))
34
+ - `graph_name` added to property index list for fast scoped lookups
35
+ - 15 new tests covering `graph_name` write stamping, read isolation, entity separation, stats scoping, temporal chain boundaries, and backward compatibility
36
+
37
+ ### Changed
38
+
39
+ - `SearchResult` gains two new fields: `valid_at`, `invalid_at` (both default `None`, backward compatible)
40
+ - `MemoryEvent` gains `valid_at` field (default `None`, backward compatible)
41
+ - `ExtractionResult` gains `temporal_annotations` field (default empty dict, backward compatible)
42
+ - `MemoryStats` gains `episode_count` and `community_count` fields (both default 0)
43
+ - `search()`, `explain()` gain `point_in_time` keyword parameter on both `MemoryManager` and `AsyncMemoryManager`
44
+ - `_expire_memory()` sets `invalid_at` on the expired node when bi-temporal mode is enabled
45
+ - `_execute_decisions()` passes `valid_at` from temporal annotations to new memories and sets `invalid_at` on superseded memories
46
+ - `_stats_impl()` now counts Episode and Community nodes
47
+ - `db_info` in stats includes `episode_node_count` and `community_node_count`
48
+ - MCP `memory_search` tool gains `point_in_time` parameter
49
+
50
+ ## [0.2.1] - 2026-04-07
51
+
52
+ Test quality, error surfacing, documentation improvements, and shared database support.
53
+
54
+ ### Added
55
+
56
+ - **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))
57
+ - **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
58
+
59
+ ### Fixed
60
+
61
+ - **Graph metrics pollution**: `_recompute_graph_metrics()` now writes `_pagerank`, `_betweenness`, `_community` only to Memory/Entity nodes instead of every node in the database
62
+ - **Unlabeled Cypher queries**: `temporal_chain()` and `_get_node_relations()` now require `:Memory` labels on match patterns, preventing traversal from foreign nodes
63
+ - **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
64
+ - **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
65
+ - **Error surfacing tests**: broken embedder on add/search now verified to raise, not silently return empty results
66
+ - **Persistence lifecycle tests**: write, close, reopen round-trip with `tmp_path`
67
+ - **Multi-session regression tests**: sequential open/close/open guards against event loop corruption
68
+ - **Concurrency tests**: concurrent async adds for same user (lock verification) and different users (isolation)
69
+ - **Reconciliation boundary test**: controlled embeddings at threshold boundary verify UPDATE triggers
70
+ - **History ordering test**: LEADS_TO chain verified for session-order retrieval
71
+ - **Delete cascade test**: orphaned entity behavior after memory deletion documented
72
+ - **Memory type filter test**: search with `memory_type` filter verified
73
+ - **Summarize test**: non-empty summary from related memories
74
+ - **MCP tool smoke tests**: all tool functions imported and called with mock context
75
+
76
+ ### Changed
77
+
78
+ - `test_search_with_broken_embedder` now uses `pytest.raises` instead of silently passing
79
+ - Concurrency tests use `async with` context manager for guaranteed cleanup
80
+ - Error surfacing tests wrapped in `try/finally` for manager cleanup
81
+ - Shared `make_manager` helper extracted to `conftest.py`
82
+ - README: added model compatibility table (OpenAI, Anthropic, Mistral, Groq, Google)
83
+ - README: documented reconciliation threshold tuning and `history()` return type
84
+ - 435 tests passing
85
+
8
86
  ## [0.2.0] - 2026-03-26
9
87
 
10
88
  Temporal reasoning, multi-hop search and engine integration for better long running memory performance.
@@ -197,6 +275,8 @@ Initial release.
197
275
  - **Windows async compatibility**: persistent `asyncio.Runner` and `ProactorEventLoop` safety net for Python 3.13+
198
276
  - 230 tests, 83% coverage
199
277
 
278
+ [Unreleased]: https://github.com/GrafeoDB/grafeo-memory/compare/v0.2.1...HEAD
279
+ [0.2.1]: https://github.com/GrafeoDB/grafeo-memory/compare/v0.2.0...v0.2.1
200
280
  [0.2.0]: https://github.com/GrafeoDB/grafeo-memory/compare/v0.1.5...v0.2.0
201
281
  [0.1.5]: https://github.com/GrafeoDB/grafeo-memory/compare/v0.1.4...v0.1.5
202
282
  [0.1.4]: https://github.com/GrafeoDB/grafeo-memory/compare/v0.1.3...v0.1.4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grafeo-memory
3
- Version: 0.2.0
3
+ Version: 0.2.2
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
 
@@ -0,0 +1,129 @@
1
+ """Quick benchmark for temporal knowledge graph features.
2
+
3
+ Exercises:
4
+ 1. Bi-temporal model (valid_at/invalid_at, point-in-time queries)
5
+ 2. Episode provenance (Episode nodes, PRODUCED edges, session replay)
6
+
7
+ Requires:
8
+ MISTRAL_API_KEY in .env
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+
15
+ from dotenv import load_dotenv
16
+
17
+ load_dotenv()
18
+
19
+ from mistralai import Mistral # noqa: E402
20
+
21
+ from grafeo_memory import MemoryConfig, MemoryManager, MistralEmbedder # noqa: E402
22
+
23
+
24
+ def main():
25
+ client = Mistral()
26
+ embedder = MistralEmbedder(client)
27
+
28
+ config = MemoryConfig(
29
+ user_id="markus",
30
+ enable_bitemporal=True,
31
+ enable_episodes=True,
32
+ run_id="bench_run_1",
33
+ )
34
+
35
+ t0 = time.perf_counter()
36
+
37
+ with MemoryManager("mistral:mistral-small-latest", config, embedder=embedder) as mem:
38
+ # ------------------------------------------------------------------
39
+ # Phase 1: Bi-temporal -- add facts with real-world dates
40
+ # ------------------------------------------------------------------
41
+ print("=== Phase 1: Bi-Temporal ===\n")
42
+
43
+ msgs = [
44
+ "Markus joined Acme Corp as a software engineer in March 2022.",
45
+ "Markus was promoted to senior engineer at Acme Corp in January 2024.",
46
+ "Markus left Acme and joined Globex Industries in September 2024.",
47
+ "Markus started learning Rust in 2023, he previously only knew Python and Go.",
48
+ ]
49
+
50
+ for msg in msgs:
51
+ t1 = time.perf_counter()
52
+ events = mem.add(msg)
53
+ dt = (time.perf_counter() - t1) * 1000
54
+ for e in events:
55
+ va = f" valid_at={e.valid_at}" if e.valid_at else ""
56
+ print(f" [{e.action.value.upper():6s}] {e.text}{va}")
57
+ if e.old_text:
58
+ print(f" was: {e.old_text}")
59
+ print(f" ({dt:.0f}ms)\n")
60
+
61
+ # Point-in-time search: "as of mid-2023"
62
+ from datetime import UTC, datetime
63
+
64
+ mid_2023 = int(datetime(2023, 7, 1, tzinfo=UTC).timestamp() * 1000)
65
+ print("Search: 'Where does Markus work?' (point_in_time=2023-07-01)")
66
+ results = mem.search("Where does Markus work?", point_in_time=mid_2023)
67
+ for r in results[:3]:
68
+ va = f" valid_at={r.valid_at}" if r.valid_at else ""
69
+ ia = f" invalid_at={r.invalid_at}" if r.invalid_at else ""
70
+ print(f" [{r.score:.2f}] {r.text}{va}{ia}")
71
+ print()
72
+
73
+ # Current search (no point_in_time)
74
+ print("Search: 'Where does Markus work?' (current)")
75
+ results = mem.search("Where does Markus work?")
76
+ for r in results[:3]:
77
+ va = f" valid_at={r.valid_at}" if r.valid_at else ""
78
+ print(f" [{r.score:.2f}] {r.text}{va}")
79
+ print()
80
+
81
+ # ------------------------------------------------------------------
82
+ # Phase 2: Episode provenance
83
+ # ------------------------------------------------------------------
84
+ print("=== Phase 2: Episode Provenance ===\n")
85
+
86
+ episodes = mem.get_episodes()
87
+ print(f"Episodes created: {len(episodes)}")
88
+ for ep in episodes:
89
+ print(f" [{ep.episode_id}] {ep.content[:60]}...")
90
+ print(f" produced: {ep.produced_memories}")
91
+ print(f" mentions: {ep.mentioned_entities}")
92
+ print()
93
+
94
+ # Session replay
95
+ if episodes:
96
+ print("Episode chain (forward from first):")
97
+ chain = mem.episode_chain(episodes[0].episode_id, direction="forward")
98
+ for ep in chain:
99
+ print(f" -> [{ep.episode_id}] {ep.content[:50]}...")
100
+ print()
101
+
102
+ # Provenance
103
+ memories = mem.get_all()
104
+ if memories:
105
+ mid = memories[0].memory_id
106
+ prov = mem.get_provenance(mid)
107
+ print(f"Provenance for memory '{memories[0].text[:40]}...':")
108
+ for ep in prov:
109
+ print(f" <- Episode [{ep.episode_id}] {ep.content[:50]}...")
110
+ print()
111
+
112
+ # ------------------------------------------------------------------
113
+ # Stats
114
+ # ------------------------------------------------------------------
115
+ print("=== Stats ===\n")
116
+ stats = mem.stats()
117
+ print(f" Memories: {stats.total_memories}")
118
+ print(f" Entities: {stats.entity_count}")
119
+ print(f" Relations: {stats.relation_count}")
120
+ print(f" Episodes: {stats.episode_count}")
121
+ print(f" Communities: {stats.community_count}")
122
+ print()
123
+
124
+ total = (time.perf_counter() - t0) * 1000
125
+ print(f"Total time: {total:.0f}ms")
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "grafeo-memory"
3
- version = "0.2.0"
3
+ version = "0.2.2"
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"
@@ -16,11 +16,19 @@ from .scoring import (
16
16
  )
17
17
  from .temporal import TemporalHints, detect_temporal_hints
18
18
  from .types import (
19
+ COMMUNITY_LABEL,
20
+ EPISODE_LABEL,
21
+ HAS_MEMBER_EDGE,
19
22
  LEADS_TO_EDGE,
23
+ MENTIONS_EDGE,
24
+ NEXT_EPISODE_EDGE,
25
+ PRODUCED_EDGE,
20
26
  SUPERSEDES_EDGE,
21
27
  AddResult,
28
+ CommunityInfo,
22
29
  EntitiesOutput,
23
30
  Entity,
31
+ EpisodeResult,
24
32
  ExplainResult,
25
33
  ExplainStep,
26
34
  ExtractionResult,
@@ -39,13 +47,21 @@ from .types import (
39
47
  )
40
48
 
41
49
  __all__ = [
50
+ "COMMUNITY_LABEL",
51
+ "EPISODE_LABEL",
52
+ "HAS_MEMBER_EDGE",
42
53
  "LEADS_TO_EDGE",
54
+ "MENTIONS_EDGE",
55
+ "NEXT_EPISODE_EDGE",
56
+ "PRODUCED_EDGE",
43
57
  "SUPERSEDES_EDGE",
44
58
  "AddResult",
45
59
  "AsyncMemoryManager",
60
+ "CommunityInfo",
46
61
  "EmbeddingClient",
47
62
  "EntitiesOutput",
48
63
  "Entity",
64
+ "EpisodeResult",
49
65
  "ExplainResult",
50
66
  "ExplainStep",
51
67
  "ExtractionResult",
@@ -80,4 +96,4 @@ __all__ = [
80
96
  "detect_temporal_hints",
81
97
  ]
82
98
 
83
- __version__ = "0.2.0"
99
+ __version__ = "0.2.2"
@@ -0,0 +1,283 @@
1
+ """Community lifecycle: detect, summarize, and query entity clusters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ from collections.abc import Callable
8
+ from typing import TYPE_CHECKING
9
+
10
+ from pydantic_ai import Agent
11
+
12
+ from .prompts import COMMUNITY_SUMMARY_SYSTEM, COMMUNITY_SUMMARY_USER
13
+ from .schemas import CommunitySummaryOutput
14
+ from .search.vector import _get_props
15
+ from .types import (
16
+ COMMUNITY_LABEL,
17
+ ENTITY_LABEL,
18
+ HAS_MEMBER_EDGE,
19
+ RELATION_EDGE,
20
+ CommunityInfo,
21
+ ModelType,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from pydantic_ai.usage import RunUsage
26
+
27
+ from .protocol import GrafeoDBProtocol
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ async def materialize_communities_async(
33
+ db: GrafeoDBProtocol,
34
+ model: ModelType,
35
+ user_id: str,
36
+ louvain_result: dict,
37
+ *,
38
+ _on_usage: Callable[[str, RunUsage], None] | None = None,
39
+ ) -> list[CommunityInfo]:
40
+ """Create/update Community nodes from Louvain partition results.
41
+
42
+ Only communities with >= 2 members are materialized.
43
+ Summaries are regenerated only when membership changes.
44
+ """
45
+ communities_map = louvain_result.get("communities", {})
46
+ if not communities_map:
47
+ return []
48
+
49
+ # Group entity node IDs by community_id
50
+ clusters: dict[int, list[int]] = {}
51
+ for node_id, community_id in communities_map.items():
52
+ clusters.setdefault(community_id, []).append(node_id)
53
+
54
+ # Only process entities belonging to this user
55
+ entity_nodes: dict[int, dict] = {}
56
+ try:
57
+ for node_id, props in db.get_nodes_by_label(ENTITY_LABEL):
58
+ if props.get("user_id") == user_id:
59
+ entity_nodes[node_id] = props
60
+ except Exception:
61
+ return []
62
+
63
+ # Existing community nodes
64
+ existing_communities: dict[int, tuple[int, int]] = {} # community_id -> (node_id, member_count)
65
+ try:
66
+ for node_id, props in db.get_nodes_by_label(COMMUNITY_LABEL):
67
+ if props.get("user_id") == user_id:
68
+ cid = props.get("community_id")
69
+ if cid is not None:
70
+ existing_communities[int(cid)] = (node_id, int(props.get("member_count", 0)))
71
+ except Exception:
72
+ pass
73
+
74
+ results: list[CommunityInfo] = []
75
+ active_cids: set[int] = set()
76
+
77
+ for cid, member_ids in clusters.items():
78
+ # Filter to only entity nodes for this user
79
+ user_members = [nid for nid in member_ids if nid in entity_nodes]
80
+ if len(user_members) < 2:
81
+ continue
82
+
83
+ active_cids.add(cid)
84
+ member_names = [entity_nodes[nid].get("name", "") for nid in user_members]
85
+
86
+ # Check if community already exists with same membership
87
+ if cid in existing_communities:
88
+ existing_nid, existing_count = existing_communities[cid]
89
+ if existing_count == len(user_members):
90
+ # Membership unchanged, read existing summary
91
+ node = db.get_node(existing_nid)
92
+ if node is not None:
93
+ props = _get_props(node)
94
+ results.append(
95
+ CommunityInfo(
96
+ community_id=str(cid),
97
+ name=props.get("name", ""),
98
+ summary=props.get("summary", ""),
99
+ member_count=existing_count,
100
+ member_entities=member_names,
101
+ )
102
+ )
103
+ continue
104
+
105
+ # Collect relations between members for context
106
+ relation_texts: list[str] = []
107
+ for nid in user_members:
108
+ try:
109
+ query = (
110
+ f"MATCH (a:{ENTITY_LABEL})-[r:{RELATION_EDGE}]->(b:{ENTITY_LABEL}) "
111
+ f"WHERE id(a) = $nid RETURN a.name, r.relation_type, b.name"
112
+ )
113
+ for row in db.execute(query, {"nid": nid}):
114
+ if isinstance(row, dict):
115
+ vals = list(row.values())
116
+ if len(vals) >= 3:
117
+ relation_texts.append(f"{vals[0]} -> {vals[1]} -> {vals[2]}")
118
+ except Exception:
119
+ pass
120
+
121
+ # Generate summary via LLM
122
+ entities_text = "\n".join(f"- {n}" for n in member_names)
123
+ relations_text = "\n".join(f"- {r}" for r in relation_texts) if relation_texts else "(no relationships)"
124
+
125
+ agent = Agent(model, system_prompt=COMMUNITY_SUMMARY_SYSTEM, output_type=CommunitySummaryOutput)
126
+ try:
127
+ result = await agent.run(COMMUNITY_SUMMARY_USER.format(entities=entities_text, relations=relations_text))
128
+ except Exception:
129
+ logger.warning("Community summary generation failed for cid=%s", cid, exc_info=True)
130
+ continue
131
+
132
+ if _on_usage is not None:
133
+ _on_usage("community_summary", result.usage())
134
+
135
+ name = result.output.name
136
+ summary = result.output.summary
137
+
138
+ import time
139
+
140
+ now_ms = int(time.time() * 1000)
141
+
142
+ # Create or update community node
143
+ if cid in existing_communities:
144
+ existing_nid, _ = existing_communities[cid]
145
+ db.set_node_property(existing_nid, "name", name)
146
+ db.set_node_property(existing_nid, "summary", summary)
147
+ db.set_node_property(existing_nid, "member_count", len(user_members))
148
+ db.set_node_property(existing_nid, "updated_at", now_ms)
149
+ community_nid = existing_nid
150
+ # Delete old HAS_MEMBER edges
151
+ try:
152
+ del_query = (
153
+ f"MATCH (c:{COMMUNITY_LABEL})-[r:{HAS_MEMBER_EDGE}]->(:{ENTITY_LABEL}) "
154
+ f"WHERE id(c) = $cid RETURN id(r)"
155
+ )
156
+ for row in db.execute(del_query, {"cid": existing_nid}):
157
+ if isinstance(row, dict):
158
+ vals = list(row.values())
159
+ if vals:
160
+ with contextlib.suppress(Exception):
161
+ db.delete_edge(int(vals[0]))
162
+ except Exception:
163
+ pass
164
+ else:
165
+ node = db.create_node(
166
+ [COMMUNITY_LABEL],
167
+ {
168
+ "name": name,
169
+ "summary": summary,
170
+ "community_id": cid,
171
+ "member_count": len(user_members),
172
+ "user_id": user_id,
173
+ "created_at": now_ms,
174
+ "updated_at": now_ms,
175
+ },
176
+ )
177
+ community_nid = node.id if hasattr(node, "id") else node
178
+
179
+ # Create HAS_MEMBER edges
180
+ for nid in user_members:
181
+ with contextlib.suppress(Exception):
182
+ db.create_edge(community_nid, nid, HAS_MEMBER_EDGE)
183
+
184
+ results.append(
185
+ CommunityInfo(
186
+ community_id=str(cid),
187
+ name=name,
188
+ summary=summary,
189
+ member_count=len(user_members),
190
+ member_entities=member_names,
191
+ )
192
+ )
193
+
194
+ # Delete dissolved communities (existing but no longer active)
195
+ for cid, (existing_nid, _) in existing_communities.items():
196
+ if cid not in active_cids:
197
+ with contextlib.suppress(Exception):
198
+ db.delete_node(existing_nid)
199
+
200
+ return results
201
+
202
+
203
+ def get_communities(db: GrafeoDBProtocol, user_id: str) -> list[CommunityInfo]:
204
+ """Retrieve all Community nodes for a user."""
205
+ try:
206
+ nodes = db.get_nodes_by_label(COMMUNITY_LABEL)
207
+ except Exception:
208
+ return []
209
+
210
+ results: list[CommunityInfo] = []
211
+ for node_id, props in nodes:
212
+ if props.get("user_id") != user_id:
213
+ continue
214
+
215
+ # Collect member entity names
216
+ member_names: list[str] = []
217
+ try:
218
+ query = (
219
+ f"MATCH (c:{COMMUNITY_LABEL})-[:{HAS_MEMBER_EDGE}]->(e:{ENTITY_LABEL}) WHERE id(c) = $cid RETURN e.name"
220
+ )
221
+ for row in db.execute(query, {"cid": node_id}):
222
+ if isinstance(row, dict):
223
+ vals = list(row.values())
224
+ if vals and vals[0]:
225
+ member_names.append(str(vals[0]))
226
+ except Exception:
227
+ pass
228
+
229
+ results.append(
230
+ CommunityInfo(
231
+ community_id=str(props.get("community_id", node_id)),
232
+ name=props.get("name", ""),
233
+ summary=props.get("summary", ""),
234
+ member_count=int(props.get("member_count", 0)),
235
+ member_entities=member_names,
236
+ )
237
+ )
238
+ return results
239
+
240
+
241
+ def get_community_context(db: GrafeoDBProtocol, entity_names: list[str], user_id: str) -> list[CommunityInfo]:
242
+ """Find communities that contain any of the given entities."""
243
+ if not entity_names:
244
+ return []
245
+
246
+ try:
247
+ nodes = db.get_nodes_by_label(COMMUNITY_LABEL)
248
+ except Exception:
249
+ return []
250
+
251
+ name_set = {n.lower() for n in entity_names}
252
+ results: list[CommunityInfo] = []
253
+
254
+ for node_id, props in nodes:
255
+ if props.get("user_id") != user_id:
256
+ continue
257
+
258
+ # Check if any member entity matches
259
+ member_names: list[str] = []
260
+ try:
261
+ query = (
262
+ f"MATCH (c:{COMMUNITY_LABEL})-[:{HAS_MEMBER_EDGE}]->(e:{ENTITY_LABEL}) WHERE id(c) = $cid RETURN e.name"
263
+ )
264
+ for row in db.execute(query, {"cid": node_id}):
265
+ if isinstance(row, dict):
266
+ vals = list(row.values())
267
+ if vals and vals[0]:
268
+ member_names.append(str(vals[0]))
269
+ except Exception:
270
+ pass
271
+
272
+ if any(m.lower() in name_set for m in member_names):
273
+ results.append(
274
+ CommunityInfo(
275
+ community_id=str(props.get("community_id", node_id)),
276
+ name=props.get("name", ""),
277
+ summary=props.get("summary", ""),
278
+ member_count=int(props.get("member_count", 0)),
279
+ member_entities=member_names,
280
+ )
281
+ )
282
+
283
+ return results
@@ -2,8 +2,11 @@
2
2
 
3
3
  from .entities import extract, extract_async, extract_entities, extract_entities_async
4
4
  from .facts import extract_facts, extract_facts_async
5
+ from .temporal import annotate_temporal, annotate_temporal_async
5
6
 
6
7
  __all__ = [
8
+ "annotate_temporal",
9
+ "annotate_temporal_async",
7
10
  "extract",
8
11
  "extract_async",
9
12
  "extract_entities",