sessions-graph 0.1.0__py3-none-any.whl
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/__init__.py +39 -0
- sessions_graph/connector.py +117 -0
- sessions_graph/core.py +237 -0
- sessions_graph/models.py +69 -0
- sessions_graph-0.1.0.dist-info/METADATA +135 -0
- sessions_graph-0.1.0.dist-info/RECORD +7 -0
- sessions_graph-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|
sessions_graph/core.py
ADDED
|
@@ -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
|
+
)
|
sessions_graph/models.py
ADDED
|
@@ -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)
|
|
@@ -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,7 @@
|
|
|
1
|
+
sessions_graph/__init__.py,sha256=w45gncXgA_L7fqggHLH7VgK70mvnAinqK-XFVATFJXk,1236
|
|
2
|
+
sessions_graph/connector.py,sha256=67h-lRqt0-egJFw3Y61KSjrNbsLj3Iq8lXaInXZeN5k,4108
|
|
3
|
+
sessions_graph/core.py,sha256=DI3c9IcyfefupZ5vgAATq-5DniU_yRebbv5HxNgi_64,8647
|
|
4
|
+
sessions_graph/models.py,sha256=lzUAwnRcQ-smFer0f-fbyM8VURI0fsbZZswbZ_0za2k,2132
|
|
5
|
+
sessions_graph-0.1.0.dist-info/METADATA,sha256=q5e2uln6bhLENvZrFcRhDZNDFtOleLFrEZdkQF44kgc,4463
|
|
6
|
+
sessions_graph-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
sessions_graph-0.1.0.dist-info/RECORD,,
|