docugraph 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.
Files changed (37) hide show
  1. docugraph/__init__.py +24 -0
  2. docugraph/_version.py +28 -0
  3. docugraph/agents/__init__.py +27 -0
  4. docugraph/agents/coding_agent.py +532 -0
  5. docugraph/agents/tools/__init__.py +62 -0
  6. docugraph/agents/tools/graph_query.py +203 -0
  7. docugraph/agents/tools/memory_ops.py +305 -0
  8. docugraph/agents/tools/search_code.py +305 -0
  9. docugraph/agents/tools/search_docs.py +148 -0
  10. docugraph/core/__init__.py +34 -0
  11. docugraph/core/config.py +183 -0
  12. docugraph/core/embeddings.py +394 -0
  13. docugraph/core/llm.py +892 -0
  14. docugraph/core/models.py +145 -0
  15. docugraph/ingestion/__init__.py +64 -0
  16. docugraph/ingestion/api_docs.py +646 -0
  17. docugraph/ingestion/chunker.py +382 -0
  18. docugraph/ingestion/crawler.py +305 -0
  19. docugraph/ingestion/git_indexer.py +556 -0
  20. docugraph/ingestion/local_files.py +516 -0
  21. docugraph/interfaces/__init__.py +1 -0
  22. docugraph/interfaces/cli.py +983 -0
  23. docugraph/interfaces/mcp_server.py +918 -0
  24. docugraph/retrieval/__init__.py +54 -0
  25. docugraph/retrieval/context_builder.py +522 -0
  26. docugraph/retrieval/hybrid_search.py +534 -0
  27. docugraph/retrieval/reranker.py +532 -0
  28. docugraph/storage/__init__.py +14 -0
  29. docugraph/storage/graph_store.py +602 -0
  30. docugraph/storage/memory_store.py +529 -0
  31. docugraph/storage/vector_store.py +367 -0
  32. docugraph-0.1.0.dist-info/METADATA +354 -0
  33. docugraph-0.1.0.dist-info/RECORD +37 -0
  34. docugraph-0.1.0.dist-info/WHEEL +4 -0
  35. docugraph-0.1.0.dist-info/entry_points.txt +3 -0
  36. docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
  37. docugraph-0.1.0.dist-info/licenses/NOTICE +10 -0
