sessions-graph 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sessions_graph-0.1.0/.gitignore +192 -0
- sessions_graph-0.1.0/PKG-INFO +135 -0
- sessions_graph-0.1.0/README.md +118 -0
- sessions_graph-0.1.0/pyproject.toml +32 -0
- sessions_graph-0.1.0/src/sessions_graph/__init__.py +39 -0
- sessions_graph-0.1.0/src/sessions_graph/connector.py +117 -0
- sessions_graph-0.1.0/src/sessions_graph/core.py +237 -0
- sessions_graph-0.1.0/src/sessions_graph/models.py +69 -0
- sessions_graph-0.1.0/tests/__init__.py +0 -0
- sessions_graph-0.1.0/tests/test_sessions_graph.py +179 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
|
|
110
|
+
# pdm
|
|
111
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
112
|
+
#pdm.lock
|
|
113
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
114
|
+
# in version control.
|
|
115
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
116
|
+
.pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
121
|
+
__pypackages__/
|
|
122
|
+
|
|
123
|
+
# Celery stuff
|
|
124
|
+
celerybeat-schedule
|
|
125
|
+
celerybeat.pid
|
|
126
|
+
|
|
127
|
+
# SageMath parsed files
|
|
128
|
+
*.sage.py
|
|
129
|
+
|
|
130
|
+
# Environments
|
|
131
|
+
.env
|
|
132
|
+
.venv
|
|
133
|
+
env/
|
|
134
|
+
venv/
|
|
135
|
+
ENV/
|
|
136
|
+
env.bak/
|
|
137
|
+
venv.bak/
|
|
138
|
+
|
|
139
|
+
# Spyder project settings
|
|
140
|
+
.spyderproject
|
|
141
|
+
.spyproject
|
|
142
|
+
|
|
143
|
+
# Rope project settings
|
|
144
|
+
.ropeproject
|
|
145
|
+
|
|
146
|
+
# mkdocs documentation
|
|
147
|
+
/site
|
|
148
|
+
|
|
149
|
+
# mypy
|
|
150
|
+
.mypy_cache/
|
|
151
|
+
.dmypy.json
|
|
152
|
+
dmypy.json
|
|
153
|
+
|
|
154
|
+
# Pyre type checker
|
|
155
|
+
.pyre/
|
|
156
|
+
|
|
157
|
+
# pytype static type analyzer
|
|
158
|
+
.pytype/
|
|
159
|
+
|
|
160
|
+
# Cython debug symbols
|
|
161
|
+
cython_debug/
|
|
162
|
+
|
|
163
|
+
# PyCharm
|
|
164
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
165
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
166
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
167
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
168
|
+
#.idea/
|
|
169
|
+
|
|
170
|
+
# Ruff stuff:
|
|
171
|
+
.ruff_cache/
|
|
172
|
+
|
|
173
|
+
# PyPI configuration file
|
|
174
|
+
.pypirc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# VS Code
|
|
178
|
+
.vscode/
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# Local agent hook configuration
|
|
182
|
+
/.codex/
|
|
183
|
+
/.claude/settings.local.json
|
|
184
|
+
/AGENTS.md
|
|
185
|
+
/docs/agents/
|
|
186
|
+
/context-graph/CONTEXT-MAP.md
|
|
187
|
+
/context-graph/*/CONTEXT.md
|
|
188
|
+
/context-graph/docs/adr/
|
|
189
|
+
/context-graph/*/docs/adr/
|
|
190
|
+
|
|
191
|
+
# Project specific files
|
|
192
|
+
/enterprise-context/sic-agent/sic-scrapper/output/*
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sessions-graph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cross-session memory store for agents, backed by Memgraph
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: memgraph-toolbox
|
|
12
|
+
Provides-Extra: agent-context-graph
|
|
13
|
+
Requires-Dist: agent-context-graph>=0.1.2; extra == 'agent-context-graph'
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=9.0.3; extra == 'test'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Sessions Graph
|
|
19
|
+
|
|
20
|
+
Sessions Graph is the [Context Graph](../CONTEXT-MAP.md) component for session context and cross-session recall. It is the **authority on `(:Session)` nodes** in the Context Graph family. It stores free-form text assertions — called **Memories** — written explicitly by agents, and makes them searchable in future sessions.
|
|
21
|
+
|
|
22
|
+
Requires **Memgraph ≥ 3.6** (text search is stable from that release).
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install sessions-graph
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
To use with Agent Context Graph:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install sessions-graph[agent-context-graph]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from sessions_graph import SessionsGraph
|
|
40
|
+
|
|
41
|
+
graph = SessionsGraph() # connects via MEMGRAPH_HOST / MEMGRAPH_PORT env vars
|
|
42
|
+
graph.setup() # creates constraints and the text index (run once)
|
|
43
|
+
|
|
44
|
+
# Write a memory
|
|
45
|
+
mem = graph.save_memory(
|
|
46
|
+
user_id="alice",
|
|
47
|
+
content="Prefers Python over TypeScript",
|
|
48
|
+
session_id="s-abc123", # optional — links memory to a session for provenance
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Retrieve all memories for a user
|
|
52
|
+
memories = graph.get_memories("alice")
|
|
53
|
+
|
|
54
|
+
# Search memories by content (full-text, powered by Tantivy)
|
|
55
|
+
results = graph.search_memories("alice", "Python")
|
|
56
|
+
|
|
57
|
+
# Update or delete
|
|
58
|
+
graph.update_memory(mem.memory_id, "Prefers Python, especially for data tooling")
|
|
59
|
+
graph.delete_memory(mem.memory_id)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Integration with Agent Context Graph
|
|
63
|
+
|
|
64
|
+
Wire the `SessionsGraphConnector` into an `AgentLink` to get automatic session provenance — the connector tracks the active `session_id` and `user_id` from `SessionStartEvent` so you can reference them when saving memories.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from sessions_graph import SessionsGraph
|
|
68
|
+
from sessions_graph.connector import SessionsGraphConnector
|
|
69
|
+
from agent_context_graph import AgentLink
|
|
70
|
+
from agent_context_graph.adapters.claude import ClaudeAdapter
|
|
71
|
+
|
|
72
|
+
graph = SessionsGraph()
|
|
73
|
+
graph.setup()
|
|
74
|
+
|
|
75
|
+
connector = SessionsGraphConnector(graph)
|
|
76
|
+
link = AgentLink()
|
|
77
|
+
link.add_connector(connector)
|
|
78
|
+
|
|
79
|
+
adapter = ClaudeAdapter(
|
|
80
|
+
link,
|
|
81
|
+
session_id="s-abc123",
|
|
82
|
+
session_kwargs={"user_id": "alice"},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# During the session, save memories via the Python API:
|
|
86
|
+
graph.save_memory(
|
|
87
|
+
user_id=connector.active_user_id,
|
|
88
|
+
content="User works primarily in the ai-toolkit repository",
|
|
89
|
+
session_id=connector.active_session_id,
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Graph schema
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
(:User {user_id})
|
|
97
|
+
└─[:HAS_MEMORY]─▶ (:Memory {memory_id, user_id, content, created_at})
|
|
98
|
+
▲
|
|
99
|
+
[:PRODUCED_MEMORY]
|
|
100
|
+
│
|
|
101
|
+
(:Session {session_id}) ← shared idempotent coordination point
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Text search
|
|
105
|
+
|
|
106
|
+
Sessions Graph uses [Memgraph text search](https://memgraph.com/docs/querying/text-search) (powered by Tantivy) for `search_memories`. The text index is created on `setup()`:
|
|
107
|
+
|
|
108
|
+
```cypher
|
|
109
|
+
CREATE TEXT INDEX memory_content_index ON :Memory(content);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Searches run as:
|
|
113
|
+
|
|
114
|
+
```cypher
|
|
115
|
+
CALL text_search.search_all('memory_content_index', 'Python')
|
|
116
|
+
YIELD node AS m, score
|
|
117
|
+
WHERE m.user_id = 'alice'
|
|
118
|
+
RETURN m.content, score
|
|
119
|
+
ORDER BY score DESC
|
|
120
|
+
LIMIT 10;
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The query string follows [Tantivy query syntax](https://docs.rs/tantivy/latest/tantivy/query/struct.QueryParser.html).
|
|
124
|
+
|
|
125
|
+
## API reference
|
|
126
|
+
|
|
127
|
+
| Method | Description |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `setup()` | Create constraints and text index. Run once on first use. |
|
|
130
|
+
| `drop()` | Remove all Memory-related constraints and indexes. |
|
|
131
|
+
| `save_memory(user_id, content, *, session_id, memory_id)` | Persist a new Memory. Returns the stored `Memory` object. |
|
|
132
|
+
| `get_memories(user_id)` | Return all Memories for a user, newest first. |
|
|
133
|
+
| `search_memories(user_id, query, *, limit=10)` | Full-text search over Memory content. |
|
|
134
|
+
| `update_memory(memory_id, content)` | Replace the content of an existing Memory. Returns `None` if not found. |
|
|
135
|
+
| `delete_memory(memory_id)` | Remove a Memory and all its relationships. |
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Sessions Graph
|
|
2
|
+
|
|
3
|
+
Sessions Graph is the [Context Graph](../CONTEXT-MAP.md) component for session context and cross-session recall. It is the **authority on `(:Session)` nodes** in the Context Graph family. It stores free-form text assertions — called **Memories** — written explicitly by agents, and makes them searchable in future sessions.
|
|
4
|
+
|
|
5
|
+
Requires **Memgraph ≥ 3.6** (text search is stable from that release).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install sessions-graph
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
To use with Agent Context Graph:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install sessions-graph[agent-context-graph]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from sessions_graph import SessionsGraph
|
|
23
|
+
|
|
24
|
+
graph = SessionsGraph() # connects via MEMGRAPH_HOST / MEMGRAPH_PORT env vars
|
|
25
|
+
graph.setup() # creates constraints and the text index (run once)
|
|
26
|
+
|
|
27
|
+
# Write a memory
|
|
28
|
+
mem = graph.save_memory(
|
|
29
|
+
user_id="alice",
|
|
30
|
+
content="Prefers Python over TypeScript",
|
|
31
|
+
session_id="s-abc123", # optional — links memory to a session for provenance
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Retrieve all memories for a user
|
|
35
|
+
memories = graph.get_memories("alice")
|
|
36
|
+
|
|
37
|
+
# Search memories by content (full-text, powered by Tantivy)
|
|
38
|
+
results = graph.search_memories("alice", "Python")
|
|
39
|
+
|
|
40
|
+
# Update or delete
|
|
41
|
+
graph.update_memory(mem.memory_id, "Prefers Python, especially for data tooling")
|
|
42
|
+
graph.delete_memory(mem.memory_id)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Integration with Agent Context Graph
|
|
46
|
+
|
|
47
|
+
Wire the `SessionsGraphConnector` into an `AgentLink` to get automatic session provenance — the connector tracks the active `session_id` and `user_id` from `SessionStartEvent` so you can reference them when saving memories.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from sessions_graph import SessionsGraph
|
|
51
|
+
from sessions_graph.connector import SessionsGraphConnector
|
|
52
|
+
from agent_context_graph import AgentLink
|
|
53
|
+
from agent_context_graph.adapters.claude import ClaudeAdapter
|
|
54
|
+
|
|
55
|
+
graph = SessionsGraph()
|
|
56
|
+
graph.setup()
|
|
57
|
+
|
|
58
|
+
connector = SessionsGraphConnector(graph)
|
|
59
|
+
link = AgentLink()
|
|
60
|
+
link.add_connector(connector)
|
|
61
|
+
|
|
62
|
+
adapter = ClaudeAdapter(
|
|
63
|
+
link,
|
|
64
|
+
session_id="s-abc123",
|
|
65
|
+
session_kwargs={"user_id": "alice"},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# During the session, save memories via the Python API:
|
|
69
|
+
graph.save_memory(
|
|
70
|
+
user_id=connector.active_user_id,
|
|
71
|
+
content="User works primarily in the ai-toolkit repository",
|
|
72
|
+
session_id=connector.active_session_id,
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Graph schema
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
(:User {user_id})
|
|
80
|
+
└─[:HAS_MEMORY]─▶ (:Memory {memory_id, user_id, content, created_at})
|
|
81
|
+
▲
|
|
82
|
+
[:PRODUCED_MEMORY]
|
|
83
|
+
│
|
|
84
|
+
(:Session {session_id}) ← shared idempotent coordination point
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Text search
|
|
88
|
+
|
|
89
|
+
Sessions Graph uses [Memgraph text search](https://memgraph.com/docs/querying/text-search) (powered by Tantivy) for `search_memories`. The text index is created on `setup()`:
|
|
90
|
+
|
|
91
|
+
```cypher
|
|
92
|
+
CREATE TEXT INDEX memory_content_index ON :Memory(content);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Searches run as:
|
|
96
|
+
|
|
97
|
+
```cypher
|
|
98
|
+
CALL text_search.search_all('memory_content_index', 'Python')
|
|
99
|
+
YIELD node AS m, score
|
|
100
|
+
WHERE m.user_id = 'alice'
|
|
101
|
+
RETURN m.content, score
|
|
102
|
+
ORDER BY score DESC
|
|
103
|
+
LIMIT 10;
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The query string follows [Tantivy query syntax](https://docs.rs/tantivy/latest/tantivy/query/struct.QueryParser.html).
|
|
107
|
+
|
|
108
|
+
## API reference
|
|
109
|
+
|
|
110
|
+
| Method | Description |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `setup()` | Create constraints and text index. Run once on first use. |
|
|
113
|
+
| `drop()` | Remove all Memory-related constraints and indexes. |
|
|
114
|
+
| `save_memory(user_id, content, *, session_id, memory_id)` | Persist a new Memory. Returns the stored `Memory` object. |
|
|
115
|
+
| `get_memories(user_id)` | Return all Memories for a user, newest first. |
|
|
116
|
+
| `search_memories(user_id, query, *, limit=10)` | Full-text search over Memory content. |
|
|
117
|
+
| `update_memory(memory_id, content)` | Replace the content of an existing Memory. Returns `None` if not found. |
|
|
118
|
+
| `delete_memory(memory_id)` | Remove a Memory and all its relationships. |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sessions-graph"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Cross-session memory store for agents, backed by Memgraph"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Programming Language :: Python :: 3.10",
|
|
10
|
+
"Programming Language :: Python :: 3.11",
|
|
11
|
+
"Programming Language :: Python :: 3.12",
|
|
12
|
+
"Programming Language :: Python :: 3.13",
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"memgraph-toolbox",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
agent-context-graph = [
|
|
20
|
+
"agent-context-graph>=0.1.2",
|
|
21
|
+
]
|
|
22
|
+
test = [
|
|
23
|
+
"pytest>=9.0.3",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.uv.sources]
|
|
27
|
+
agent-context-graph = { workspace = true }
|
|
28
|
+
memgraph-toolbox = { workspace = true }
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Sessions Graph: Cross-session memory store for agents, backed by Memgraph.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from sessions_graph import SessionsGraph, Memory
|
|
6
|
+
|
|
7
|
+
graph = SessionsGraph()
|
|
8
|
+
graph.setup()
|
|
9
|
+
|
|
10
|
+
mem = graph.save_memory(user_id="alice", content="Prefers Python over TypeScript")
|
|
11
|
+
results = graph.search_memories(user_id="alice", query="Python")
|
|
12
|
+
|
|
13
|
+
Integration with Agent Context Graph::
|
|
14
|
+
|
|
15
|
+
from sessions_graph import SessionsGraph
|
|
16
|
+
from sessions_graph.connector import SessionsGraphConnector
|
|
17
|
+
from agent_context_graph import AgentLink
|
|
18
|
+
from agent_context_graph.adapters.claude import ClaudeAdapter
|
|
19
|
+
|
|
20
|
+
graph = SessionsGraph()
|
|
21
|
+
graph.setup()
|
|
22
|
+
|
|
23
|
+
connector = SessionsGraphConnector(graph)
|
|
24
|
+
link = AgentLink()
|
|
25
|
+
link.add_connector(connector)
|
|
26
|
+
adapter = ClaudeAdapter(link, session_id="s-1", session_kwargs={"user_id": "alice"})
|
|
27
|
+
|
|
28
|
+
# Memory writes and recalls go through the Python API:
|
|
29
|
+
graph.save_memory(
|
|
30
|
+
user_id=connector.active_user_id,
|
|
31
|
+
content="User works in the ai-toolkit repo",
|
|
32
|
+
session_id=connector.active_session_id,
|
|
33
|
+
)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from .core import SessionsGraph
|
|
37
|
+
from .models import Memory, MemoryValidationError
|
|
38
|
+
|
|
39
|
+
__all__ = ["Memory", "MemoryValidationError", "SessionsGraph"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Agent Context Graph connector for SessionsGraph.
|
|
2
|
+
|
|
3
|
+
The connector is intentionally thin: it watches ``SessionStartEvent`` and
|
|
4
|
+
``SessionEndEvent`` to MERGE ``(:User)`` and ``(:Session)`` nodes so that
|
|
5
|
+
provenance relationships can be wired when memories are saved. Memory writes
|
|
6
|
+
themselves happen through the :class:`SessionsGraph` Python API directly,
|
|
7
|
+
not through the event stream.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from sessions_graph import SessionsGraph
|
|
12
|
+
from sessions_graph.connector import SessionsGraphConnector
|
|
13
|
+
from agent_context_graph import AgentLink
|
|
14
|
+
from agent_context_graph.adapters.claude import ClaudeAdapter
|
|
15
|
+
|
|
16
|
+
graph = SessionsGraph()
|
|
17
|
+
graph.setup()
|
|
18
|
+
|
|
19
|
+
connector = SessionsGraphConnector(graph)
|
|
20
|
+
|
|
21
|
+
link = AgentLink()
|
|
22
|
+
link.add_connector(connector)
|
|
23
|
+
adapter = ClaudeAdapter(link, session_id="s-1", session_kwargs={"user_id": "alice"})
|
|
24
|
+
|
|
25
|
+
# Later, from within the agent:
|
|
26
|
+
graph.save_memory(
|
|
27
|
+
user_id=connector.active_user_id,
|
|
28
|
+
content="User prefers concise answers",
|
|
29
|
+
session_id=connector.active_session_id,
|
|
30
|
+
)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from typing import TYPE_CHECKING
|
|
36
|
+
|
|
37
|
+
from agent_context_graph.events import Event, EventType, SessionEndEvent, SessionStartEvent
|
|
38
|
+
from agent_context_graph.protocols import GraphConnector
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from .core import SessionsGraph
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_SUPPORTED_EVENTS = {EventType.SESSION_START, EventType.SESSION_END}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SessionsGraphConnector(GraphConnector):
|
|
48
|
+
"""Receives Agent Context Graph session events for memory provenance.
|
|
49
|
+
|
|
50
|
+
On ``SESSION_START``:
|
|
51
|
+
- MERGEs ``(:User {user_id})`` and ``(:Session {session_id})`` nodes.
|
|
52
|
+
- Tracks ``active_user_id`` and ``active_session_id`` for use in API calls.
|
|
53
|
+
|
|
54
|
+
On ``SESSION_END``:
|
|
55
|
+
- Clears the tracked active session context.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
graph: An initialised :class:`SessionsGraph` instance.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, graph: SessionsGraph) -> None:
|
|
62
|
+
self._graph = graph
|
|
63
|
+
self._active_user_id: str | None = None
|
|
64
|
+
self._active_session_id: str | None = None
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
# GraphConnector interface
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def supports(self, event: Event) -> bool:
|
|
71
|
+
return event.event_type in _SUPPORTED_EVENTS
|
|
72
|
+
|
|
73
|
+
def on_event(self, event: Event) -> None:
|
|
74
|
+
if isinstance(event, SessionStartEvent):
|
|
75
|
+
self._on_session_start(event)
|
|
76
|
+
elif isinstance(event, SessionEndEvent):
|
|
77
|
+
self._on_session_end(event)
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# Active session context (convenience for callers)
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def active_user_id(self) -> str | None:
|
|
85
|
+
"""The ``user_id`` from the most recent ``SessionStartEvent``, if any."""
|
|
86
|
+
return self._active_user_id
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def active_session_id(self) -> str | None:
|
|
90
|
+
"""The ``session_id`` from the most recent ``SessionStartEvent``, if any."""
|
|
91
|
+
return self._active_session_id
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
# Internals
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def _on_session_start(self, event: SessionStartEvent) -> None:
|
|
98
|
+
user_id: str | None = getattr(event, "user_id", None)
|
|
99
|
+
|
|
100
|
+
if user_id:
|
|
101
|
+
self._active_user_id = user_id
|
|
102
|
+
# Ensure the User node exists before any save_memory call
|
|
103
|
+
self._graph._db.query(
|
|
104
|
+
"MERGE (:User {user_id: $user_id});",
|
|
105
|
+
params={"user_id": user_id},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
self._active_session_id = event.session_id
|
|
109
|
+
# Ensure the Session node exists for provenance wiring
|
|
110
|
+
self._graph._db.query(
|
|
111
|
+
"MERGE (:Session {session_id: $session_id});",
|
|
112
|
+
params={"session_id": event.session_id},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _on_session_end(self, event: SessionEndEvent) -> None:
|
|
116
|
+
self._active_user_id = None
|
|
117
|
+
self._active_session_id = None
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Core SessionsGraph class for storing and recalling agent memories in Memgraph.
|
|
2
|
+
|
|
3
|
+
Graph schema
|
|
4
|
+
------------
|
|
5
|
+
Nodes:
|
|
6
|
+
(:User {user_id})
|
|
7
|
+
(:Memory {memory_id, user_id, content, created_at, session_id?})
|
|
8
|
+
(:Session {session_id})
|
|
9
|
+
|
|
10
|
+
Relationships:
|
|
11
|
+
(:User)-[:HAS_MEMORY]->(:Memory)
|
|
12
|
+
(:Session)-[:PRODUCED_MEMORY]->(:Memory) — only when session_id is provided
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from memgraph_toolbox.api.memgraph import Memgraph
|
|
21
|
+
|
|
22
|
+
from .models import Memory, validate_content, validate_memory_id, validate_user_id
|
|
23
|
+
|
|
24
|
+
_FULLTEXT_INDEX = "memory_content_index"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SessionsGraph:
|
|
28
|
+
"""Store and recall agent memories in Memgraph.
|
|
29
|
+
|
|
30
|
+
Provides:
|
|
31
|
+
- :meth:`save_memory` — persist a new Memory for a user
|
|
32
|
+
- :meth:`get_memories` — retrieve all Memories for a user
|
|
33
|
+
- :meth:`search_memories` — full-text search over Memory content
|
|
34
|
+
- :meth:`update_memory` — replace the content of an existing Memory
|
|
35
|
+
- :meth:`delete_memory` — remove a Memory by ID
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, memgraph: Memgraph | None = None, **kwargs: Any) -> None:
|
|
39
|
+
"""Initialise SessionsGraph.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
memgraph: An existing Memgraph client instance. When *None* a new
|
|
43
|
+
one is created from *kwargs* / environment variables.
|
|
44
|
+
**kwargs: Forwarded to :class:`Memgraph` when *memgraph* is ``None``.
|
|
45
|
+
"""
|
|
46
|
+
self._db = memgraph or Memgraph(**kwargs)
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
# Schema setup
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def setup(self) -> None:
|
|
53
|
+
"""Create constraints, indexes, and the full-text index."""
|
|
54
|
+
self._db.query("CREATE CONSTRAINT ON (u:User) ASSERT u.user_id IS UNIQUE;")
|
|
55
|
+
self._db.query("CREATE CONSTRAINT ON (m:Memory) ASSERT m.memory_id IS UNIQUE;")
|
|
56
|
+
self._db.query("CREATE INDEX ON :Memory(user_id);")
|
|
57
|
+
self._db.query("CREATE INDEX ON :Memory(created_at);")
|
|
58
|
+
self._db.query(f"CREATE TEXT INDEX {_FULLTEXT_INDEX} ON :Memory(content);")
|
|
59
|
+
|
|
60
|
+
def drop(self) -> None:
|
|
61
|
+
"""Remove all Memory-related constraints and indexes."""
|
|
62
|
+
with contextlib.suppress(Exception):
|
|
63
|
+
self._db.query("DROP CONSTRAINT ON (u:User) ASSERT u.user_id IS UNIQUE;")
|
|
64
|
+
with contextlib.suppress(Exception):
|
|
65
|
+
self._db.query("DROP CONSTRAINT ON (m:Memory) ASSERT m.memory_id IS UNIQUE;")
|
|
66
|
+
with contextlib.suppress(Exception):
|
|
67
|
+
self._db.query(f"DROP TEXT INDEX {_FULLTEXT_INDEX};")
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Write
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def save_memory(
|
|
74
|
+
self,
|
|
75
|
+
user_id: str,
|
|
76
|
+
content: str,
|
|
77
|
+
*,
|
|
78
|
+
session_id: str | None = None,
|
|
79
|
+
memory_id: str | None = None,
|
|
80
|
+
) -> Memory:
|
|
81
|
+
"""Persist a new Memory for *user_id*.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
user_id: The owning user identity.
|
|
85
|
+
content: The free-form text assertion to store.
|
|
86
|
+
session_id: Optional session that produced this memory (for provenance).
|
|
87
|
+
memory_id: Override the auto-generated UUID (useful in tests).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The persisted :class:`Memory` instance.
|
|
91
|
+
"""
|
|
92
|
+
memory = Memory(
|
|
93
|
+
user_id=validate_user_id(user_id),
|
|
94
|
+
content=validate_content(content),
|
|
95
|
+
session_id=session_id,
|
|
96
|
+
**({"memory_id": memory_id} if memory_id else {}),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# MERGE user, CREATE memory, wire ownership
|
|
100
|
+
self._db.query(
|
|
101
|
+
"""
|
|
102
|
+
MERGE (u:User {user_id: $user_id})
|
|
103
|
+
CREATE (m:Memory {
|
|
104
|
+
memory_id: $memory_id,
|
|
105
|
+
user_id: $user_id,
|
|
106
|
+
content: $content,
|
|
107
|
+
created_at: $created_at
|
|
108
|
+
})
|
|
109
|
+
CREATE (u)-[:HAS_MEMORY]->(m)
|
|
110
|
+
""",
|
|
111
|
+
params={
|
|
112
|
+
"user_id": memory.user_id,
|
|
113
|
+
"memory_id": memory.memory_id,
|
|
114
|
+
"content": memory.content,
|
|
115
|
+
"created_at": memory.created_at,
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Wire session provenance when a session_id is supplied
|
|
120
|
+
if session_id:
|
|
121
|
+
self._db.query(
|
|
122
|
+
"""
|
|
123
|
+
MERGE (s:Session {session_id: $session_id})
|
|
124
|
+
WITH s
|
|
125
|
+
MATCH (m:Memory {memory_id: $memory_id})
|
|
126
|
+
CREATE (s)-[:PRODUCED_MEMORY]->(m)
|
|
127
|
+
""",
|
|
128
|
+
params={"session_id": session_id, "memory_id": memory.memory_id},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return memory
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
# Read
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def get_memories(self, user_id: str) -> list[Memory]:
|
|
138
|
+
"""Return all Memories owned by *user_id*, newest first."""
|
|
139
|
+
validate_user_id(user_id)
|
|
140
|
+
rows = self._db.query(
|
|
141
|
+
"""
|
|
142
|
+
MATCH (u:User {user_id: $user_id})-[:HAS_MEMORY]->(m:Memory)
|
|
143
|
+
OPTIONAL MATCH (s:Session)-[:PRODUCED_MEMORY]->(m)
|
|
144
|
+
RETURN m.memory_id AS memory_id,
|
|
145
|
+
m.user_id AS user_id,
|
|
146
|
+
m.content AS content,
|
|
147
|
+
m.created_at AS created_at,
|
|
148
|
+
s.session_id AS session_id
|
|
149
|
+
ORDER BY m.created_at DESC
|
|
150
|
+
""",
|
|
151
|
+
params={"user_id": user_id},
|
|
152
|
+
)
|
|
153
|
+
return [self._row_to_memory(r) for r in rows]
|
|
154
|
+
|
|
155
|
+
def search_memories(self, user_id: str, query: str, *, limit: int = 10) -> list[Memory]:
|
|
156
|
+
"""Full-text search over Memory content for *user_id*.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
user_id: Only return Memories owned by this user.
|
|
160
|
+
query: Full-text search query string.
|
|
161
|
+
limit: Maximum number of results to return.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Matching :class:`Memory` instances ordered by relevance score.
|
|
165
|
+
"""
|
|
166
|
+
validate_user_id(user_id)
|
|
167
|
+
if not query or not query.strip():
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
rows = self._db.query(
|
|
171
|
+
f"""
|
|
172
|
+
CALL text_search.search_all('{_FULLTEXT_INDEX}', $query)
|
|
173
|
+
YIELD node AS m, score
|
|
174
|
+
WHERE m.user_id = $user_id
|
|
175
|
+
OPTIONAL MATCH (s:Session)-[:PRODUCED_MEMORY]->(m)
|
|
176
|
+
RETURN m.memory_id AS memory_id,
|
|
177
|
+
m.user_id AS user_id,
|
|
178
|
+
m.content AS content,
|
|
179
|
+
m.created_at AS created_at,
|
|
180
|
+
s.session_id AS session_id
|
|
181
|
+
ORDER BY score DESC
|
|
182
|
+
LIMIT {int(limit)}
|
|
183
|
+
""",
|
|
184
|
+
params={"user_id": user_id, "query": query.strip()},
|
|
185
|
+
)
|
|
186
|
+
return [self._row_to_memory(r) for r in rows]
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------
|
|
189
|
+
# Update / Delete
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def update_memory(self, memory_id: str, content: str) -> Memory | None:
|
|
193
|
+
"""Replace the content of an existing Memory.
|
|
194
|
+
|
|
195
|
+
Returns the updated :class:`Memory`, or ``None`` if not found.
|
|
196
|
+
"""
|
|
197
|
+
validate_memory_id(memory_id)
|
|
198
|
+
validate_content(content)
|
|
199
|
+
|
|
200
|
+
rows = self._db.query(
|
|
201
|
+
"""
|
|
202
|
+
MATCH (m:Memory {memory_id: $memory_id})
|
|
203
|
+
SET m.content = $content
|
|
204
|
+
OPTIONAL MATCH (s:Session)-[:PRODUCED_MEMORY]->(m)
|
|
205
|
+
RETURN m.memory_id AS memory_id,
|
|
206
|
+
m.user_id AS user_id,
|
|
207
|
+
m.content AS content,
|
|
208
|
+
m.created_at AS created_at,
|
|
209
|
+
s.session_id AS session_id
|
|
210
|
+
""",
|
|
211
|
+
params={"memory_id": memory_id, "content": content},
|
|
212
|
+
)
|
|
213
|
+
if not rows:
|
|
214
|
+
return None
|
|
215
|
+
return self._row_to_memory(rows[0])
|
|
216
|
+
|
|
217
|
+
def delete_memory(self, memory_id: str) -> None:
|
|
218
|
+
"""Remove a Memory and all its relationships by ID."""
|
|
219
|
+
validate_memory_id(memory_id)
|
|
220
|
+
self._db.query(
|
|
221
|
+
"MATCH (m:Memory {memory_id: $memory_id}) DETACH DELETE m;",
|
|
222
|
+
params={"memory_id": memory_id},
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
# Helpers
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _row_to_memory(row: dict) -> Memory:
|
|
231
|
+
return Memory(
|
|
232
|
+
memory_id=row["memory_id"],
|
|
233
|
+
user_id=row["user_id"],
|
|
234
|
+
content=row["content"],
|
|
235
|
+
created_at=row["created_at"],
|
|
236
|
+
session_id=row.get("session_id"),
|
|
237
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Data models for Sessions Graph.
|
|
2
|
+
|
|
3
|
+
Defines the core data structure for a Memory — a cross-session-durable
|
|
4
|
+
free-form text assertion written explicitly by an agent.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
_USER_ID_RE = re.compile(r"^[a-zA-Z0-9_@.\-]{1,256}$")
|
|
15
|
+
_MEMORY_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _utc_now() -> str:
|
|
19
|
+
return datetime.now(timezone.utc).isoformat()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _generate_id() -> str:
|
|
23
|
+
return str(uuid4())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MemoryValidationError(ValueError):
|
|
27
|
+
"""Raised when a Memory field violates validation rules."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_user_id(user_id: str) -> str:
|
|
31
|
+
if not user_id or not _USER_ID_RE.match(user_id):
|
|
32
|
+
raise MemoryValidationError(f"user_id must match pattern {_USER_ID_RE.pattern!r}, got: {user_id!r}")
|
|
33
|
+
return user_id
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_memory_id(memory_id: str) -> str:
|
|
37
|
+
if not memory_id or not _MEMORY_ID_RE.match(memory_id):
|
|
38
|
+
raise MemoryValidationError(f"memory_id must match pattern {_MEMORY_ID_RE.pattern!r}, got: {memory_id!r}")
|
|
39
|
+
return memory_id
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_content(content: str) -> str:
|
|
43
|
+
if not content or not content.strip():
|
|
44
|
+
raise MemoryValidationError("content must be a non-empty string")
|
|
45
|
+
return content
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Memory:
|
|
50
|
+
"""A cross-session-durable free-form text assertion owned by a user.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
user_id: The identity of the user this memory belongs to.
|
|
54
|
+
content: The free-form text assertion.
|
|
55
|
+
memory_id: Unique identifier (auto-generated UUID by default).
|
|
56
|
+
created_at: ISO-format UTC timestamp of when the memory was written.
|
|
57
|
+
session_id: The session that produced this memory (optional provenance).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
user_id: str
|
|
61
|
+
content: str
|
|
62
|
+
memory_id: str = field(default_factory=_generate_id)
|
|
63
|
+
created_at: str = field(default_factory=_utc_now)
|
|
64
|
+
session_id: str | None = None
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
validate_user_id(self.user_id)
|
|
68
|
+
validate_memory_id(self.memory_id)
|
|
69
|
+
validate_content(self.content)
|
|
File without changes
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Unit tests for Sessions Graph models, core, and connector.
|
|
2
|
+
|
|
3
|
+
These tests use an in-memory stub for the Memgraph client so they run
|
|
4
|
+
without a live database.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from unittest.mock import MagicMock
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from sessions_graph.models import Memory, MemoryValidationError
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# models
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestMemory:
|
|
20
|
+
def test_valid_memory_is_created(self):
|
|
21
|
+
m = Memory(user_id="alice", content="Prefers Python")
|
|
22
|
+
assert m.user_id == "alice"
|
|
23
|
+
assert m.content == "Prefers Python"
|
|
24
|
+
assert m.memory_id # auto-generated
|
|
25
|
+
assert m.created_at
|
|
26
|
+
assert m.session_id is None
|
|
27
|
+
|
|
28
|
+
def test_empty_content_raises(self):
|
|
29
|
+
with pytest.raises(MemoryValidationError, match="content"):
|
|
30
|
+
Memory(user_id="alice", content="")
|
|
31
|
+
|
|
32
|
+
def test_whitespace_only_content_raises(self):
|
|
33
|
+
with pytest.raises(MemoryValidationError, match="content"):
|
|
34
|
+
Memory(user_id="alice", content=" ")
|
|
35
|
+
|
|
36
|
+
def test_invalid_user_id_raises(self):
|
|
37
|
+
with pytest.raises(MemoryValidationError, match="user_id"):
|
|
38
|
+
Memory(user_id="alice bob", content="some fact") # space not allowed
|
|
39
|
+
|
|
40
|
+
def test_session_id_stored(self):
|
|
41
|
+
m = Memory(user_id="alice", content="fact", session_id="s-1")
|
|
42
|
+
assert m.session_id == "s-1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# core (stubbed Memgraph)
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _stub_db(rows: list | None = None):
|
|
51
|
+
db = MagicMock()
|
|
52
|
+
db.query.return_value = rows or []
|
|
53
|
+
return db
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _graph(rows=None):
|
|
57
|
+
from sessions_graph.core import SessionsGraph
|
|
58
|
+
|
|
59
|
+
g = SessionsGraph.__new__(SessionsGraph)
|
|
60
|
+
g._db = _stub_db(rows)
|
|
61
|
+
return g
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestSessionsGraphCore:
|
|
65
|
+
def test_save_memory_runs_two_queries(self):
|
|
66
|
+
g = _graph()
|
|
67
|
+
mem = g.save_memory("alice", "Prefers dark mode")
|
|
68
|
+
|
|
69
|
+
assert mem.user_id == "alice"
|
|
70
|
+
assert mem.content == "Prefers dark mode"
|
|
71
|
+
assert g._db.query.call_count == 1 # no session_id → one query only
|
|
72
|
+
|
|
73
|
+
def test_save_memory_with_session_runs_two_queries(self):
|
|
74
|
+
g = _graph()
|
|
75
|
+
g.save_memory("alice", "Fact", session_id="s-1")
|
|
76
|
+
assert g._db.query.call_count == 2 # main + provenance
|
|
77
|
+
|
|
78
|
+
def test_save_memory_rejects_empty_content(self):
|
|
79
|
+
g = _graph()
|
|
80
|
+
with pytest.raises(MemoryValidationError):
|
|
81
|
+
g.save_memory("alice", "")
|
|
82
|
+
|
|
83
|
+
def test_get_memories_returns_empty_list(self):
|
|
84
|
+
g = _graph(rows=[])
|
|
85
|
+
result = g.get_memories("alice")
|
|
86
|
+
assert result == []
|
|
87
|
+
|
|
88
|
+
def test_get_memories_maps_rows(self):
|
|
89
|
+
rows = [
|
|
90
|
+
{
|
|
91
|
+
"memory_id": "m-1",
|
|
92
|
+
"user_id": "alice",
|
|
93
|
+
"content": "Prefers Python",
|
|
94
|
+
"created_at": "2026-01-01T00:00:00+00:00",
|
|
95
|
+
"session_id": None,
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
g = _graph(rows=rows)
|
|
99
|
+
result = g.get_memories("alice")
|
|
100
|
+
assert len(result) == 1
|
|
101
|
+
assert result[0].content == "Prefers Python"
|
|
102
|
+
|
|
103
|
+
def test_search_memories_skips_empty_query(self):
|
|
104
|
+
g = _graph()
|
|
105
|
+
result = g.search_memories("alice", "")
|
|
106
|
+
assert result == []
|
|
107
|
+
g._db.query.assert_not_called()
|
|
108
|
+
|
|
109
|
+
def test_delete_memory_calls_detach_delete(self):
|
|
110
|
+
g = _graph()
|
|
111
|
+
g.delete_memory("m-1")
|
|
112
|
+
query_text = g._db.query.call_args.args[0]
|
|
113
|
+
assert "DETACH DELETE" in query_text
|
|
114
|
+
|
|
115
|
+
def test_update_memory_returns_none_when_not_found(self):
|
|
116
|
+
g = _graph(rows=[])
|
|
117
|
+
result = g.update_memory("m-1", "new content")
|
|
118
|
+
assert result is None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# connector
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestSessionsGraphConnector:
|
|
127
|
+
def _make(self):
|
|
128
|
+
pytest.importorskip("agent_context_graph", reason="agent-context-graph not installed")
|
|
129
|
+
from sessions_graph.connector import SessionsGraphConnector
|
|
130
|
+
|
|
131
|
+
from agent_context_graph.events import SessionEndEvent, SessionStartEvent
|
|
132
|
+
|
|
133
|
+
db = MagicMock()
|
|
134
|
+
db.query.return_value = []
|
|
135
|
+
|
|
136
|
+
from sessions_graph.core import SessionsGraph
|
|
137
|
+
|
|
138
|
+
graph = SessionsGraph.__new__(SessionsGraph)
|
|
139
|
+
graph._db = db
|
|
140
|
+
|
|
141
|
+
connector = SessionsGraphConnector(graph)
|
|
142
|
+
return connector, graph, db, SessionStartEvent, SessionEndEvent
|
|
143
|
+
|
|
144
|
+
def test_session_start_merges_user_and_session_nodes(self):
|
|
145
|
+
connector, _graph, db, SessionStartEvent, _ = self._make()
|
|
146
|
+
|
|
147
|
+
event = SessionStartEvent(session_id="s-1", user_id="alice")
|
|
148
|
+
connector.on_event(event)
|
|
149
|
+
|
|
150
|
+
assert connector.active_user_id == "alice"
|
|
151
|
+
assert connector.active_session_id == "s-1"
|
|
152
|
+
assert db.query.call_count == 2 # User MERGE + Session MERGE
|
|
153
|
+
|
|
154
|
+
def test_session_start_without_user_id_only_merges_session(self):
|
|
155
|
+
connector, _graph, db, SessionStartEvent, _ = self._make()
|
|
156
|
+
|
|
157
|
+
event = SessionStartEvent(session_id="s-1")
|
|
158
|
+
connector.on_event(event)
|
|
159
|
+
|
|
160
|
+
assert connector.active_user_id is None
|
|
161
|
+
assert connector.active_session_id == "s-1"
|
|
162
|
+
assert db.query.call_count == 1 # Session MERGE only
|
|
163
|
+
|
|
164
|
+
def test_session_end_clears_active_context(self):
|
|
165
|
+
connector, _graph, _db, SessionStartEvent, SessionEndEvent = self._make()
|
|
166
|
+
|
|
167
|
+
connector.on_event(SessionStartEvent(session_id="s-1", user_id="alice"))
|
|
168
|
+
connector.on_event(SessionEndEvent(session_id="s-1"))
|
|
169
|
+
|
|
170
|
+
assert connector.active_user_id is None
|
|
171
|
+
assert connector.active_session_id is None
|
|
172
|
+
|
|
173
|
+
def test_supports_session_events_only(self):
|
|
174
|
+
connector, _, _, SessionStartEvent, SessionEndEvent = self._make()
|
|
175
|
+
from agent_context_graph.events import ToolStartEvent
|
|
176
|
+
|
|
177
|
+
assert connector.supports(SessionStartEvent(session_id="s-1"))
|
|
178
|
+
assert connector.supports(SessionEndEvent(session_id="s-1"))
|
|
179
|
+
assert not connector.supports(ToolStartEvent(session_id="s-1", tool_name="read_file"))
|