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.
@@ -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"))