minder-cli 0.2.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 (132) hide show
  1. minder/__init__.py +12 -0
  2. minder/api/routers/prompts.py +177 -0
  3. minder/application/__init__.py +1 -0
  4. minder/application/admin/__init__.py +11 -0
  5. minder/application/admin/dto.py +453 -0
  6. minder/application/admin/jobs.py +327 -0
  7. minder/application/admin/use_cases.py +1895 -0
  8. minder/auth/__init__.py +12 -0
  9. minder/auth/context.py +26 -0
  10. minder/auth/middleware.py +70 -0
  11. minder/auth/principal.py +59 -0
  12. minder/auth/rate_limiter.py +89 -0
  13. minder/auth/rbac.py +60 -0
  14. minder/auth/service.py +541 -0
  15. minder/bootstrap/__init__.py +9 -0
  16. minder/bootstrap/providers.py +109 -0
  17. minder/bootstrap/transport.py +807 -0
  18. minder/cache/__init__.py +10 -0
  19. minder/cache/providers.py +140 -0
  20. minder/chunking/__init__.py +4 -0
  21. minder/chunking/code_splitter.py +184 -0
  22. minder/chunking/splitter.py +136 -0
  23. minder/cli.py +1542 -0
  24. minder/config.py +179 -0
  25. minder/continuity.py +363 -0
  26. minder/dev.py +160 -0
  27. minder/embedding/__init__.py +9 -0
  28. minder/embedding/base.py +7 -0
  29. minder/embedding/local.py +65 -0
  30. minder/embedding/openai.py +7 -0
  31. minder/graph/__init__.py +11 -0
  32. minder/graph/edges.py +13 -0
  33. minder/graph/executor.py +127 -0
  34. minder/graph/graph.py +263 -0
  35. minder/graph/nodes/__init__.py +27 -0
  36. minder/graph/nodes/evaluator.py +21 -0
  37. minder/graph/nodes/guard.py +64 -0
  38. minder/graph/nodes/llm.py +59 -0
  39. minder/graph/nodes/planning.py +30 -0
  40. minder/graph/nodes/reasoning.py +87 -0
  41. minder/graph/nodes/reranker.py +141 -0
  42. minder/graph/nodes/retriever.py +86 -0
  43. minder/graph/nodes/verification.py +230 -0
  44. minder/graph/nodes/workflow_planner.py +250 -0
  45. minder/graph/runtime.py +15 -0
  46. minder/graph/state.py +26 -0
  47. minder/llm/__init__.py +5 -0
  48. minder/llm/base.py +14 -0
  49. minder/llm/local.py +381 -0
  50. minder/llm/openai.py +89 -0
  51. minder/models/__init__.py +109 -0
  52. minder/models/base.py +10 -0
  53. minder/models/client.py +137 -0
  54. minder/models/document.py +34 -0
  55. minder/models/error.py +32 -0
  56. minder/models/graph.py +114 -0
  57. minder/models/history.py +32 -0
  58. minder/models/job.py +62 -0
  59. minder/models/prompt.py +41 -0
  60. minder/models/repository.py +62 -0
  61. minder/models/rule.py +68 -0
  62. minder/models/session.py +51 -0
  63. minder/models/skill.py +52 -0
  64. minder/models/user.py +41 -0
  65. minder/models/workflow.py +35 -0
  66. minder/observability/__init__.py +57 -0
  67. minder/observability/audit.py +243 -0
  68. minder/observability/logging.py +253 -0
  69. minder/observability/metrics.py +448 -0
  70. minder/observability/tracing.py +215 -0
  71. minder/presentation/__init__.py +1 -0
  72. minder/presentation/http/__init__.py +1 -0
  73. minder/presentation/http/admin/__init__.py +3 -0
  74. minder/presentation/http/admin/api.py +1309 -0
  75. minder/presentation/http/admin/context.py +94 -0
  76. minder/presentation/http/admin/dashboard.py +111 -0
  77. minder/presentation/http/admin/jobs.py +208 -0
  78. minder/presentation/http/admin/memories.py +185 -0
  79. minder/presentation/http/admin/prompts.py +219 -0
  80. minder/presentation/http/admin/routes.py +127 -0
  81. minder/presentation/http/admin/runtime.py +650 -0
  82. minder/presentation/http/admin/search.py +368 -0
  83. minder/presentation/http/admin/skills.py +230 -0
  84. minder/prompts/__init__.py +646 -0
  85. minder/prompts/formatter.py +142 -0
  86. minder/resources/__init__.py +318 -0
  87. minder/retrieval/__init__.py +5 -0
  88. minder/retrieval/hybrid.py +178 -0
  89. minder/retrieval/mmr.py +116 -0
  90. minder/retrieval/multi_hop.py +115 -0
  91. minder/runtime.py +15 -0
  92. minder/server.py +145 -0
  93. minder/store/__init__.py +64 -0
  94. minder/store/document.py +115 -0
  95. minder/store/error.py +82 -0
  96. minder/store/feedback.py +114 -0
  97. minder/store/graph.py +588 -0
  98. minder/store/history.py +57 -0
  99. minder/store/interfaces.py +512 -0
  100. minder/store/milvus/__init__.py +11 -0
  101. minder/store/milvus/client.py +26 -0
  102. minder/store/milvus/collections.py +15 -0
  103. minder/store/milvus/vector_store.py +232 -0
  104. minder/store/mongodb/__init__.py +11 -0
  105. minder/store/mongodb/client.py +49 -0
  106. minder/store/mongodb/indexes.py +90 -0
  107. minder/store/mongodb/operational_store.py +993 -0
  108. minder/store/relational.py +1087 -0
  109. minder/store/repo_state.py +58 -0
  110. minder/store/rule.py +93 -0
  111. minder/store/vector.py +79 -0
  112. minder/tools/__init__.py +47 -0
  113. minder/tools/auth.py +94 -0
  114. minder/tools/graph.py +839 -0
  115. minder/tools/ingest.py +353 -0
  116. minder/tools/memory.py +381 -0
  117. minder/tools/query.py +307 -0
  118. minder/tools/registry.py +269 -0
  119. minder/tools/repo_scanner.py +1266 -0
  120. minder/tools/search.py +15 -0
  121. minder/tools/session.py +316 -0
  122. minder/tools/skills.py +899 -0
  123. minder/tools/workflow.py +215 -0
  124. minder/transport/__init__.py +4 -0
  125. minder/transport/base.py +286 -0
  126. minder/transport/sse.py +252 -0
  127. minder/transport/stdio.py +29 -0
  128. minder_cli-0.2.0.dist-info/METADATA +318 -0
  129. minder_cli-0.2.0.dist-info/RECORD +132 -0
  130. minder_cli-0.2.0.dist-info/WHEEL +4 -0
  131. minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
  132. minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,114 @@
