alma-memory 0.5.1__py3-none-any.whl → 0.7.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.
Files changed (111) hide show
  1. alma/__init__.py +296 -226
  2. alma/compression/__init__.py +33 -0
  3. alma/compression/pipeline.py +980 -0
  4. alma/confidence/__init__.py +47 -47
  5. alma/confidence/engine.py +540 -540
  6. alma/confidence/types.py +351 -351
  7. alma/config/loader.py +157 -157
  8. alma/consolidation/__init__.py +23 -23
  9. alma/consolidation/engine.py +678 -678
  10. alma/consolidation/prompts.py +84 -84
  11. alma/core.py +1189 -430
  12. alma/domains/__init__.py +30 -30
  13. alma/domains/factory.py +359 -359
  14. alma/domains/schemas.py +448 -448
  15. alma/domains/types.py +272 -272
  16. alma/events/__init__.py +75 -75
  17. alma/events/emitter.py +285 -284
  18. alma/events/storage_mixin.py +246 -246
  19. alma/events/types.py +126 -126
  20. alma/events/webhook.py +425 -425
  21. alma/exceptions.py +49 -49
  22. alma/extraction/__init__.py +31 -31
  23. alma/extraction/auto_learner.py +265 -265
  24. alma/extraction/extractor.py +420 -420
  25. alma/graph/__init__.py +106 -106
  26. alma/graph/backends/__init__.py +32 -32
  27. alma/graph/backends/kuzu.py +624 -624
  28. alma/graph/backends/memgraph.py +432 -432
  29. alma/graph/backends/memory.py +236 -236
  30. alma/graph/backends/neo4j.py +417 -417
  31. alma/graph/base.py +159 -159
  32. alma/graph/extraction.py +198 -198
  33. alma/graph/store.py +860 -860
  34. alma/harness/__init__.py +35 -35
  35. alma/harness/base.py +386 -386
  36. alma/harness/domains.py +705 -705
  37. alma/initializer/__init__.py +37 -37
  38. alma/initializer/initializer.py +418 -418
  39. alma/initializer/types.py +250 -250
  40. alma/integration/__init__.py +62 -62
  41. alma/integration/claude_agents.py +444 -444
  42. alma/integration/helena.py +423 -423
  43. alma/integration/victor.py +471 -471
  44. alma/learning/__init__.py +101 -86
  45. alma/learning/decay.py +878 -0
  46. alma/learning/forgetting.py +1446 -1446
  47. alma/learning/heuristic_extractor.py +390 -390
  48. alma/learning/protocols.py +374 -374
  49. alma/learning/validation.py +346 -346
  50. alma/mcp/__init__.py +123 -45
  51. alma/mcp/__main__.py +156 -156
  52. alma/mcp/resources.py +122 -122
  53. alma/mcp/server.py +955 -591
  54. alma/mcp/tools.py +3254 -509
  55. alma/observability/__init__.py +91 -84
  56. alma/observability/config.py +302 -302
  57. alma/observability/guidelines.py +170 -0
  58. alma/observability/logging.py +424 -424
  59. alma/observability/metrics.py +583 -583
  60. alma/observability/tracing.py +440 -440
  61. alma/progress/__init__.py +21 -21
  62. alma/progress/tracker.py +607 -607
  63. alma/progress/types.py +250 -250
  64. alma/retrieval/__init__.py +134 -53
  65. alma/retrieval/budget.py +525 -0
  66. alma/retrieval/cache.py +1304 -1061
  67. alma/retrieval/embeddings.py +202 -202
  68. alma/retrieval/engine.py +850 -427
  69. alma/retrieval/modes.py +365 -0
  70. alma/retrieval/progressive.py +560 -0
  71. alma/retrieval/scoring.py +344 -344
  72. alma/retrieval/trust_scoring.py +637 -0
  73. alma/retrieval/verification.py +797 -0
  74. alma/session/__init__.py +19 -19
  75. alma/session/manager.py +442 -399
  76. alma/session/types.py +288 -288
  77. alma/storage/__init__.py +101 -90
  78. alma/storage/archive.py +233 -0
  79. alma/storage/azure_cosmos.py +1259 -1259
  80. alma/storage/base.py +1083 -583
  81. alma/storage/chroma.py +1443 -1443
  82. alma/storage/constants.py +103 -103
  83. alma/storage/file_based.py +614 -614
  84. alma/storage/migrations/__init__.py +21 -21
  85. alma/storage/migrations/base.py +321 -321
  86. alma/storage/migrations/runner.py +323 -323
  87. alma/storage/migrations/version_stores.py +337 -337
  88. alma/storage/migrations/versions/__init__.py +11 -11
  89. alma/storage/migrations/versions/v1_0_0.py +373 -373
  90. alma/storage/migrations/versions/v1_1_0_workflow_context.py +551 -0
  91. alma/storage/pinecone.py +1080 -1080
  92. alma/storage/postgresql.py +1948 -1559
  93. alma/storage/qdrant.py +1306 -1306
  94. alma/storage/sqlite_local.py +3041 -1457
  95. alma/testing/__init__.py +46 -46
  96. alma/testing/factories.py +301 -301
  97. alma/testing/mocks.py +389 -389
  98. alma/types.py +292 -264
  99. alma/utils/__init__.py +19 -0
  100. alma/utils/tokenizer.py +521 -0
  101. alma/workflow/__init__.py +83 -0
  102. alma/workflow/artifacts.py +170 -0
  103. alma/workflow/checkpoint.py +311 -0
  104. alma/workflow/context.py +228 -0
  105. alma/workflow/outcomes.py +189 -0
  106. alma/workflow/reducers.py +393 -0
  107. {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/METADATA +210 -72
  108. alma_memory-0.7.0.dist-info/RECORD +112 -0
  109. alma_memory-0.5.1.dist-info/RECORD +0 -93
  110. {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/WHEEL +0 -0
  111. {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/top_level.txt +0 -0
@@ -1,417 +1,417 @@
1
- """
2
- ALMA Graph Memory - Neo4j Backend.
3
-
4
- Neo4j implementation of the GraphBackend interface.
5
- """
6
-
7
- import json
8
- import logging
9
- from datetime import datetime, timezone
10
- from typing import Any, Dict, List, Optional
11
-
12
- from alma.graph.base import GraphBackend
13
- from alma.graph.store import Entity, Relationship
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
-
18
- class Neo4jBackend(GraphBackend):
19
- """
20
- Neo4j graph database backend.
21
-
22
- Requires neo4j Python driver: pip install neo4j
23
-
24
- Example usage:
25
- backend = Neo4jBackend(
26
- uri="bolt://localhost:7687",
27
- username="neo4j",
28
- password="password"
29
- )
30
- backend.add_entity(entity)
31
- backend.close()
32
- """
33
-
34
- def __init__(
35
- self,
36
- uri: str,
37
- username: str,
38
- password: str,
39
- database: str = "neo4j",
40
- ):
41
- """
42
- Initialize Neo4j connection.
43
-
44
- Args:
45
- uri: Neo4j connection URI (bolt:// or neo4j+s://)
46
- username: Database username
47
- password: Database password
48
- database: Database name (default: "neo4j")
49
- """
50
- self.uri = uri
51
- self.username = username
52
- self.password = password
53
- self.database = database
54
- self._driver = None
55
-
56
- def _get_driver(self):
57
- """Lazy initialization of Neo4j driver."""
58
- if self._driver is None:
59
- try:
60
- from neo4j import GraphDatabase
61
-
62
- self._driver = GraphDatabase.driver(
63
- self.uri,
64
- auth=(self.username, self.password),
65
- )
66
- except ImportError as err:
67
- raise ImportError(
68
- "neo4j package required for Neo4j graph backend. "
69
- "Install with: pip install neo4j"
70
- ) from err
71
- return self._driver
72
-
73
- def _run_query(self, query: str, parameters: Optional[Dict] = None) -> List[Dict]:
74
- """Execute a Cypher query."""
75
- driver = self._get_driver()
76
- with driver.session(database=self.database) as session:
77
- result = session.run(query, parameters or {})
78
- return [dict(record) for record in result]
79
-
80
- def add_entity(self, entity: Entity) -> str:
81
- """Add or update an entity in Neo4j."""
82
- # Extract project_id and agent from properties if present
83
- properties = entity.properties.copy()
84
- project_id = properties.pop("project_id", None)
85
- agent = properties.pop("agent", None)
86
-
87
- query = """
88
- MERGE (e:Entity {id: $id})
89
- SET e.name = $name,
90
- e.entity_type = $entity_type,
91
- e.properties = $properties,
92
- e.created_at = $created_at
93
- """
94
- params = {
95
- "id": entity.id,
96
- "name": entity.name,
97
- "entity_type": entity.entity_type,
98
- "properties": json.dumps(properties),
99
- "created_at": entity.created_at.isoformat(),
100
- }
101
-
102
- # Add optional fields if present
103
- if project_id:
104
- query += ", e.project_id = $project_id"
105
- params["project_id"] = project_id
106
- if agent:
107
- query += ", e.agent = $agent"
108
- params["agent"] = agent
109
-
110
- query += " RETURN e.id as id"
111
-
112
- result = self._run_query(query, params)
113
- return result[0]["id"] if result else entity.id
114
-
115
- def add_relationship(self, relationship: Relationship) -> str:
116
- """Add or update a relationship in Neo4j."""
117
- # Sanitize relationship type for Cypher (remove special characters)
118
- rel_type = (
119
- relationship.relation_type.replace("-", "_").replace(" ", "_").upper()
120
- )
121
-
122
- query = f"""
123
- MATCH (source:Entity {{id: $source_id}})
124
- MATCH (target:Entity {{id: $target_id}})
125
- MERGE (source)-[r:{rel_type}]->(target)
126
- SET r.id = $id,
127
- r.properties = $properties,
128
- r.confidence = $confidence,
129
- r.created_at = $created_at
130
- RETURN r.id as id
131
- """
132
- result = self._run_query(
133
- query,
134
- {
135
- "id": relationship.id,
136
- "source_id": relationship.source_id,
137
- "target_id": relationship.target_id,
138
- "properties": json.dumps(relationship.properties),
139
- "confidence": relationship.confidence,
140
- "created_at": relationship.created_at.isoformat(),
141
- },
142
- )
143
- return result[0]["id"] if result else relationship.id
144
-
145
- def get_entity(self, entity_id: str) -> Optional[Entity]:
146
- """Get an entity by ID."""
147
- query = """
148
- MATCH (e:Entity {id: $id})
149
- RETURN e.id as id, e.name as name, e.entity_type as entity_type,
150
- e.properties as properties, e.created_at as created_at,
151
- e.project_id as project_id, e.agent as agent
152
- """
153
- result = self._run_query(query, {"id": entity_id})
154
- if not result:
155
- return None
156
-
157
- r = result[0]
158
- properties = json.loads(r["properties"]) if r["properties"] else {}
159
-
160
- # Add project_id and agent back to properties if present
161
- if r.get("project_id"):
162
- properties["project_id"] = r["project_id"]
163
- if r.get("agent"):
164
- properties["agent"] = r["agent"]
165
-
166
- return Entity(
167
- id=r["id"],
168
- name=r["name"],
169
- entity_type=r["entity_type"],
170
- properties=properties,
171
- created_at=(
172
- datetime.fromisoformat(r["created_at"])
173
- if r["created_at"]
174
- else datetime.now(timezone.utc)
175
- ),
176
- )
177
-
178
- def get_entities(
179
- self,
180
- entity_type: Optional[str] = None,
181
- project_id: Optional[str] = None,
182
- agent: Optional[str] = None,
183
- limit: int = 100,
184
- ) -> List[Entity]:
185
- """Get entities with optional filtering."""
186
- conditions = []
187
- params: Dict[str, Any] = {"limit": limit}
188
-
189
- if entity_type:
190
- conditions.append("e.entity_type = $entity_type")
191
- params["entity_type"] = entity_type
192
- if project_id:
193
- conditions.append("e.project_id = $project_id")
194
- params["project_id"] = project_id
195
- if agent:
196
- conditions.append("e.agent = $agent")
197
- params["agent"] = agent
198
-
199
- where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
200
-
201
- query = f"""
202
- MATCH (e:Entity)
203
- {where_clause}
204
- RETURN e.id as id, e.name as name, e.entity_type as entity_type,
205
- e.properties as properties, e.created_at as created_at,
206
- e.project_id as project_id, e.agent as agent
207
- LIMIT $limit
208
- """
209
-
210
- results = self._run_query(query, params)
211
- entities = []
212
- for r in results:
213
- properties = json.loads(r["properties"]) if r["properties"] else {}
214
- if r.get("project_id"):
215
- properties["project_id"] = r["project_id"]
216
- if r.get("agent"):
217
- properties["agent"] = r["agent"]
218
-
219
- entities.append(
220
- Entity(
221
- id=r["id"],
222
- name=r["name"],
223
- entity_type=r["entity_type"],
224
- properties=properties,
225
- created_at=(
226
- datetime.fromisoformat(r["created_at"])
227
- if r["created_at"]
228
- else datetime.now(timezone.utc)
229
- ),
230
- )
231
- )
232
- return entities
233
-
234
- def get_relationships(self, entity_id: str) -> List[Relationship]:
235
- """Get all relationships for an entity (both directions)."""
236
- query = """
237
- MATCH (e:Entity {id: $entity_id})-[r]-(other:Entity)
238
- RETURN r.id as id,
239
- CASE WHEN startNode(r).id = $entity_id THEN e.id ELSE other.id END as source_id,
240
- CASE WHEN endNode(r).id = $entity_id THEN e.id ELSE other.id END as target_id,
241
- type(r) as relation_type, r.properties as properties,
242
- r.confidence as confidence, r.created_at as created_at
243
- """
244
-
245
- results = self._run_query(query, {"entity_id": entity_id})
246
- relationships = []
247
- for r in results:
248
- rel_id = (
249
- r["id"] or f"{r['source_id']}-{r['relation_type']}-{r['target_id']}"
250
- )
251
- relationships.append(
252
- Relationship(
253
- id=rel_id,
254
- source_id=r["source_id"],
255
- target_id=r["target_id"],
256
- relation_type=r["relation_type"],
257
- properties=json.loads(r["properties"]) if r["properties"] else {},
258
- confidence=r["confidence"] or 1.0,
259
- created_at=(
260
- datetime.fromisoformat(r["created_at"])
261
- if r["created_at"]
262
- else datetime.now(timezone.utc)
263
- ),
264
- )
265
- )
266
- return relationships
267
-
268
- def search_entities(
269
- self,
270
- query: str,
271
- embedding: Optional[List[float]] = None,
272
- top_k: int = 10,
273
- ) -> List[Entity]:
274
- """
275
- Search for entities by name.
276
-
277
- Note: Vector similarity search requires Neo4j 5.x with vector index.
278
- Falls back to text search if embedding is provided but vector index
279
- is not available.
280
- """
281
- # For now, we do text-based search
282
- # Vector search can be added when Neo4j vector indexes are set up
283
- cypher = """
284
- MATCH (e:Entity)
285
- WHERE toLower(e.name) CONTAINS toLower($query)
286
- RETURN e.id as id, e.name as name, e.entity_type as entity_type,
287
- e.properties as properties, e.created_at as created_at,
288
- e.project_id as project_id, e.agent as agent
289
- LIMIT $limit
290
- """
291
-
292
- results = self._run_query(cypher, {"query": query, "limit": top_k})
293
- entities = []
294
- for r in results:
295
- properties = json.loads(r["properties"]) if r["properties"] else {}
296
- if r.get("project_id"):
297
- properties["project_id"] = r["project_id"]
298
- if r.get("agent"):
299
- properties["agent"] = r["agent"]
300
-
301
- entities.append(
302
- Entity(
303
- id=r["id"],
304
- name=r["name"],
305
- entity_type=r["entity_type"],
306
- properties=properties,
307
- created_at=(
308
- datetime.fromisoformat(r["created_at"])
309
- if r["created_at"]
310
- else datetime.now(timezone.utc)
311
- ),
312
- )
313
- )
314
- return entities
315
-
316
- def delete_entity(self, entity_id: str) -> bool:
317
- """Delete an entity and its relationships."""
318
- query = """
319
- MATCH (e:Entity {id: $id})
320
- DETACH DELETE e
321
- RETURN count(e) as deleted
322
- """
323
- result = self._run_query(query, {"id": entity_id})
324
- return result[0]["deleted"] > 0 if result else False
325
-
326
- def delete_relationship(self, relationship_id: str) -> bool:
327
- """Delete a specific relationship by ID."""
328
- query = """
329
- MATCH ()-[r]-()
330
- WHERE r.id = $id
331
- DELETE r
332
- RETURN count(r) as deleted
333
- """
334
- result = self._run_query(query, {"id": relationship_id})
335
- return result[0]["deleted"] > 0 if result else False
336
-
337
- def close(self) -> None:
338
- """Close the Neo4j driver connection."""
339
- if self._driver:
340
- self._driver.close()
341
- self._driver = None
342
-
343
- # Additional methods for compatibility with existing GraphStore API
344
-
345
- def find_entities(
346
- self,
347
- name: Optional[str] = None,
348
- entity_type: Optional[str] = None,
349
- limit: int = 10,
350
- ) -> List[Entity]:
351
- """
352
- Find entities by name or type.
353
-
354
- This method provides compatibility with the existing GraphStore API.
355
- """
356
- if name:
357
- return self.search_entities(query=name, top_k=limit)
358
-
359
- return self.get_entities(entity_type=entity_type, limit=limit)
360
-
361
- def get_relationships_directional(
362
- self,
363
- entity_id: str,
364
- direction: str = "both",
365
- relation_type: Optional[str] = None,
366
- ) -> List[Relationship]:
367
- """
368
- Get relationships for an entity with direction control.
369
-
370
- This method provides compatibility with the existing GraphStore API.
371
-
372
- Args:
373
- entity_id: The entity ID.
374
- direction: "outgoing", "incoming", or "both".
375
- relation_type: Optional filter by relationship type.
376
-
377
- Returns:
378
- List of matching relationships.
379
- """
380
- if direction == "outgoing":
381
- pattern = "(e)-[r]->(other)"
382
- elif direction == "incoming":
383
- pattern = "(e)<-[r]-(other)"
384
- else:
385
- pattern = "(e)-[r]-(other)"
386
-
387
- type_filter = f":{relation_type}" if relation_type else ""
388
-
389
- query = f"""
390
- MATCH (e:Entity {{id: $entity_id}}){pattern.replace("[r]", f"[r{type_filter}]")}
391
- RETURN r.id as id, e.id as source_id, other.id as target_id,
392
- type(r) as relation_type, r.properties as properties,
393
- r.confidence as confidence, r.created_at as created_at
394
- """
395
-
396
- results = self._run_query(query, {"entity_id": entity_id})
397
- relationships = []
398
- for r in results:
399
- rel_id = (
400
- r["id"] or f"{r['source_id']}-{r['relation_type']}-{r['target_id']}"
401
- )
402
- relationships.append(
403
- Relationship(
404
- id=rel_id,
405
- source_id=r["source_id"],
406
- target_id=r["target_id"],
407
- relation_type=r["relation_type"],
408
- properties=json.loads(r["properties"]) if r["properties"] else {},
409
- confidence=r["confidence"] or 1.0,
410
- created_at=(
411
- datetime.fromisoformat(r["created_at"])
412
- if r["created_at"]
413
- else datetime.now(timezone.utc)
414
- ),
415
- )
416
- )
417
- return relationships
1
+ """
2
+ ALMA Graph Memory - Neo4j Backend.
3
+
4
+ Neo4j implementation of the GraphBackend interface.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from alma.graph.base import GraphBackend
13
+ from alma.graph.store import Entity, Relationship
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class Neo4jBackend(GraphBackend):
19
+ """
20
+ Neo4j graph database backend.
21
+
22
+ Requires neo4j Python driver: pip install neo4j
23
+
24
+ Example usage:
25
+ backend = Neo4jBackend(
26
+ uri="bolt://localhost:7687",
27
+ username="neo4j",
28
+ password="password"
29
+ )
30
+ backend.add_entity(entity)
31
+ backend.close()
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ uri: str,
37
+ username: str,
38
+ password: str,
39
+ database: str = "neo4j",
40
+ ):
41
+ """
42
+ Initialize Neo4j connection.
43
+
44
+ Args:
45
+ uri: Neo4j connection URI (bolt:// or neo4j+s://)
46
+ username: Database username
47
+ password: Database password
48
+ database: Database name (default: "neo4j")
49
+ """
50
+ self.uri = uri
51
+ self.username = username
52
+ self.password = password
53
+ self.database = database
54
+ self._driver = None
55
+
56
+ def _get_driver(self):
57
+ """Lazy initialization of Neo4j driver."""
58
+ if self._driver is None:
59
+ try:
60
+ from neo4j import GraphDatabase
61
+
62
+ self._driver = GraphDatabase.driver(
63
+ self.uri,
64
+ auth=(self.username, self.password),
65
+ )
66
+ except ImportError as err:
67
+ raise ImportError(
68
+ "neo4j package required for Neo4j graph backend. "
69
+ "Install with: pip install neo4j"
70
+ ) from err
71
+ return self._driver
72
+
73
+ def _run_query(self, query: str, parameters: Optional[Dict] = None) -> List[Dict]:
74
+ """Execute a Cypher query."""
75
+ driver = self._get_driver()
76
+ with driver.session(database=self.database) as session:
77
+ result = session.run(query, parameters or {})
78
+ return [dict(record) for record in result]
79
+
80
+ def add_entity(self, entity: Entity) -> str:
81
+ """Add or update an entity in Neo4j."""
82
+ # Extract project_id and agent from properties if present
83
+ properties = entity.properties.copy()
84
+ project_id = properties.pop("project_id", None)
85
+ agent = properties.pop("agent", None)
86
+
87
+ query = """
88
+ MERGE (e:Entity {id: $id})
89
+ SET e.name = $name,
90
+ e.entity_type = $entity_type,
91
+ e.properties = $properties,
92
+ e.created_at = $created_at
93
+ """
94
+ params = {
95
+ "id": entity.id,
96
+ "name": entity.name,
97
+ "entity_type": entity.entity_type,
98
+ "properties": json.dumps(properties),
99
+ "created_at": entity.created_at.isoformat(),
100
+ }
101
+
102
+ # Add optional fields if present
103
+ if project_id:
104
+ query += ", e.project_id = $project_id"
105
+ params["project_id"] = project_id
106
+ if agent:
107
+ query += ", e.agent = $agent"
108
+ params["agent"] = agent
109
+
110
+ query += " RETURN e.id as id"
111
+
112
+ result = self._run_query(query, params)
113
+ return result[0]["id"] if result else entity.id
114
+
115
+ def add_relationship(self, relationship: Relationship) -> str:
116
+ """Add or update a relationship in Neo4j."""
117
+ # Sanitize relationship type for Cypher (remove special characters)
118
+ rel_type = (
119
+ relationship.relation_type.replace("-", "_").replace(" ", "_").upper()
120
+ )
121
+
122
+ query = f"""
123
+ MATCH (source:Entity {{id: $source_id}})
124
+ MATCH (target:Entity {{id: $target_id}})
125
+ MERGE (source)-[r:{rel_type}]->(target)
126
+ SET r.id = $id,
127
+ r.properties = $properties,
128
+ r.confidence = $confidence,
129
+ r.created_at = $created_at
130
+ RETURN r.id as id
131
+ """
132
+ result = self._run_query(
133
+ query,
134
+ {
135
+ "id": relationship.id,
136
+ "source_id": relationship.source_id,
137
+ "target_id": relationship.target_id,
138
+ "properties": json.dumps(relationship.properties),
139
+ "confidence": relationship.confidence,
140
+ "created_at": relationship.created_at.isoformat(),
141
+ },
142
+ )
143
+ return result[0]["id"] if result else relationship.id
144
+
145
+ def get_entity(self, entity_id: str) -> Optional[Entity]:
146
+ """Get an entity by ID."""
147
+ query = """
148
+ MATCH (e:Entity {id: $id})
149
+ RETURN e.id as id, e.name as name, e.entity_type as entity_type,
150
+ e.properties as properties, e.created_at as created_at,
151
+ e.project_id as project_id, e.agent as agent
152
+ """
153
+ result = self._run_query(query, {"id": entity_id})
154
+ if not result:
155
+ return None
156
+
157
+ r = result[0]
158
+ properties = json.loads(r["properties"]) if r["properties"] else {}
159
+
160
+ # Add project_id and agent back to properties if present
161
+ if r.get("project_id"):
162
+ properties["project_id"] = r["project_id"]
163
+ if r.get("agent"):
164
+ properties["agent"] = r["agent"]
165
+
166
+ return Entity(
167
+ id=r["id"],
168
+ name=r["name"],
169
+ entity_type=r["entity_type"],
170
+ properties=properties,
171
+ created_at=(
172
+ datetime.fromisoformat(r["created_at"])
173
+ if r["created_at"]
174
+ else datetime.now(timezone.utc)
175
+ ),
176
+ )
177
+
178
+ def get_entities(
179
+ self,
180
+ entity_type: Optional[str] = None,
181
+ project_id: Optional[str] = None,
182
+ agent: Optional[str] = None,
183
+ limit: int = 100,
184
+ ) -> List[Entity]:
185
+ """Get entities with optional filtering."""
186
+ conditions = []
187
+ params: Dict[str, Any] = {"limit": limit}
188
+
189
+ if entity_type:
190
+ conditions.append("e.entity_type = $entity_type")
191
+ params["entity_type"] = entity_type
192
+ if project_id:
193
+ conditions.append("e.project_id = $project_id")
194
+ params["project_id"] = project_id
195
+ if agent:
196
+ conditions.append("e.agent = $agent")
197
+ params["agent"] = agent
198
+
199
+ where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
200
+
201
+ query = f"""
202
+ MATCH (e:Entity)
203
+ {where_clause}
204
+ RETURN e.id as id, e.name as name, e.entity_type as entity_type,
205
+ e.properties as properties, e.created_at as created_at,
206
+ e.project_id as project_id, e.agent as agent
207
+ LIMIT $limit
208
+ """
209
+
210
+ results = self._run_query(query, params)
211
+ entities = []
212
+ for r in results:
213
+ properties = json.loads(r["properties"]) if r["properties"] else {}
214
+ if r.get("project_id"):
215
+ properties["project_id"] = r["project_id"]
216
+ if r.get("agent"):
217
+ properties["agent"] = r["agent"]
218
+
219
+ entities.append(
220
+ Entity(
221
+ id=r["id"],
222
+ name=r["name"],
223
+ entity_type=r["entity_type"],
224
+ properties=properties,
225
+ created_at=(
226
+ datetime.fromisoformat(r["created_at"])
227
+ if r["created_at"]
228
+ else datetime.now(timezone.utc)
229
+ ),
230
+ )
231
+ )
232
+ return entities
233
+
234
+ def get_relationships(self, entity_id: str) -> List[Relationship]:
235
+ """Get all relationships for an entity (both directions)."""
236
+ query = """
237
+ MATCH (e:Entity {id: $entity_id})-[r]-(other:Entity)
238
+ RETURN r.id as id,
239
+ CASE WHEN startNode(r).id = $entity_id THEN e.id ELSE other.id END as source_id,
240
+ CASE WHEN endNode(r).id = $entity_id THEN e.id ELSE other.id END as target_id,
241
+ type(r) as relation_type, r.properties as properties,
242
+ r.confidence as confidence, r.created_at as created_at
243
+ """
244
+
245
+ results = self._run_query(query, {"entity_id": entity_id})
246
+ relationships = []
247
+ for r in results:
248
+ rel_id = (
249
+ r["id"] or f"{r['source_id']}-{r['relation_type']}-{r['target_id']}"
250
+ )
251
+ relationships.append(
252
+ Relationship(
253
+ id=rel_id,
254
+ source_id=r["source_id"],
255
+ target_id=r["target_id"],
256
+ relation_type=r["relation_type"],
257
+ properties=json.loads(r["properties"]) if r["properties"] else {},
258
+ confidence=r["confidence"] or 1.0,
259
+ created_at=(
260
+ datetime.fromisoformat(r["created_at"])
261
+ if r["created_at"]
262
+ else datetime.now(timezone.utc)
263
+ ),
264
+ )
265
+ )
266
+ return relationships
267
+
268
+ def search_entities(
269
+ self,
270
+ query: str,
271
+ embedding: Optional[List[float]] = None,
272
+ top_k: int = 10,
273
+ ) -> List[Entity]:
274
+ """
275
+ Search for entities by name.
276
+
277
+ Note: Vector similarity search requires Neo4j 5.x with vector index.
278
+ Falls back to text search if embedding is provided but vector index
279
+ is not available.
280
+ """
281
+ # For now, we do text-based search
282
+ # Vector search can be added when Neo4j vector indexes are set up
283
+ cypher = """
284
+ MATCH (e:Entity)
285
+ WHERE toLower(e.name) CONTAINS toLower($query)
286
+ RETURN e.id as id, e.name as name, e.entity_type as entity_type,
287
+ e.properties as properties, e.created_at as created_at,
288
+ e.project_id as project_id, e.agent as agent
289
+ LIMIT $limit
290
+ """
291
+
292
+ results = self._run_query(cypher, {"query": query, "limit": top_k})
293
+ entities = []
294
+ for r in results:
295
+ properties = json.loads(r["properties"]) if r["properties"] else {}
296
+ if r.get("project_id"):
297
+ properties["project_id"] = r["project_id"]
298
+ if r.get("agent"):
299
+ properties["agent"] = r["agent"]
300
+
301
+ entities.append(
302
+ Entity(
303
+ id=r["id"],
304
+ name=r["name"],
305
+ entity_type=r["entity_type"],
306
+ properties=properties,
307
+ created_at=(
308
+ datetime.fromisoformat(r["created_at"])
309
+ if r["created_at"]
310
+ else datetime.now(timezone.utc)
311
+ ),
312
+ )
313
+ )
314
+ return entities
315
+
316
+ def delete_entity(self, entity_id: str) -> bool:
317
+ """Delete an entity and its relationships."""
318
+ query = """
319
+ MATCH (e:Entity {id: $id})
320
+ DETACH DELETE e
321
+ RETURN count(e) as deleted
322
+ """
323
+ result = self._run_query(query, {"id": entity_id})
324
+ return result[0]["deleted"] > 0 if result else False
325
+
326
+ def delete_relationship(self, relationship_id: str) -> bool:
327
+ """Delete a specific relationship by ID."""
328
+ query = """
329
+ MATCH ()-[r]-()
330
+ WHERE r.id = $id
331
+ DELETE r
332
+ RETURN count(r) as deleted
333
+ """
334
+ result = self._run_query(query, {"id": relationship_id})
335
+ return result[0]["deleted"] > 0 if result else False
336
+
337
+ def close(self) -> None:
338
+ """Close the Neo4j driver connection."""
339
+ if self._driver:
340
+ self._driver.close()
341
+ self._driver = None
342
+
343
+ # Additional methods for compatibility with existing GraphStore API
344
+
345
+ def find_entities(
346
+ self,
347
+ name: Optional[str] = None,
348
+ entity_type: Optional[str] = None,
349
+ limit: int = 10,
350
+ ) -> List[Entity]:
351
+ """
352
+ Find entities by name or type.
353
+
354
+ This method provides compatibility with the existing GraphStore API.
355
+ """
356
+ if name:
357
+ return self.search_entities(query=name, top_k=limit)
358
+
359
+ return self.get_entities(entity_type=entity_type, limit=limit)
360
+
361
+ def get_relationships_directional(
362
+ self,
363
+ entity_id: str,
364
+ direction: str = "both",
365
+ relation_type: Optional[str] = None,
366
+ ) -> List[Relationship]:
367
+ """
368
+ Get relationships for an entity with direction control.
369
+
370
+ This method provides compatibility with the existing GraphStore API.
371
+
372
+ Args:
373
+ entity_id: The entity ID.
374
+ direction: "outgoing", "incoming", or "both".
375
+ relation_type: Optional filter by relationship type.
376
+
377
+ Returns:
378
+ List of matching relationships.
379
+ """
380
+ if direction == "outgoing":
381
+ pattern = "(e)-[r]->(other)"
382
+ elif direction == "incoming":
383
+ pattern = "(e)<-[r]-(other)"
384
+ else:
385
+ pattern = "(e)-[r]-(other)"
386
+
387
+ type_filter = f":{relation_type}" if relation_type else ""
388
+
389
+ query = f"""
390
+ MATCH (e:Entity {{id: $entity_id}}){pattern.replace("[r]", f"[r{type_filter}]")}
391
+ RETURN r.id as id, e.id as source_id, other.id as target_id,
392
+ type(r) as relation_type, r.properties as properties,
393
+ r.confidence as confidence, r.created_at as created_at
394
+ """
395
+
396
+ results = self._run_query(query, {"entity_id": entity_id})
397
+ relationships = []
398
+ for r in results:
399
+ rel_id = (
400
+ r["id"] or f"{r['source_id']}-{r['relation_type']}-{r['target_id']}"
401
+ )
402
+ relationships.append(
403
+ Relationship(
404
+ id=rel_id,
405
+ source_id=r["source_id"],
406
+ target_id=r["target_id"],
407
+ relation_type=r["relation_type"],
408
+ properties=json.loads(r["properties"]) if r["properties"] else {},
409
+ confidence=r["confidence"] or 1.0,
410
+ created_at=(
411
+ datetime.fromisoformat(r["created_at"])
412
+ if r["created_at"]
413
+ else datetime.now(timezone.utc)
414
+ ),
415
+ )
416
+ )
417
+ return relationships