@@ -0,0 +1,602 @@
1
+ """Graph store backed by Graphiti + Kuzu for knowledge graph operations.
2
+
3
+ Supports LLM-agnostic configuration with local-first priority:
4
+ - Ollama (local, default if available)
5
+ - OpenAI (cloud)
6
+ - Anthropic (cloud)
7
+
8
+ Embeddings reuse the docugraph embedder (fastembed by default) via
9
+ GraphitiEmbedderAdapter, so graph ingestion needs no embedding service —
10
+ only the entity-extraction LLM (Graphiti design).
11
+
12
+ Graphiti also wants a cross-encoder for search reranking; its default
13
+ (OpenAIRerankerClient) requires an OpenAI key even in an Ollama-only setup,
14
+ and its only bundled offline alternative (BGERerankerClient) pulls in
15
+ sentence-transformers/torch. We supply NoopCrossEncoder so graph ingestion
16
+ works offline with no key and no implicit large download — consistent with
17
+ docugraph's opt-in reranking philosophy.
18
+
19
+ Uses the standalone LLM abstraction from docugraph.core.llm.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import json
26
+ import os
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from docugraph.core.config import EmbeddingProvider, get_config
32
+ from docugraph.core.embeddings import (
33
+ EmbeddingProviderBase,
34
+ OllamaEmbedder,
35
+ OpenAIEmbedder,
36
+ get_embedder,
37
+ )
38
+ from docugraph.core.llm import get_graphiti_llm_client
39
+ from docugraph.core.models import Entity, EntityType, RelationshipType
40
+
41
+ # Graphiti 0.30 validates injected clients with pydantic is_instance_of, so
42
+ # our adapters must actually subclass its base classes (duck-typing no longer
43
+ # passes). graphiti-core is a hard dependency; the guard only keeps the module
44
+ # importable if it is somehow absent, matching the lazy-degradation elsewhere.
45
+ try:
46
+ from graphiti_core.cross_encoder.client import CrossEncoderClient as _CrossEncoderBase
47
+ from graphiti_core.embedder.client import EmbedderClient as _EmbedderBase
48
+ except ImportError: # pragma: no cover - graphiti-core is a hard dependency
49
+ _EmbedderBase = object # type: ignore[assignment,misc]
50
+ _CrossEncoderBase = object # type: ignore[assignment,misc]
51
+
52
+
53
+ class NoopCrossEncoder(_CrossEncoderBase):
54
+ """Graphiti CrossEncoderClient that preserves input order (no reranking).
55
+
56
+ Graphiti's default cross-encoder (OpenAIRerankerClient) needs an OpenAI
57
+ key, and its offline BGERerankerClient needs sentence-transformers/torch.
58
+ Neither fits a torch-free, key-optional setup, so this no-op keeps graph
59
+ search working: it returns passages in their original order with a simple
60
+ descending pseudo-score. Retrieval relevance still comes from the
61
+ embedding + graph-distance search; only the extra rerank step is skipped.
62
+ """
63
+
64
+ async def rank(
65
+ self,
66
+ query: str, # noqa: ARG002 -- interface-mandated; no-op ignores the query
67
+ passages: list[str],
68
+ ) -> list[tuple[str, float]]:
69
+ n = len(passages)
70
+ return [(passage, float(n - i)) for i, passage in enumerate(passages)]
71
+
72
+
73
+ class GraphitiEmbedderAdapter(_EmbedderBase):
74
+ """Graphiti EmbedderClient backed by a docugraph embedder.
75
+
76
+ Lets the doc knowledge graph reuse the same embedding backend as the
77
+ vector store (fastembed by default) instead of requiring a separate
78
+ Ollama/OpenAI embedding service. Implements Graphiti's async
79
+ ``create``/``create_batch`` interface; the sync docugraph embedder runs
80
+ in a worker thread.
81
+ """
82
+
83
+ def __init__(self, embedder: EmbeddingProviderBase | None = None):
84
+ self._embedder = embedder or get_embedder()
85
+
86
+ async def create(self, input_data: Any) -> list[float]:
87
+ """Embed a single text (Graphiti uses this for entity names)."""
88
+ # Graphiti may hand us a str or a list; normalize to the query path.
89
+ text = input_data if isinstance(input_data, str) else " ".join(map(str, input_data))
90
+ return await asyncio.to_thread(self._embedder.embed_query, text)
91
+
92
+ async def create_batch(self, input_data_list: list[str]) -> list[list[float]]:
93
+ """Embed a batch of texts."""
94
+ if not input_data_list:
95
+ return []
96
+ return await asyncio.to_thread(self._embedder.embed, input_data_list)
97
+
98
+
99
+ def _get_embedder() -> Any:
100
+ """Create an embedder based on configuration.
101
+
102
+ Ollama/OpenAI use Graphiti's own service-backed embedder; every other
103
+ provider (fastembed default, sentence-transformers, cohere, auto)
104
+ reuses the docugraph embedder via GraphitiEmbedderAdapter.
105
+
106
+ Returns:
107
+ Graphiti-compatible embedder
108
+ """
109
+ from graphiti_core.embedder.openai import (
110
+ OpenAIEmbedder as _GraphitiOpenAIEmbedder,
111
+ )
112
+ from graphiti_core.embedder.openai import OpenAIEmbedderConfig
113
+
114
+ emb_config = get_config().embeddings
115
+
116
+ # Both branches ask the docugraph embedder for the model name and width
117
+ # rather than restating them here. The widths used to be literals -- 768 for
118
+ # Ollama, 1536 for OpenAI -- which is the same defect as the tables in
119
+ # core/embeddings.py: a second place holding a fact the model itself reports,
120
+ # right only for the model whoever wrote it had in mind.
121
+ if emb_config.provider == EmbeddingProvider.OLLAMA:
122
+ local = OllamaEmbedder(model_name=emb_config.model)
123
+ embedder_config = OpenAIEmbedderConfig(
124
+ api_key="ollama",
125
+ embedding_model=local.model_name,
126
+ embedding_dim=emb_config.dimensions or local.dimensions,
127
+ base_url="http://localhost:11434/v1",
128
+ )
129
+ return _GraphitiOpenAIEmbedder(config=embedder_config)
130
+
131
+ elif emb_config.provider == EmbeddingProvider.OPENAI:
132
+ api_key = os.environ.get("OPENAI_API_KEY")
133
+ if not api_key:
134
+ raise RuntimeError("OPENAI_API_KEY required for OpenAI embeddings")
135
+
136
+ cloud = OpenAIEmbedder(model_name=emb_config.model)
137
+ embedder_config = OpenAIEmbedderConfig(
138
+ api_key=api_key,
139
+ embedding_model=cloud.model_name,
140
+ embedding_dim=emb_config.dimensions or cloud.dimensions,
141
+ )
142
+ return _GraphitiOpenAIEmbedder(config=embedder_config)
143
+
144
+ else:
145
+ # fastembed / sentence-transformers / cohere / auto: reuse the
146
+ # docugraph embedder — no embedding service required.
147
+ return GraphitiEmbedderAdapter()
148
+
149
+
150
+ class GraphStore:
151
+ """Knowledge graph store backed by Graphiti with Kuzu driver.
152
+
153
+ Provides entity and relationship storage with temporal tracking,
154
+ semantic search, and graph traversal capabilities.
155
+
156
+ LLM-agnostic: Works with Ollama (local), OpenAI, or Anthropic.
157
+ """
158
+
159
+ def __init__(self, db_path: Path | str | None = None) -> None:
160
+ """Initialize the graph store.
161
+
162
+ Args:
163
+ db_path: Path to the Kuzu database directory. If None, uses config default.
164
+ """
165
+ if db_path is None:
166
+ config = get_config()
167
+ db_path = config.storage.data_dir / "graph"
168
+
169
+ self._db_path = Path(db_path)
170
+ self._db_path.mkdir(parents=True, exist_ok=True)
171
+
172
+ self._graphiti: Any | None = None
173
+ self._driver: Any | None = None
174
+ self._initialized = False
175
+
176
+ async def _ensure_initialized(self) -> Any:
177
+ """Lazily initialize Graphiti; returns the ready Graphiti instance."""
178
+ if self._initialized:
179
+ return self._graphiti
180
+
181
+ try:
182
+ from graphiti_core import Graphiti
183
+ from graphiti_core.driver.kuzu_driver import KuzuDriver
184
+ except ImportError as e:
185
+ raise ImportError(
186
+ "graphiti-core[kuzu] is required for GraphStore. "
187
+ "Install with: pip install 'graphiti-core[kuzu]'"
188
+ ) from e
189
+
190
+ # Resolve the LLM and embedder before opening the database.
191
+ # get_llm_provider checks that a provider is actually reachable and has
192
+ # the model, so a failure here costs nothing on disk: no Kùzu database
193
+ # file, no indices, no driver handle left open. (The `graph/` directory
194
+ # itself is created by __init__, before this runs.)
195
+ llm_client = get_graphiti_llm_client()
196
+ embedder = _get_embedder()
197
+
198
+ # Create Kuzu driver
199
+ kuzu_db_path = str(self._db_path / "graphiti.kuzu")
200
+ self._driver = KuzuDriver(db=kuzu_db_path)
201
+ # graphiti-core 0.30's deprecated Kuzu backend never sets `_database`,
202
+ # but Graphiti._resolve_request_scope reads it for any non-default
203
+ # group_id (AttributeError otherwise). Kuzu's clone() is a no-op, so
204
+ # seeding it with the Kuzu default group id is a safe workaround.
205
+ if not hasattr(self._driver, "_database"):
206
+ self._driver._database = ""
207
+
208
+ # Initialize Graphiti with custom clients. cross_encoder is set to a
209
+ # no-op so ingestion doesn't require an OpenAI key (Graphiti's default
210
+ # reranker) or sentence-transformers/torch (its BGE reranker).
211
+ self._graphiti = Graphiti(
212
+ graph_driver=self._driver,
213
+ llm_client=llm_client,
214
+ embedder=embedder,
215
+ cross_encoder=NoopCrossEncoder(),
216
+ )
217
+
218
+ # Build indices for optimal performance
219
+ try:
220
+ await self._graphiti.build_indices_and_constraints()
221
+ except Exception as e:
222
+ import logging
223
+
224
+ logging.debug(f"Could not build indices (may already exist): {e}")
225
+
226
+ # graphiti-core 0.30's deprecated Kuzu backend does not actually create
227
+ # the full-text search indexes (build_indices_and_constraints returns
228
+ # without error but SHOW_INDEXES is empty), so the edge-dedup search
229
+ # during add_episode fails with "Table RelatesToNode_ doesn't have an
230
+ # index with name edge_name_and_fact". Create them explicitly here.
231
+ await self._ensure_fts_indexes()
232
+
233
+ self._initialized = True
234
+ return self._graphiti
235
+
236
+ # Kuzu FTS indexes Graphiti's search path expects (table, index, columns).
237
+ _KUZU_FTS_INDEXES = (
238
+ ("Episodic", "episode_content", ("content", "source", "source_description")),
239
+ ("Entity", "node_name_and_summary", ("name", "summary")),
240
+ ("Community", "community_name", ("name",)),
241
+ ("RelatesToNode_", "edge_name_and_fact", ("name", "fact")),
242
+ )
243
+
244
+ async def _ensure_fts_indexes(self) -> None:
245
+ """Create any missing Kuzu full-text indexes.
246
+
247
+ Checks SHOW_INDEXES first and only creates the ones that are absent, so
248
+ re-initializing an existing graph produces no error-log noise from
249
+ Graphiti's driver (which logs before raising on "already exists").
250
+ """
251
+ import logging
252
+
253
+ driver = self._driver
254
+ assert driver is not None # noqa: S101 -- set by _ensure_initialized before this runs
255
+
256
+ existing: set[str] = set()
257
+ try:
258
+ records, _, _ = await driver.execute_query("CALL SHOW_INDEXES() RETURN *;")
259
+ for row in records or []:
260
+ name = row.get("index_name")
261
+ if name:
262
+ existing.add(str(name))
263
+ except Exception as e:
264
+ logging.debug(f"Could not enumerate existing indexes: {e}")
265
+
266
+ for table, index_name, columns in self._KUZU_FTS_INDEXES:
267
+ if index_name in existing:
268
+ continue
269
+ col_list = ", ".join(f"'{c}'" for c in columns)
270
+ query = f"CALL CREATE_FTS_INDEX('{table}', '{index_name}', [{col_list}]);"
271
+ try:
272
+ await driver.execute_query(query)
273
+ except Exception as e:
274
+ # "already exists" is a benign race; anything else is non-fatal
275
+ # (search degrades, ingestion still works).
276
+ if "already exists" not in str(e).lower():
277
+ logging.debug(f"Could not create FTS index {index_name}: {e}")
278
+
279
+ async def close(self) -> None:
280
+ """Close the graph store and release resources."""
281
+ if self._graphiti is not None:
282
+ await self._graphiti.close()
283
+ self._graphiti = None
284
+ self._driver = None
285
+ self._initialized = False
286
+
287
+ async def add_episode(
288
+ self,
289
+ name: str,
290
+ content: str,
291
+ source_type: str = "text",
292
+ source_description: str = "",
293
+ group_id: str = "default",
294
+ reference_time: datetime | None = None,
295
+ ) -> dict[str, Any]:
296
+ """Add an episode to extract entities and relationships.
297
+
298
+ Graphiti automatically extracts entities and relationships from the content
299
+ using the configured LLM (Ollama, OpenAI, or Anthropic).
300
+
301
+ Args:
302
+ name: Episode identifier/name
303
+ content: Text content to process
304
+ source_type: Type of source ("text" or "json")
305
+ source_description: Description of the data source
306
+ group_id: Group ID for scoping searches
307
+ reference_time: When this information became valid
308
+
309
+ Returns:
310
+ Dict with episode info, extracted entities, and relationships
311
+ """
312
+ graphiti = await self._ensure_initialized()
313
+
314
+ from graphiti_core.nodes import EpisodeType
315
+
316
+ episode_type = EpisodeType.json if source_type == "json" else EpisodeType.text
317
+
318
+ if reference_time is None:
319
+ reference_time = datetime.now(UTC)
320
+
321
+ result = await graphiti.add_episode(
322
+ name=name,
323
+ episode_body=content,
324
+ source=episode_type,
325
+ source_description=source_description,
326
+ reference_time=reference_time,
327
+ group_id=group_id,
328
+ )
329
+
330
+ return {
331
+ "episode_uuid": result.episode.uuid,
332
+ "entities_count": len(result.nodes),
333
+ "relationships_count": len(result.edges),
334
+ "entities": [
335
+ {"uuid": n.uuid, "name": n.name, "labels": getattr(n, "labels", [])}
336
+ for n in result.nodes
337
+ ],
338
+ "relationships": [{"uuid": e.uuid, "fact": e.fact} for e in result.edges],
339
+ }
340
+
341
+ async def add_entity(self, entity: Entity) -> str:
342
+ """Add a single entity to the graph.
343
+
344
+ Note: For bulk entity extraction, use add_episode() which leverages
345
+ the configured LLM for extraction. This method is for manual entity addition.
346
+
347
+ Args:
348
+ entity: Entity to add
349
+
350
+ Returns:
351
+ Entity UUID from Graphiti
352
+ """
353
+ graphiti = await self._ensure_initialized()
354
+
355
+ # Create an episode with structured data representing the entity
356
+ entity_data = {
357
+ "entity_id": entity.id,
358
+ "name": entity.name,
359
+ "type": entity.entity_type.value,
360
+ "description": entity.description or "",
361
+ "properties": entity.properties,
362
+ }
363
+
364
+ result = await graphiti.add_episode(
365
+ name=f"entity_{entity.id}",
366
+ episode_body=json.dumps(entity_data),
367
+ source="json",
368
+ source_description=f"Manual entity: {entity.entity_type.value}",
369
+ reference_time=entity.valid_from,
370
+ group_id="entities",
371
+ )
372
+
373
+ episode_uuid: str = result.episode.uuid
374
+ return episode_uuid
375
+
376
+ async def add_entities(self, entities: list[Entity]) -> int:
377
+ """Add multiple entities to the graph.
378
+
379
+ Args:
380
+ entities: List of entities to add
381
+
382
+ Returns:
383
+ Number of entities added
384
+ """
385
+ count = 0
386
+ for entity in entities:
387
+ await self.add_entity(entity)
388
+ count += 1
389
+ return count
390
+
391
+ async def search(
392
+ self,
393
+ query: str,
394
+ group_ids: list[str] | None = None,
395
+ num_results: int = 10,
396
+ entity_types: list[EntityType] | None = None,
397
+ ) -> list[dict[str, Any]]:
398
+ """Search the knowledge graph using hybrid semantic + keyword search.
399
+
400
+ Args:
401
+ query: Search query
402
+ group_ids: Optional list of group IDs to scope the search
403
+ num_results: Maximum number of results
404
+ entity_types: Optional filter by entity types
405
+
406
+ Returns:
407
+ List of matching facts/relationships with metadata
408
+ """
409
+ graphiti = await self._ensure_initialized()
410
+
411
+ search_kwargs: dict[str, Any] = {
412
+ "query": query,
413
+ "num_results": num_results,
414
+ }
415
+
416
+ if group_ids:
417
+ search_kwargs["group_ids"] = group_ids
418
+
419
+ # Apply entity type filter if specified
420
+ if entity_types:
421
+ from graphiti_core.search.search_filters import SearchFilters
422
+
423
+ entity_labels = [et.value for et in entity_types]
424
+ search_kwargs["search_filter"] = SearchFilters(entity_labels=entity_labels)
425
+
426
+ results = await graphiti.search(**search_kwargs)
427
+
428
+ return [
429
+ {
430
+ "uuid": edge.uuid,
431
+ "fact": edge.fact,
432
+ "source_node_uuid": edge.source_node_uuid,
433
+ "target_node_uuid": edge.target_node_uuid,
434
+ "valid_at": edge.valid_at.isoformat() if edge.valid_at else None,
435
+ "invalid_at": edge.invalid_at.isoformat() if edge.invalid_at else None,
436
+ "created_at": edge.created_at.isoformat() if edge.created_at else None,
437
+ "episodes": list(edge.episodes) if hasattr(edge, "episodes") else [],
438
+ }
439
+ for edge in results
440
+ ]
441
+
442
+ async def search_entities(
443
+ self,
444
+ query: str,
445
+ group_ids: list[str] | None = None,
446
+ limit: int = 10,
447
+ ) -> list[Entity]:
448
+ """Search for entities matching the query.
449
+
450
+ Args:
451
+ query: Search query
452
+ group_ids: Optional group IDs to scope search
453
+ limit: Maximum results
454
+
455
+ Returns:
456
+ List of matching Entity objects
457
+ """
458
+ # Search returns edges/facts, extract unique entities
459
+ results = await self.search(query, group_ids=group_ids, num_results=limit * 2)
460
+
461
+ # Convert to Entity objects (simplified - Graphiti returns edges not nodes directly)
462
+ entities: list[Entity] = []
463
+ seen_uuids: set[str] = set()
464
+
465
+ for r in results:
466
+ for node_uuid in [r.get("source_node_uuid"), r.get("target_node_uuid")]:
467
+ if node_uuid and node_uuid not in seen_uuids:
468
+ seen_uuids.add(node_uuid)
469
+ # Create a basic entity from the fact
470
+ entities.append(
471
+ Entity(
472
+ id=node_uuid,
473
+ name=r.get("fact", "")[:100], # Use fact as name placeholder
474
+ entity_type=EntityType.CONCEPT,
475
+ description=r.get("fact"),
476
+ )
477
+ )
478
+ if len(entities) >= limit:
479
+ return entities
480
+
481
+ return entities
482
+
483
+ async def get_related(
484
+ self,
485
+ entity_uuid: str,
486
+ relationship_types: list[RelationshipType] | None = None,
487
+ depth: int = 1,
488
+ limit: int = 20,
489
+ ) -> list[dict[str, Any]]:
490
+ """Get entities related to a given entity.
491
+
492
+ Uses Graphiti's graph distance reranking to find related entities.
493
+
494
+ Args:
495
+ entity_uuid: UUID of the center entity
496
+ relationship_types: Optional filter by relationship types
497
+ depth: How deep to look — widens the result pool per hop
498
+ limit: Maximum results
499
+
500
+ Returns:
501
+ List of related facts/edges
502
+ """
503
+ graphiti = await self._ensure_initialized()
504
+
505
+ # Use center_node_uuid for graph-distance based retrieval; deeper
506
+ # traversals pull a proportionally wider pool of connected facts.
507
+ results = await graphiti.search(
508
+ query="*", # Wildcard to get all connected
509
+ center_node_uuid=entity_uuid,
510
+ num_results=limit * max(1, depth),
511
+ )
512
+
513
+ facts = [
514
+ {
515
+ "uuid": edge.uuid,
516
+ "fact": edge.fact,
517
+ "source_node_uuid": edge.source_node_uuid,
518
+ "target_node_uuid": edge.target_node_uuid,
519
+ }
520
+ for edge in results
521
+ ]
522
+
523
+ # Post-filter by relationship type (edge facts carry the type as text).
524
+ if relationship_types:
525
+ wanted = {rt.value.lower().replace("_", " ") for rt in relationship_types}
526
+ facts = [f for f in facts if any(w in f["fact"].lower() for w in wanted)]
527
+
528
+ return facts
529
+
530
+ async def delete_episode(self, episode_uuid: str) -> bool:
531
+ """Delete an episode and its associated data.
532
+
533
+ Args:
534
+ episode_uuid: UUID of the episode to delete
535
+
536
+ Returns:
537
+ True if deleted successfully
538
+ """
539
+ await self._ensure_initialized()
540
+
541
+ # Graphiti doesn't have direct episode deletion in the public API
542
+ raise NotImplementedError(
543
+ f"Episode deletion ({episode_uuid}) not yet supported. "
544
+ "Use clear() to reset the entire graph."
545
+ )
546
+
547
+ async def clear(self) -> None:
548
+ """Clear all data from the graph store."""
549
+ await self.close()
550
+
551
+ import shutil
552
+
553
+ if self._db_path.exists():
554
+ shutil.rmtree(self._db_path)
555
+ self._db_path.mkdir(parents=True, exist_ok=True)
556
+
557
+ async def count(self) -> dict[str, int]:
558
+ """Get counts of entities and relationships.
559
+
560
+ Returns:
561
+ Dict with entity_count and relationship_count
562
+ """
563
+ await self._ensure_initialized()
564
+
565
+ # Graphiti doesn't expose direct count methods
566
+ return {
567
+ "entity_count": -1, # Not directly available
568
+ "relationship_count": -1,
569
+ }
570
+
571
+ def __enter__(self) -> GraphStore:
572
+ """Context manager entry."""
573
+ return self
574
+
575
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
576
+ """Context manager exit - close the store.
577
+
578
+ The synchronous form of closing. Code inside a running loop uses
579
+ ``async with``, whose exit awaits ``close`` directly.
580
+ """
581
+ asyncio.run(self.close())
582
+
583
+ async def __aenter__(self) -> GraphStore:
584
+ """Async context manager entry."""
585
+ await self._ensure_initialized()
586
+ return self
587
+
588
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
589
+ """Async context manager exit."""
590
+ await self.close()
591
+
592
+
593
+ def get_graph_store(db_path: Path | str | None = None) -> GraphStore:
594
+ """Factory function to get a GraphStore instance.
595
+
596
+ Args:
597
+ db_path: Optional database path override
598
+
599
+ Returns:
600
+ GraphStore instance
601
+ """
602
+ return GraphStore(db_path=db_path)