1
+ """
2
+ FeedbackStore — async SQLAlchemy CRUD for the Feedback domain model.
3
+
4
+ Supports per-entity listing and rating aggregation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from contextlib import asynccontextmanager
11
+ from typing import AsyncGenerator
12
+
13
+ from sqlalchemy import delete, func, select, update
14
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
15
+
16
+ from minder.models import Base, Feedback
17
+
18
+
19
+ class FeedbackStore:
20
+ """Async store for :class:`~minder.models.Feedback` entities."""
21
+
22
+ def __init__(self, db_url: str, echo: bool = False) -> None:
23
+ self._engine = create_async_engine(db_url, echo=echo)
24
+ self._session_factory = async_sessionmaker(
25
+ self._engine,
26
+ expire_on_commit=False,
27
+ class_=AsyncSession,
28
+ )
29
+
30
+ # ------------------------------------------------------------------
31
+ # Lifecycle
32
+ # ------------------------------------------------------------------
33
+
34
+ async def init_db(self) -> None:
35
+ async with self._engine.begin() as conn:
36
+ await conn.run_sync(Base.metadata.create_all)
37
+
38
+ async def dispose(self) -> None:
39
+ await self._engine.dispose()
40
+
41
+ @asynccontextmanager
42
+ async def _session(self) -> AsyncGenerator[AsyncSession, None]:
43
+ async with self._session_factory() as sess:
44
+ try:
45
+ yield sess
46
+ await sess.commit()
47
+ except Exception:
48
+ await sess.rollback()
49
+ raise
50
+
51
+ # ------------------------------------------------------------------
52
+ # CRUD
53
+ # ------------------------------------------------------------------
54
+
55
+ async def create_feedback(self, **kwargs) -> Feedback:
56
+ async with self._session() as sess:
57
+ fb = Feedback(**kwargs)
58
+ sess.add(fb)
59
+ await sess.flush()
60
+ await sess.refresh(fb)
61
+ return fb
62
+
63
+ async def get_feedback_by_id(self, feedback_id: uuid.UUID) -> Feedback | None:
64
+ async with self._session() as sess:
65
+ result = await sess.execute(
66
+ select(Feedback).where(Feedback.id == feedback_id)
67
+ )
68
+ return result.scalar_one_or_none()
69
+
70
+ async def list_feedback(self) -> list[Feedback]:
71
+ async with self._session() as sess:
72
+ result = await sess.execute(select(Feedback))
73
+ return list(result.scalars().all())
74
+
75
+ async def list_by_entity(
76
+ self, entity_type: str, entity_id: uuid.UUID
77
+ ) -> list[Feedback]:
78
+ """Return all feedback for a specific entity."""
79
+ async with self._session() as sess:
80
+ result = await sess.execute(
81
+ select(Feedback).where(
82
+ Feedback.entity_type == entity_type,
83
+ Feedback.entity_id == entity_id,
84
+ )
85
+ )
86
+ return list(result.scalars().all())
87
+
88
+ async def average_rating(self, entity_id: uuid.UUID) -> float | None:
89
+ """
90
+ Return the average rating across all feedback for ``entity_id``,
91
+ or ``None`` if no feedback exists.
92
+ """
93
+ async with self._session() as sess:
94
+ result = await sess.execute(
95
+ select(func.avg(Feedback.rating)).where(
96
+ Feedback.entity_id == entity_id
97
+ )
98
+ )
99
+ avg = result.scalar_one_or_none()
100
+ return float(avg) if avg is not None else None
101
+
102
+ async def update_feedback(self, feedback_id: uuid.UUID, **kwargs) -> Feedback | None:
103
+ async with self._session() as sess:
104
+ await sess.execute(
105
+ update(Feedback).where(Feedback.id == feedback_id).values(**kwargs)
106
+ )
107
+ result = await sess.execute(
108
+ select(Feedback).where(Feedback.id == feedback_id)
109
+ )
110
+ return result.scalar_one_or_none()
111
+
112
+ async def delete_feedback(self, feedback_id: uuid.UUID) -> None:
113
+ async with self._session() as sess:
114
+ await sess.execute(delete(Feedback).where(Feedback.id == feedback_id))
minder/store/graph.py ADDED
@@ -0,0 +1,588 @@
1
+ """
2
+ KnowledgeGraphStore — SQLAlchemy-backed graph store for module/service/owner
3
+ relationships.
4
+
5
+ Nodes represent named entities (module, file, service, owner).
6
+ Edges represent directed relationships (depends_on, imports, calls, owns).
7
+
8
+ v2 adds repo_id + branch columns so nodes from different repos/branches
9
+ never collide. KnowledgeGraphStore.init_db() runs _migrate_graph_v2()
10
+ automatically on first boot when the columns are absent.
11
+
12
+ Backed by SQLite (dev) or PostgreSQL (prod) via the shared async engine.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import uuid
19
+ from collections import deque
20
+ from contextlib import asynccontextmanager
21
+ from typing import Any, AsyncGenerator
22
+
23
+ from sqlalchemy import delete, select, text, update
24
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
25
+
26
+ from minder.models import Base, GraphEdge, GraphNode
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class KnowledgeGraphStore:
32
+ """Async graph store. Supports node/edge CRUD + BFS traversal."""
33
+
34
+ def __init__(self, db_url: str, echo: bool = False) -> None:
35
+ self._engine = create_async_engine(db_url, echo=echo)
36
+ self._session_factory = async_sessionmaker(
37
+ self._engine,
38
+ expire_on_commit=False,
39
+ class_=AsyncSession,
40
+ )
41
+
42
+ # ------------------------------------------------------------------
43
+ # Lifecycle
44
+ # ------------------------------------------------------------------
45
+
46
+ async def init_db(self) -> None:
47
+ """Create graph tables (idempotent) then run v2 migration if needed."""
48
+ async with self._engine.begin() as conn:
49
+ await conn.run_sync(Base.metadata.create_all)
50
+ await self._migrate_graph_v2()
51
+
52
+ async def dispose(self) -> None:
53
+ await self._engine.dispose()
54
+
55
+ @asynccontextmanager
56
+ async def _session(self) -> AsyncGenerator[AsyncSession, None]:
57
+ async with self._session_factory() as sess:
58
+ try:
59
+ yield sess
60
+ await sess.commit()
61
+ except Exception:
62
+ await sess.rollback()
63
+ raise
64
+
65
+ # ------------------------------------------------------------------
66
+ # v2 schema migration
67
+ # ------------------------------------------------------------------
68
+
69
+ async def _migrate_graph_v2(self) -> None:
70
+ """Add repo_id + branch columns and update unique constraints if missing."""
71
+ async with self._engine.begin() as conn:
72
+ dialect = conn.dialect.name # type: ignore[attr-defined]
73
+
74
+ if dialect == "sqlite":
75
+ await self._migrate_graph_v2_sqlite(conn)
76
+ elif dialect == "postgresql":
77
+ await self._migrate_graph_v2_postgresql(conn)
78
+ # Other dialects: no-op (create_all already built the new schema)
79
+
80
+ async def _migrate_graph_v2_sqlite(self, conn: Any) -> None:
81
+ """SQLite migration: recreate graph_nodes/graph_edges with new schema."""
82
+ result = await conn.execute(text("PRAGMA table_info(graph_nodes)"))
83
+ existing_cols = {row[1] for row in result.fetchall()}
84
+
85
+ if "repo_id" not in existing_cols:
86
+ logger.info("Migrating graph_nodes to v2 schema (SQLite)...")
87
+ # Recreate graph_nodes with new columns + new unique constraint
88
+ await conn.execute(text("""
89
+ CREATE TABLE IF NOT EXISTS graph_nodes_v2 (
90
+ id TEXT NOT NULL,
91
+ repo_id TEXT NOT NULL DEFAULT '',
92
+ branch TEXT NOT NULL DEFAULT '',
93
+ node_type TEXT NOT NULL,
94
+ name TEXT NOT NULL,
95
+ metadata JSON,
96
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
97
+ PRIMARY KEY (id),
98
+ UNIQUE (repo_id, branch, node_type, name)
99
+ )
100
+ """))
101
+ # Migrate existing data: extract repo_id/branch from JSON metadata
102
+ await conn.execute(text("""
103
+ INSERT OR IGNORE INTO graph_nodes_v2
104
+ (id, repo_id, branch, node_type, name, metadata, created_at)
105
+ SELECT
106
+ id,
107
+ COALESCE(json_extract(metadata, '$.repo_id'), ''),
108
+ COALESCE(json_extract(metadata, '$.branch'), ''),
109
+ node_type,
110
+ name,
111
+ metadata,
112
+ created_at
113
+ FROM graph_nodes
114
+ """))
115
+ await conn.execute(text("DROP TABLE graph_nodes"))
116
+ await conn.execute(text("ALTER TABLE graph_nodes_v2 RENAME TO graph_nodes"))
117
+ await conn.execute(text(
118
+ "CREATE INDEX IF NOT EXISTS ix_graph_nodes_repo_id ON graph_nodes (repo_id)"
119
+ ))
120
+ await conn.execute(text(
121
+ "CREATE INDEX IF NOT EXISTS ix_graph_nodes_branch ON graph_nodes (branch)"
122
+ ))
123
+ await conn.execute(text(
124
+ "CREATE INDEX IF NOT EXISTS ix_graph_nodes_node_type ON graph_nodes (node_type)"
125
+ ))
126
+ logger.info("graph_nodes v2 migration complete.")
127
+
128
+ # --- graph_edges ---
129
+ result = await conn.execute(text("PRAGMA table_info(graph_edges)"))
130
+ existing_edge_cols = {row[1] for row in result.fetchall()}
131
+
132
+ if "repo_id" not in existing_edge_cols:
133
+ logger.info("Migrating graph_edges to v2 schema (SQLite)...")
134
+ await conn.execute(text("""
135
+ CREATE TABLE IF NOT EXISTS graph_edges_v2 (
136
+ id TEXT NOT NULL,
137
+ repo_id TEXT NOT NULL DEFAULT '',
138
+ source_id TEXT NOT NULL,
139
+ target_id TEXT NOT NULL,
140
+ relation TEXT NOT NULL,
141
+ weight REAL DEFAULT 1.0,
142
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
143
+ PRIMARY KEY (id),
144
+ UNIQUE (repo_id, source_id, target_id, relation)
145
+ )
146
+ """))
147
+ await conn.execute(text("""
148
+ INSERT OR IGNORE INTO graph_edges_v2
149
+ (id, repo_id, source_id, target_id, relation, weight, created_at)
150
+ SELECT id, '', source_id, target_id, relation, weight, created_at
151
+ FROM graph_edges
152
+ """))
153
+ await conn.execute(text("DROP TABLE graph_edges"))
154
+ await conn.execute(text("ALTER TABLE graph_edges_v2 RENAME TO graph_edges"))
155
+ await conn.execute(text(
156
+ "CREATE INDEX IF NOT EXISTS ix_graph_edges_repo_id ON graph_edges (repo_id)"
157
+ ))
158
+ await conn.execute(text(
159
+ "CREATE INDEX IF NOT EXISTS ix_graph_edges_source_id ON graph_edges (source_id)"
160
+ ))
161
+ await conn.execute(text(
162
+ "CREATE INDEX IF NOT EXISTS ix_graph_edges_target_id ON graph_edges (target_id)"
163
+ ))
164
+ logger.info("graph_edges v2 migration complete.")
165
+
166
+ # Migrate tracked_branches column on repositories table if missing
167
+ result = await conn.execute(text("PRAGMA table_info(repositories)"))
168
+ repo_cols = {row[1] for row in result.fetchall()}
169
+ if "tracked_branches" not in repo_cols:
170
+ await conn.execute(text(
171
+ "ALTER TABLE repositories ADD COLUMN tracked_branches JSON DEFAULT '[]'"
172
+ ))
173
+ logger.info("repositories.tracked_branches column added.")
174
+
175
+ async def _migrate_graph_v2_postgresql(self, conn: Any) -> None:
176
+ """PostgreSQL migration: ADD COLUMN IF NOT EXISTS + constraint update."""
177
+ # Check if repo_id column exists
178
+ result = await conn.execute(text("""
179
+ SELECT column_name FROM information_schema.columns
180
+ WHERE table_name = 'graph_nodes' AND column_name = 'repo_id'
181
+ """))
182
+ if not result.fetchone():
183
+ logger.info("Migrating graph_nodes to v2 schema (PostgreSQL)...")
184
+ await conn.execute(text(
185
+ "ALTER TABLE graph_nodes ADD COLUMN IF NOT EXISTS repo_id VARCHAR NOT NULL DEFAULT ''"
186
+ ))
187
+ await conn.execute(text(
188
+ "ALTER TABLE graph_nodes ADD COLUMN IF NOT EXISTS branch VARCHAR NOT NULL DEFAULT ''"
189
+ ))
190
+ # Populate from existing JSON metadata
191
+ await conn.execute(text("""
192
+ UPDATE graph_nodes
193
+ SET repo_id = COALESCE(metadata->>'repo_id', ''),
194
+ branch = COALESCE(metadata->>'branch', '')
195
+ WHERE repo_id = '' OR repo_id IS NULL
196
+ """))
197
+ # Drop old constraint, add new
198
+ await conn.execute(text(
199
+ "ALTER TABLE graph_nodes DROP CONSTRAINT IF EXISTS uq_graph_node_type_name"
200
+ ))
201
+ await conn.execute(text("""
202
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_graph_node_repo_branch_type_name
203
+ ON graph_nodes (repo_id, branch, node_type, name)
204
+ """))
205
+ logger.info("graph_nodes v2 migration complete.")
206
+
207
+ # --- graph_edges ---
208
+ result = await conn.execute(text("""
209
+ SELECT column_name FROM information_schema.columns
210
+ WHERE table_name = 'graph_edges' AND column_name = 'repo_id'
211
+ """))
212
+ if not result.fetchone():
213
+ logger.info("Migrating graph_edges to v2 schema (PostgreSQL)...")
214
+ await conn.execute(text(
215
+ "ALTER TABLE graph_edges ADD COLUMN IF NOT EXISTS repo_id VARCHAR NOT NULL DEFAULT ''"
216
+ ))
217
+ await conn.execute(text(
218
+ "ALTER TABLE graph_edges DROP CONSTRAINT IF EXISTS uq_graph_edge_src_tgt_rel"
219
+ ))
220
+ await conn.execute(text("""
221
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_graph_edge_repo_src_tgt_rel
222
+ ON graph_edges (repo_id, source_id, target_id, relation)
223
+ """))
224
+ logger.info("graph_edges v2 migration complete.")
225
+
226
+ # tracked_branches on repositories
227
+ result = await conn.execute(text("""
228
+ SELECT column_name FROM information_schema.columns
229
+ WHERE table_name = 'repositories' AND column_name = 'tracked_branches'
230
+ """))
231
+ if not result.fetchone():
232
+ await conn.execute(text(
233
+ "ALTER TABLE repositories ADD COLUMN IF NOT EXISTS tracked_branches JSON DEFAULT '[]'"
234
+ ))
235
+ logger.info("repositories.tracked_branches column added.")
236
+
237
+ # ------------------------------------------------------------------
238
+ # Node operations
239
+ # ------------------------------------------------------------------
240
+
241
+ async def add_node(
242
+ self,
243
+ node_type: str,
244
+ name: str,
245
+ metadata: dict[str, Any] | None = None,
246
+ node_id: uuid.UUID | None = None,
247
+ *,
248
+ repo_id: str = "",
249
+ branch: str = "",
250
+ ) -> GraphNode:
251
+ """Insert a node. Raises on duplicate (repo_id, branch, type, name)."""
252
+ async with self._session() as sess:
253
+ node = GraphNode(
254
+ id=node_id or uuid.uuid4(),
255
+ repo_id=repo_id,
256
+ branch=branch,
257
+ node_type=node_type,
258
+ name=name,
259
+ node_metadata=metadata or {},
260
+ )
261
+ sess.add(node)
262
+ await sess.flush()
263
+ await sess.refresh(node)
264
+ return node
265
+
266
+ async def upsert_node(
267
+ self,
268
+ node_type: str,
269
+ name: str,
270
+ metadata: dict[str, Any] | None = None,
271
+ *,
272
+ repo_id: str = "",
273
+ branch: str = "",
274
+ ) -> GraphNode:
275
+ """Insert or update a node by (repo_id, branch, type, name)."""
276
+ async with self._session() as sess:
277
+ result = await sess.execute(
278
+ select(GraphNode).where(
279
+ GraphNode.repo_id == repo_id,
280
+ GraphNode.branch == branch,
281
+ GraphNode.node_type == node_type,
282
+ GraphNode.name == name,
283
+ )
284
+ )
285
+ existing = result.scalar_one_or_none()
286
+ if existing is not None:
287
+ if metadata:
288
+ await sess.execute(
289
+ update(GraphNode)
290
+ .where(GraphNode.id == existing.id)
291
+ .values(node_metadata={**dict(existing.node_metadata), **metadata})
292
+ )
293
+ await sess.refresh(existing)
294
+ return existing
295
+ node = GraphNode(
296
+ id=uuid.uuid4(),
297
+ repo_id=repo_id,
298
+ branch=branch,
299
+ node_type=node_type,
300
+ name=name,
301
+ node_metadata=metadata or {},
302
+ )
303
+ sess.add(node)
304
+ await sess.flush()
305
+ await sess.refresh(node)
306
+ return node
307
+
308
+ async def get_node(self, node_id: uuid.UUID) -> GraphNode | None:
309
+ async with self._session() as sess:
310
+ result = await sess.execute(
311
+ select(GraphNode).where(GraphNode.id == node_id)
312
+ )
313
+ return result.scalar_one_or_none()
314
+
315
+ async def get_node_by_name(
316
+ self,
317
+ node_type: str,
318
+ name: str,
319
+ *,
320
+ repo_id: str = "",
321
+ branch: str = "",
322
+ ) -> GraphNode | None:
323
+ async with self._session() as sess:
324
+ result = await sess.execute(
325
+ select(GraphNode).where(
326
+ GraphNode.repo_id == repo_id,
327
+ GraphNode.branch == branch,
328
+ GraphNode.node_type == node_type,
329
+ GraphNode.name == name,
330
+ )
331
+ )
332
+ return result.scalar_one_or_none()
333
+
334
+ async def list_nodes(self) -> list[GraphNode]:
335
+ """Return ALL nodes across all repos/branches (use sparingly)."""
336
+ async with self._session() as sess:
337
+ result = await sess.execute(select(GraphNode))
338
+ return list(result.scalars().all())
339
+
340
+ async def list_nodes_by_scope(
341
+ self,
342
+ *,
343
+ repo_id: str,
344
+ branch: str | None = None,
345
+ ) -> list[GraphNode]:
346
+ """Return nodes scoped to a specific repo_id, optionally filtered by branch."""
347
+ async with self._session() as sess:
348
+ stmt = select(GraphNode).where(GraphNode.repo_id == repo_id)
349
+ if branch is not None:
350
+ stmt = stmt.where(GraphNode.branch == branch)
351
+ result = await sess.execute(stmt)
352
+ return list(result.scalars().all())
353
+
354
+ async def list_edges(self) -> list[GraphEdge]:
355
+ """Return ALL edges across all repos (use sparingly)."""
356
+ async with self._session() as sess:
357
+ result = await sess.execute(select(GraphEdge))
358
+ return list(result.scalars().all())
359
+
360
+ async def list_edges_by_scope(self, *, repo_id: str) -> list[GraphEdge]:
361
+ """Return edges scoped to a specific repo_id."""
362
+ async with self._session() as sess:
363
+ result = await sess.execute(
364
+ select(GraphEdge).where(GraphEdge.repo_id == repo_id)
365
+ )
366
+ return list(result.scalars().all())
367
+
368
+ async def query_by_type(self, node_type: str, *, repo_id: str = "") -> list[GraphNode]:
369
+ async with self._session() as sess:
370
+ stmt = select(GraphNode).where(GraphNode.node_type == node_type)
371
+ if repo_id:
372
+ stmt = stmt.where(GraphNode.repo_id == repo_id)
373
+ result = await sess.execute(stmt)
374
+ return list(result.scalars().all())
375
+
376
+ async def delete_node(self, node_id: uuid.UUID) -> None:
377
+ async with self._session() as sess:
378
+ await sess.execute(delete(GraphNode).where(GraphNode.id == node_id))
379
+ # Cascade: remove all edges incident to this node
380
+ await sess.execute(
381
+ delete(GraphEdge).where(
382
+ (GraphEdge.source_id == node_id) | (GraphEdge.target_id == node_id)
383
+ )
384
+ )
385
+
386
+ async def delete_nodes_by_scope(
387
+ self,
388
+ *,
389
+ repo_id: str,
390
+ branch: str | None = None,
391
+ paths: set[str] | None = None,
392
+ ) -> int:
393
+ """Delete nodes for a given repo/branch scope.
394
+
395
+ If *paths* is provided only nodes whose metadata.path matches are removed.
396
+ Returns the number of nodes deleted.
397
+ """
398
+ nodes = await self.list_nodes_by_scope(repo_id=repo_id, branch=branch)
399
+ deleted = 0
400
+ for node in nodes:
401
+ meta = dict(getattr(node, "node_metadata", {}) or {})
402
+ if paths is not None:
403
+ node_path = str(meta.get("path", "") or "")
404
+ if node_path not in paths:
405
+ continue
406
+ await self.delete_node(node.id)
407
+ deleted += 1
408
+ return deleted
409
+
410
+ # ------------------------------------------------------------------
411
+ # Edge operations
412
+ # ------------------------------------------------------------------
413
+
414
+ async def add_edge(
415
+ self,
416
+ source_id: uuid.UUID,
417
+ target_id: uuid.UUID,
418
+ relation: str,
419
+ weight: float = 1.0,
420
+ edge_id: uuid.UUID | None = None,
421
+ *,
422
+ repo_id: str = "",
423
+ ) -> GraphEdge:
424
+ """Insert a directed edge. Raises on duplicate (repo_id, source, target, relation)."""
425
+ async with self._session() as sess:
426
+ edge = GraphEdge(
427
+ id=edge_id or uuid.uuid4(),
428
+ repo_id=repo_id,
429
+ source_id=source_id,
430
+ target_id=target_id,
431
+ relation=relation,
432
+ weight=weight,
433
+ )
434
+ sess.add(edge)
435
+ await sess.flush()
436
+ await sess.refresh(edge)
437
+ return edge
438
+
439
+ async def upsert_edge(
440
+ self,
441
+ source_id: uuid.UUID,
442
+ target_id: uuid.UUID,
443
+ relation: str,
444
+ weight: float = 1.0,
445
+ *,
446
+ repo_id: str = "",
447
+ ) -> GraphEdge:
448
+ """Insert or update edge by (repo_id, source, target, relation)."""
449
+ async with self._session() as sess:
450
+ result = await sess.execute(
451
+ select(GraphEdge).where(
452
+ GraphEdge.repo_id == repo_id,
453
+ GraphEdge.source_id == source_id,
454
+ GraphEdge.target_id == target_id,
455
+ GraphEdge.relation == relation,
456
+ )
457
+ )
458
+ existing = result.scalar_one_or_none()
459
+ if existing is not None:
460
+ await sess.execute(
461
+ update(GraphEdge)
462
+ .where(GraphEdge.id == existing.id)
463
+ .values(weight=weight)
464
+ )
465
+ await sess.refresh(existing)
466
+ return existing
467
+ edge = GraphEdge(
468
+ id=uuid.uuid4(),
469
+ repo_id=repo_id,
470
+ source_id=source_id,
471
+ target_id=target_id,
472
+ relation=relation,
473
+ weight=weight,
474
+ )
475
+ sess.add(edge)
476
+ await sess.flush()
477
+ await sess.refresh(edge)
478
+ return edge
479
+
480
+ async def delete_edge(self, edge_id: uuid.UUID) -> None:
481
+ async with self._session() as sess:
482
+ await sess.execute(delete(GraphEdge).where(GraphEdge.id == edge_id))
483
+
484
+ # ------------------------------------------------------------------
485
+ # Traversal
486
+ # ------------------------------------------------------------------
487
+
488
+ async def get_neighbors(
489
+ self,
490
+ node_id: uuid.UUID,
491
+ *,
492
+ direction: str = "out",
493
+ relation: str | None = None,
494
+ ) -> list[GraphNode]:
495
+ """
496
+ Return neighbor nodes.
497
+
498
+ direction:
499
+ "out" — nodes that ``node_id`` points to (source → target)
500
+ "in" — nodes that point to ``node_id`` (target ← source)
501
+ "both" — union of out and in
502
+ """
503
+ async with self._session() as sess:
504
+ neighbor_ids: list[uuid.UUID] = []
505
+
506
+ async def _out() -> list[uuid.UUID]:
507
+ stmt = select(GraphEdge.target_id).where(
508
+ GraphEdge.source_id == node_id
509
+ )
510
+ if relation:
511
+ stmt = stmt.where(GraphEdge.relation == relation)
512
+ r = await sess.execute(stmt)
513
+ return list(r.scalars().all())
514
+
515
+ async def _in() -> list[uuid.UUID]:
516
+ stmt = select(GraphEdge.source_id).where(
517
+ GraphEdge.target_id == node_id
518
+ )
519
+ if relation:
520
+ stmt = stmt.where(GraphEdge.relation == relation)
521
+ r = await sess.execute(stmt)
522
+ return list(r.scalars().all())
523
+
524
+ if direction in ("out", "both"):
525
+ neighbor_ids.extend(await _out())
526
+ if direction in ("in", "both"):
527
+ neighbor_ids.extend(await _in())
528
+
529
+ if not neighbor_ids:
530
+ return []
531
+
532
+ seen: set[uuid.UUID] = set()
533
+ unique_ids = [nid for nid in neighbor_ids if not (nid in seen or seen.add(nid))] # type: ignore[func-returns-value]
534
+
535
+ result = await sess.execute(
536
+ select(GraphNode).where(GraphNode.id.in_(unique_ids))
537
+ )
538
+ return list(result.scalars().all())
539
+
540
+ async def get_path(
541
+ self,
542
+ source_id: uuid.UUID,
543
+ target_id: uuid.UUID,
544
+ *,
545
+ max_depth: int = 6,
546
+ ) -> list[GraphNode]:
547
+ """
548
+ BFS shortest path from source to target following outgoing edges.
549
+ Returns ordered list of nodes including source and target, or empty
550
+ list if no path exists within max_depth.
551
+ """
552
+ if source_id == target_id:
553
+ node = await self.get_node(source_id)
554
+ return [node] if node else []
555
+
556
+ visited: set[uuid.UUID] = {source_id}
557
+ queue: deque[tuple[uuid.UUID, list[uuid.UUID]]] = deque(
558
+ [(source_id, [source_id])]
559
+ )
560
+
561
+ while queue:
562
+ current_id, path = queue.popleft()
563
+ if len(path) > max_depth:
564
+ continue
565
+ async with self._session() as sess:
566
+ result = await sess.execute(
567
+ select(GraphEdge.target_id).where(GraphEdge.source_id == current_id)
568
+ )
569
+ neighbors = list(result.scalars().all())
570
+
571
+ for nid in neighbors:
572
+ if nid == target_id:
573
+ full_path = path + [target_id]
574
+ nodes: list[GraphNode] = []
575
+ async with self._session() as sess:
576
+ for pid in full_path:
577
+ r = await sess.execute(
578
+ select(GraphNode).where(GraphNode.id == pid)
579
+ )
580
+ n = r.scalar_one_or_none()
581
+ if n:
582
+ nodes.append(n)
583
+ return nodes
584
+ if nid not in visited:
585
+ visited.add(nid)
586
+ queue.append((nid, path + [nid]))
587
+
588
+ return []