krnl-code 1.0.4__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 (56) hide show
  1. krnl_agent/__init__.py +9 -0
  2. krnl_agent/__main__.py +7 -0
  3. krnl_agent/agent_registry.py +95 -0
  4. krnl_agent/agent_selector.py +69 -0
  5. krnl_agent/audit_log.py +155 -0
  6. krnl_agent/background.py +94 -0
  7. krnl_agent/checkpoints.py +67 -0
  8. krnl_agent/ci.py +73 -0
  9. krnl_agent/cli.py +1458 -0
  10. krnl_agent/commands.py +42 -0
  11. krnl_agent/config.py +425 -0
  12. krnl_agent/context.py +352 -0
  13. krnl_agent/depaudit.py +63 -0
  14. krnl_agent/deploy.py +245 -0
  15. krnl_agent/doctor.py +106 -0
  16. krnl_agent/events.py +141 -0
  17. krnl_agent/gitignore.py +47 -0
  18. krnl_agent/graph.py +928 -0
  19. krnl_agent/guardrails.py +70 -0
  20. krnl_agent/headless.py +60 -0
  21. krnl_agent/history.py +49 -0
  22. krnl_agent/hooks.py +72 -0
  23. krnl_agent/ingest.py +129 -0
  24. krnl_agent/llm.py +456 -0
  25. krnl_agent/loop.py +779 -0
  26. krnl_agent/mcp_client.py +128 -0
  27. krnl_agent/memory.py +61 -0
  28. krnl_agent/modelrouter.py +151 -0
  29. krnl_agent/monitor.py +112 -0
  30. krnl_agent/notify.py +119 -0
  31. krnl_agent/parallel_executor.py +139 -0
  32. krnl_agent/permissions.py +128 -0
  33. krnl_agent/plugins.py +105 -0
  34. krnl_agent/pricing.py +85 -0
  35. krnl_agent/prompts.py +60 -0
  36. krnl_agent/repomap.py +133 -0
  37. krnl_agent/sandbox.py +69 -0
  38. krnl_agent/scaffold.py +167 -0
  39. krnl_agent/schedules.py +137 -0
  40. krnl_agent/secrets.py +100 -0
  41. krnl_agent/selfheal.py +87 -0
  42. krnl_agent/server.py +302 -0
  43. krnl_agent/sessions.py +258 -0
  44. krnl_agent/settings.py +59 -0
  45. krnl_agent/skills.py +73 -0
  46. krnl_agent/teams.py +38 -0
  47. krnl_agent/tool_schemas.py +431 -0
  48. krnl_agent/tools.py +694 -0
  49. krnl_agent/webtools.py +139 -0
  50. krnl_code-1.0.4.dist-info/METADATA +214 -0
  51. krnl_code-1.0.4.dist-info/RECORD +56 -0
  52. krnl_code-1.0.4.dist-info/WHEEL +5 -0
  53. krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
  54. krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
  55. krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
  56. krnl_code-1.0.4.dist-info/top_level.txt +1 -0
krnl_agent/graph.py ADDED
@@ -0,0 +1,928 @@
1
+ """Code Knowledge Graph - Phase 1.
2
+
3
+ Builds a queryable map of the codebase using Tree-sitter, NetworkX, and SQLite.
4
+ Tracks what calls what, what imports what, what inherits from what.
5
+
6
+ Schema:
7
+ - Node types: Module (file), Class, Function (methods distinguished by parent_class attribute)
8
+ - Edge types: imports (Module→Module), calls (Function→Function), inherits (Class→Class),
9
+ defines (Module→Class, Module→Function, Class→Function)
10
+
11
+ Sync model:
12
+ - SQLite is source of truth
13
+ - On session start: load full graph from SQLite into in-memory NetworkX MultiDiGraph
14
+ - All reads during session hit in-memory graph only
15
+ - All writes write to SQLite AND patch in-memory graph (never let them drift)
16
+
17
+ Invalidation:
18
+ - Wholesale per-file replace (not incremental diffing)
19
+ - On file save: delete all nodes for that file, re-parse, re-insert, re-resolve
20
+
21
+ Cross-file resolution:
22
+ - Build module path index first
23
+ - Look up imports in index
24
+ - Never resolve by name-similarity guessing
25
+ - External packages recorded as metadata on unresolved edges
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import ast
30
+ import sqlite3
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+ from typing import Any, Optional
34
+
35
+ import networkx as nx
36
+
37
+
38
+ # --------------------------------------------------------------------------- #
39
+ # Dataclasses
40
+ # --------------------------------------------------------------------------- #
41
+ @dataclass
42
+ class Node:
43
+ id: str
44
+ type: str # Module, Class, Function
45
+ name: str
46
+ qualified_name: str
47
+ file_path: str
48
+ line_start: int
49
+ line_end: int
50
+ language: str
51
+ parent_class: Optional[str] = None # For methods
52
+
53
+
54
+ @dataclass
55
+ class Edge:
56
+ id: str
57
+ source_id: str
58
+ target_id: str
59
+ type: str # imports, calls, inherits, defines
60
+ resolved: bool
61
+ raw_reference: Optional[str] = None
62
+
63
+
64
+ # --------------------------------------------------------------------------- #
65
+ # Graph Database
66
+ # --------------------------------------------------------------------------- #
67
+ class GraphDB:
68
+ """SQLite persistence for the code knowledge graph."""
69
+
70
+ def __init__(self, db_path: str):
71
+ self.db_path = Path(db_path)
72
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
73
+ self._conn: Optional[sqlite3.Connection] = None
74
+ self._init_db()
75
+
76
+ def _init_db(self) -> None:
77
+ """Initialize SQLite schema with WAL mode for concurrency."""
78
+ conn = self._get_conn()
79
+ conn.execute("PRAGMA journal_mode=WAL")
80
+ conn.execute("""
81
+ CREATE TABLE IF NOT EXISTS nodes (
82
+ id TEXT PRIMARY KEY,
83
+ type TEXT NOT NULL,
84
+ name TEXT NOT NULL,
85
+ qualified_name TEXT NOT NULL,
86
+ file_path TEXT NOT NULL,
87
+ line_start INTEGER NOT NULL,
88
+ line_end INTEGER NOT NULL,
89
+ language TEXT NOT NULL,
90
+ parent_class TEXT
91
+ )
92
+ """)
93
+ conn.execute("""
94
+ CREATE TABLE IF NOT EXISTS edges (
95
+ id TEXT PRIMARY KEY,
96
+ source_id TEXT NOT NULL,
97
+ target_id TEXT NOT NULL,
98
+ type TEXT NOT NULL,
99
+ resolved BOOLEAN NOT NULL,
100
+ raw_reference TEXT,
101
+ FOREIGN KEY (source_id) REFERENCES nodes(id),
102
+ FOREIGN KEY (target_id) REFERENCES nodes(id)
103
+ )
104
+ """)
105
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path)")
106
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id)")
107
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id)")
108
+ conn.commit()
109
+
110
+ def _get_conn(self) -> sqlite3.Connection:
111
+ if self._conn is None:
112
+ self._conn = sqlite3.connect(self.db_path)
113
+ return self._conn
114
+
115
+ def close(self) -> None:
116
+ if self._conn:
117
+ self._conn.close()
118
+ self._conn = None
119
+
120
+ def insert_node(self, node: Node) -> None:
121
+ conn = self._get_conn()
122
+ conn.execute(
123
+ """
124
+ INSERT OR REPLACE INTO nodes
125
+ (id, type, name, qualified_name, file_path, line_start, line_end, language, parent_class)
126
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
127
+ """,
128
+ (
129
+ node.id,
130
+ node.type,
131
+ node.name,
132
+ node.qualified_name,
133
+ node.file_path,
134
+ node.line_start,
135
+ node.line_end,
136
+ node.language,
137
+ node.parent_class,
138
+ ),
139
+ )
140
+ conn.commit()
141
+
142
+ def insert_edge(self, edge: Edge) -> None:
143
+ conn = self._get_conn()
144
+ conn.execute(
145
+ """
146
+ INSERT OR REPLACE INTO edges
147
+ (id, source_id, target_id, type, resolved, raw_reference)
148
+ VALUES (?, ?, ?, ?, ?, ?)
149
+ """,
150
+ (edge.id, edge.source_id, edge.target_id, edge.type, edge.resolved, edge.raw_reference),
151
+ )
152
+ conn.commit()
153
+
154
+ def delete_nodes_for_file(self, file_path: str) -> list[str]:
155
+ """Delete all nodes for a file and return their IDs."""
156
+ conn = self._get_conn()
157
+ cursor = conn.execute("SELECT id FROM nodes WHERE file_path = ?", (file_path,))
158
+ node_ids = [row[0] for row in cursor.fetchall()]
159
+ if node_ids:
160
+ placeholders = ",".join("?" * len(node_ids))
161
+ conn.execute(f"DELETE FROM edges WHERE source_id IN ({placeholders})", node_ids)
162
+ conn.execute(f"DELETE FROM edges WHERE target_id IN ({placeholders})", node_ids)
163
+ conn.execute("DELETE FROM nodes WHERE file_path = ?", (file_path,))
164
+ conn.commit()
165
+ return node_ids
166
+
167
+ def load_all_nodes(self) -> list[Node]:
168
+ conn = self._get_conn()
169
+ cursor = conn.execute("SELECT * FROM nodes")
170
+ nodes = []
171
+ for row in cursor.fetchall():
172
+ nodes.append(
173
+ Node(
174
+ id=row[0],
175
+ type=row[1],
176
+ name=row[2],
177
+ qualified_name=row[3],
178
+ file_path=row[4],
179
+ line_start=row[5],
180
+ line_end=row[6],
181
+ language=row[7],
182
+ parent_class=row[8],
183
+ )
184
+ )
185
+ return nodes
186
+
187
+ def load_all_edges(self) -> list[Edge]:
188
+ conn = self._get_conn()
189
+ cursor = conn.execute("SELECT * FROM edges")
190
+ edges = []
191
+ for row in cursor.fetchall():
192
+ edges.append(
193
+ Edge(
194
+ id=row[0],
195
+ source_id=row[1],
196
+ target_id=row[2],
197
+ type=row[3],
198
+ resolved=bool(row[4]),
199
+ raw_reference=row[5],
200
+ )
201
+ )
202
+ return edges
203
+
204
+ def get_unresolved_edges_targeting_module(self, module_path: str) -> list[Edge]:
205
+ """Get unresolved edges that might resolve to a module path."""
206
+ conn = self._get_conn()
207
+ cursor = conn.execute(
208
+ """
209
+ SELECT * FROM edges
210
+ WHERE resolved = 0 AND raw_reference LIKE ?
211
+ """,
212
+ (f"%{module_path}%",),
213
+ )
214
+ edges = []
215
+ for row in cursor.fetchall():
216
+ edges.append(
217
+ Edge(
218
+ id=row[0],
219
+ source_id=row[1],
220
+ target_id=row[2],
221
+ type=row[3],
222
+ resolved=bool(row[4]),
223
+ raw_reference=row[5],
224
+ )
225
+ )
226
+ return edges
227
+
228
+
229
+ # --------------------------------------------------------------------------- #
230
+ # In-Memory Graph
231
+ # --------------------------------------------------------------------------- #
232
+ class CodeGraph:
233
+ """In-memory NetworkX MultiDiGraph wrapper for fast queries."""
234
+
235
+ def __init__(self):
236
+ self.graph: nx.MultiDiGraph = nx.MultiDiGraph()
237
+ self.node_by_id: dict[str, Node] = {}
238
+ self.edge_by_id: dict[str, Edge] = {}
239
+
240
+ def add_node(self, node: Node) -> None:
241
+ self.graph.add_node(
242
+ node.id,
243
+ type=node.type,
244
+ name=node.name,
245
+ qualified_name=node.qualified_name,
246
+ file_path=node.file_path,
247
+ line_start=node.line_start,
248
+ line_end=node.line_end,
249
+ language=node.language,
250
+ parent_class=node.parent_class,
251
+ )
252
+ self.node_by_id[node.id] = node
253
+
254
+ def add_edge(self, edge: Edge) -> None:
255
+ # Only add edge to graph if both source and target exist
256
+ # Unresolved edges with empty target_id are stored in edge_by_id but not in graph
257
+ if edge.target_id and edge.source_id:
258
+ self.graph.add_edge(
259
+ edge.source_id,
260
+ edge.target_id,
261
+ key=edge.id,
262
+ type=edge.type,
263
+ resolved=edge.resolved,
264
+ raw_reference=edge.raw_reference,
265
+ )
266
+ self.edge_by_id[edge.id] = edge
267
+
268
+ def remove_nodes(self, node_ids: list[str]) -> None:
269
+ for nid in node_ids:
270
+ if nid in self.graph:
271
+ self.graph.remove_node(nid)
272
+ self.node_by_id.pop(nid, None)
273
+ # Remove edges
274
+ to_remove = [eid for eid, e in self.edge_by_id.items() if e.source_id in node_ids or e.target_id in node_ids]
275
+ for eid in to_remove:
276
+ self.edge_by_id.pop(eid, None)
277
+
278
+ def get_node(self, node_id: str) -> Optional[Node]:
279
+ return self.node_by_id.get(node_id)
280
+
281
+ def get_neighbors(self, node_id: str, edge_type: Optional[str] = None, direction: str = "out") -> list[Node]:
282
+ """Get neighboring nodes. direction: 'out', 'in', or 'both'."""
283
+ if node_id not in self.graph:
284
+ return []
285
+ neighbors = []
286
+ if direction in ("out", "both"):
287
+ for _, target, key, data in self.graph.out_edges(node_id, keys=True, data=True):
288
+ if edge_type is None or data.get("type") == edge_type:
289
+ node = self.node_by_id.get(target)
290
+ if node:
291
+ neighbors.append(node)
292
+ if direction in ("in", "both"):
293
+ for source, _, key, data in self.graph.in_edges(node_id, keys=True, data=True):
294
+ if edge_type is None or data.get("type") == edge_type:
295
+ node = self.node_by_id.get(source)
296
+ if node:
297
+ neighbors.append(node)
298
+ return neighbors
299
+
300
+ def get_nodes_by_file(self, file_path: str) -> list[Node]:
301
+ return [n for n in self.node_by_id.values() if n.file_path == file_path]
302
+
303
+ def get_nodes_by_type(self, node_type: str) -> list[Node]:
304
+ return [n for n in self.node_by_id.values() if n.type == node_type]
305
+
306
+ def find_nodes_by_name(self, name: str, node_type: Optional[str] = None) -> list[Node]:
307
+ candidates = [n for n in self.node_by_id.values() if n.name == name]
308
+ if node_type:
309
+ candidates = [n for n in candidates if n.type == node_type]
310
+ return candidates
311
+
312
+
313
+ # --------------------------------------------------------------------------- #
314
+ # Module Path Index
315
+ # --------------------------------------------------------------------------- #
316
+ class ModulePathIndex:
317
+ """Index of importable module paths for cross-file resolution."""
318
+
319
+ def __init__(self, workspace: str):
320
+ self.workspace = Path(workspace)
321
+ self.index: dict[str, str] = {} # module_path -> file_path
322
+
323
+ def build(self) -> None:
324
+ """Build the module path index for Python."""
325
+ self.index.clear()
326
+ self._build_python_index()
327
+
328
+ def _build_python_index(self) -> None:
329
+ """Build Python module index respecting package boundaries."""
330
+ for py_file in self.workspace.rglob("*.py"):
331
+ if "__pycache__" in str(py_file) or ".venv" in str(py_file):
332
+ continue
333
+ rel_path = py_file.relative_to(self.workspace)
334
+ # Convert to module path
335
+ parts = list(rel_path.parts)
336
+ if parts[-1] == "__init__.py":
337
+ # Package directory
338
+ if len(parts) > 1:
339
+ module_path = ".".join(parts[:-1])
340
+ else:
341
+ # Root __init__.py - use directory name
342
+ module_path = parts[0].replace(".py", "")
343
+ else:
344
+ # Module file
345
+ if parts[-1].endswith(".py"):
346
+ parts[-1] = parts[-1][:-3]
347
+ module_path = ".".join(parts)
348
+ if module_path:
349
+ self.index[module_path] = str(py_file)
350
+
351
+ def resolve(self, import_path: str) -> Optional[str]:
352
+ """Resolve an import path to a file path, or None if not found."""
353
+ # Direct match
354
+ if import_path in self.index:
355
+ return self.index[import_path]
356
+ # Try relative resolution (e.g., "package.module" -> "package.module.submodule")
357
+ for key, path in self.index.items():
358
+ if key == import_path or key.startswith(import_path + "."):
359
+ return path
360
+ return None
361
+
362
+
363
+ # --------------------------------------------------------------------------- #
364
+ # Python Parser (Tree-sitter with AST fallback)
365
+ # --------------------------------------------------------------------------- #
366
+ class PythonParser:
367
+ """Parse Python files using Tree-sitter, with AST fallback."""
368
+
369
+ def __init__(self):
370
+ self._parser = None
371
+ self._language = None
372
+ self._use_ast_fallback = False
373
+
374
+ def _get_parser(self):
375
+ """Lazy-load Tree-sitter parser."""
376
+ if self._use_ast_fallback:
377
+ return None
378
+ if self._parser is None:
379
+ try:
380
+ import tree_sitter_python as tspython
381
+ from tree_sitter import Language, Parser
382
+
383
+ self._language = Language(tspython.language())
384
+ self._parser = Parser()
385
+ self._parser.set_language(self._language)
386
+ except ImportError:
387
+ # Tree-sitter not installed, use AST fallback
388
+ self._use_ast_fallback = True
389
+ return None
390
+ return self._parser
391
+
392
+ def parse_file(self, file_path: str) -> tuple[list[Node], list[Edge]]:
393
+ """Parse a Python file and extract nodes/edges."""
394
+ parser = self._get_parser()
395
+ if parser is not None:
396
+ return self._parse_with_tree_sitter(file_path)
397
+ else:
398
+ return self._parse_with_ast(file_path)
399
+
400
+ def _parse_with_tree_sitter(self, file_path: str) -> tuple[list[Node], list[Edge]]:
401
+ """Parse using Tree-sitter."""
402
+ try:
403
+ source = Path(file_path).read_text(encoding="utf-8")
404
+ except Exception:
405
+ return [], []
406
+
407
+ tree = self._parser.parse(source.encode("utf-8"))
408
+ nodes: list[Node] = []
409
+ edges: list[Edge] = []
410
+
411
+ # Extract module node
412
+ module_id = f"module:{file_path}"
413
+ module_node = Node(
414
+ id=module_id,
415
+ type="Module",
416
+ name=Path(file_path).stem,
417
+ qualified_name=Path(file_path).stem,
418
+ file_path=file_path,
419
+ line_start=1,
420
+ line_end=source.count("\n") + 1,
421
+ language="python",
422
+ )
423
+ nodes.append(module_node)
424
+
425
+ # Walk the AST
426
+ self._extract_nodes_and_edges_ts(tree.root_node, file_path, module_id, nodes, edges)
427
+
428
+ return nodes, edges
429
+
430
+ def _parse_with_ast(self, file_path: str) -> tuple[list[Node], list[Edge]]:
431
+ """Parse using Python's built-in ast module."""
432
+ import ast
433
+
434
+ try:
435
+ source = Path(file_path).read_text(encoding="utf-8")
436
+ except Exception:
437
+ return [], []
438
+
439
+ try:
440
+ tree = ast.parse(source)
441
+ except SyntaxError:
442
+ return [], []
443
+
444
+ nodes: list[Node] = []
445
+ edges: list[Edge] = []
446
+
447
+ # Extract module node
448
+ module_id = f"module:{file_path}"
449
+ module_node = Node(
450
+ id=module_id,
451
+ type="Module",
452
+ name=Path(file_path).stem,
453
+ qualified_name=Path(file_path).stem,
454
+ file_path=file_path,
455
+ line_start=1,
456
+ line_end=len(source.splitlines()),
457
+ language="python",
458
+ )
459
+ nodes.append(module_node)
460
+
461
+ # Walk the AST
462
+ self._extract_nodes_and_edges_ast(tree, file_path, module_id, nodes, edges)
463
+
464
+ return nodes, edges
465
+
466
+ def _extract_nodes_and_edges_ts(
467
+ self,
468
+ node,
469
+ file_path: str,
470
+ module_id: str,
471
+ nodes: list[Node],
472
+ edges: list[Edge],
473
+ parent_class: Optional[str] = None,
474
+ ) -> None:
475
+ """Recursively extract nodes and edges from Tree-sitter AST."""
476
+ if node.type == "function_definition":
477
+ func_node = self._extract_function_ts(node, file_path, parent_class)
478
+ if func_node:
479
+ nodes.append(func_node)
480
+ edges.append(
481
+ Edge(
482
+ id=f"edge:{module_id}->{func_node.id}",
483
+ source_id=module_id,
484
+ target_id=func_node.id,
485
+ type="defines",
486
+ resolved=True,
487
+ )
488
+ )
489
+ # Extract calls within function
490
+ self._extract_calls_ts(node, func_node.id, edges)
491
+
492
+ elif node.type == "class_definition":
493
+ class_node = self._extract_class_ts(node, file_path)
494
+ if class_node:
495
+ nodes.append(class_node)
496
+ edges.append(
497
+ Edge(
498
+ id=f"edge:{module_id}->{class_node.id}",
499
+ source_id=module_id,
500
+ target_id=class_node.id,
501
+ type="defines",
502
+ resolved=True,
503
+ )
504
+ )
505
+ # Extract inheritance
506
+ self._extract_inheritance_ts(node, class_node.id, edges)
507
+ # Recurse into class body
508
+ for child in node.children:
509
+ self._extract_nodes_and_edges_ts(child, file_path, module_id, nodes, edges, parent_class=class_node.name)
510
+
511
+ elif node.type == "import_statement":
512
+ self._extract_import_ts(node, module_id, edges)
513
+
514
+ elif node.type == "import_from_statement":
515
+ self._extract_import_from_ts(node, module_id, edges)
516
+
517
+ # Recurse into children
518
+ for child in node.children:
519
+ self._extract_nodes_and_edges_ts(child, file_path, module_id, nodes, edges, parent_class)
520
+
521
+ def _extract_nodes_and_edges_ast(
522
+ self,
523
+ node: ast.AST,
524
+ file_path: str,
525
+ module_id: str,
526
+ nodes: list[Node],
527
+ edges: list[Edge],
528
+ parent_class: Optional[str] = None,
529
+ ) -> None:
530
+ """Recursively extract nodes and edges from Python AST."""
531
+ if isinstance(node, ast.FunctionDef):
532
+ func_node = self._extract_function_ast(node, file_path, parent_class)
533
+ if func_node:
534
+ nodes.append(func_node)
535
+ edges.append(
536
+ Edge(
537
+ id=f"edge:{module_id}->{func_node.id}",
538
+ source_id=module_id,
539
+ target_id=func_node.id,
540
+ type="defines",
541
+ resolved=True,
542
+ )
543
+ )
544
+ # Extract calls within function
545
+ self._extract_calls_ast(node, func_node.id, edges)
546
+
547
+ elif isinstance(node, ast.ClassDef):
548
+ class_node = self._extract_class_ast(node, file_path)
549
+ if class_node:
550
+ nodes.append(class_node)
551
+ edges.append(
552
+ Edge(
553
+ id=f"edge:{module_id}->{class_node.id}",
554
+ source_id=module_id,
555
+ target_id=class_node.id,
556
+ type="defines",
557
+ resolved=True,
558
+ )
559
+ )
560
+ # Extract inheritance
561
+ self._extract_inheritance_ast(node, class_node.id, edges)
562
+ # Recurse into class body
563
+ for child in node.body:
564
+ self._extract_nodes_and_edges_ast(child, file_path, module_id, nodes, edges, parent_class=class_node.name)
565
+
566
+ elif isinstance(node, ast.Import):
567
+ self._extract_import_ast(node, module_id, edges)
568
+
569
+ elif isinstance(node, ast.ImportFrom):
570
+ self._extract_import_from_ast(node, module_id, edges)
571
+
572
+ # Recurse into children
573
+ for child in ast.iter_child_nodes(node):
574
+ self._extract_nodes_and_edges_ast(child, file_path, module_id, nodes, edges, parent_class)
575
+
576
+ def _extract_function_ts(self, node, file_path: str, parent_class: Optional[str]) -> Optional[Node]:
577
+ """Extract a function node from Tree-sitter."""
578
+ name_node = node.child_by_field_name("name")
579
+ if not name_node:
580
+ return None
581
+ name = name_node.text.decode("utf-8")
582
+ qualified_name = f"{parent_class}.{name}" if parent_class else name
583
+ return Node(
584
+ id=f"function:{file_path}:{qualified_name}",
585
+ type="Function",
586
+ name=name,
587
+ qualified_name=qualified_name,
588
+ file_path=file_path,
589
+ line_start=node.start_point[0] + 1,
590
+ line_end=node.end_point[0] + 1,
591
+ language="python",
592
+ parent_class=parent_class,
593
+ )
594
+
595
+ def _extract_function_ast(self, node: ast.FunctionDef, file_path: str, parent_class: Optional[str]) -> Optional[Node]:
596
+ """Extract a function node from AST."""
597
+ name = node.name
598
+ qualified_name = f"{parent_class}.{name}" if parent_class else name
599
+ return Node(
600
+ id=f"function:{file_path}:{qualified_name}",
601
+ type="Function",
602
+ name=name,
603
+ qualified_name=qualified_name,
604
+ file_path=file_path,
605
+ line_start=node.lineno,
606
+ line_end=node.end_lineno if node.end_lineno else node.lineno,
607
+ language="python",
608
+ parent_class=parent_class,
609
+ )
610
+
611
+ def _extract_class_ts(self, node, file_path: str) -> Optional[Node]:
612
+ """Extract a class node from Tree-sitter."""
613
+ name_node = node.child_by_field_name("name")
614
+ if not name_node:
615
+ return None
616
+ name = name_node.text.decode("utf-8")
617
+ return Node(
618
+ id=f"class:{file_path}:{name}",
619
+ type="Class",
620
+ name=name,
621
+ qualified_name=name,
622
+ file_path=file_path,
623
+ line_start=node.start_point[0] + 1,
624
+ line_end=node.end_point[0] + 1,
625
+ language="python",
626
+ )
627
+
628
+ def _extract_class_ast(self, node: ast.ClassDef, file_path: str) -> Optional[Node]:
629
+ """Extract a class node from AST."""
630
+ name = node.name
631
+ return Node(
632
+ id=f"class:{file_path}:{name}",
633
+ type="Class",
634
+ name=name,
635
+ qualified_name=name,
636
+ file_path=file_path,
637
+ line_start=node.lineno,
638
+ line_end=node.end_lineno if node.end_lineno else node.lineno,
639
+ language="python",
640
+ )
641
+
642
+ def _extract_calls_ts(self, node, source_id: str, edges: list[Edge]) -> None:
643
+ """Extract function calls within a node (Tree-sitter)."""
644
+ if node.type == "call":
645
+ func_node = node.child_by_field_name("function")
646
+ if func_node:
647
+ func_name = func_node.text.decode("utf-8")
648
+ # Create unresolved edge - will be resolved later
649
+ edges.append(
650
+ Edge(
651
+ id=f"edge:call:{source_id}:{func_name}",
652
+ source_id=source_id,
653
+ target_id="", # Will be resolved
654
+ type="calls",
655
+ resolved=False,
656
+ raw_reference=func_name,
657
+ )
658
+ )
659
+ for child in node.children:
660
+ self._extract_calls_ts(child, source_id, edges)
661
+
662
+ def _extract_calls_ast(self, node: ast.FunctionDef, source_id: str, edges: list[Edge]) -> None:
663
+ """Extract function calls within a node (AST)."""
664
+ for child in ast.walk(node):
665
+ if isinstance(child, ast.Call):
666
+ func_name = None
667
+ if isinstance(child.func, ast.Name):
668
+ func_name = child.func.id
669
+ elif isinstance(child.func, ast.Attribute):
670
+ func_name = child.func.attr
671
+ if func_name:
672
+ edges.append(
673
+ Edge(
674
+ id=f"edge:call:{source_id}:{func_name}",
675
+ source_id=source_id,
676
+ target_id="", # Will be resolved
677
+ type="calls",
678
+ resolved=False,
679
+ raw_reference=func_name,
680
+ )
681
+ )
682
+
683
+ def _extract_import_ts(self, node, source_id: str, edges: list[Edge]) -> None:
684
+ """Extract import statements (Tree-sitter)."""
685
+ for child in node.children:
686
+ if child.type == "dotted_name":
687
+ module_name = child.text.decode("utf-8")
688
+ edges.append(
689
+ Edge(
690
+ id=f"edge:import:{source_id}:{module_name}",
691
+ source_id=source_id,
692
+ target_id="", # Will be resolved
693
+ type="imports",
694
+ resolved=False,
695
+ raw_reference=module_name,
696
+ )
697
+ )
698
+
699
+ def _extract_import_ast(self, node: ast.Import, source_id: str, edges: list[Edge]) -> None:
700
+ """Extract import statements (AST)."""
701
+ for alias in node.names:
702
+ module_name = alias.name
703
+ edges.append(
704
+ Edge(
705
+ id=f"edge:import:{source_id}:{module_name}",
706
+ source_id=source_id,
707
+ target_id="", # Will be resolved
708
+ type="imports",
709
+ resolved=False,
710
+ raw_reference=module_name,
711
+ )
712
+ )
713
+
714
+ def _extract_import_from_ts(self, node, source_id: str, edges: list[Edge]) -> None:
715
+ """Extract from ... import statements (Tree-sitter)."""
716
+ for child in node.children:
717
+ if child.type == "dotted_name":
718
+ module_name = child.text.decode("utf-8")
719
+ edges.append(
720
+ Edge(
721
+ id=f"edge:import:{source_id}:{module_name}",
722
+ source_id=source_id,
723
+ target_id="", # Will be resolved
724
+ type="imports",
725
+ resolved=False,
726
+ raw_reference=module_name,
727
+ )
728
+ )
729
+
730
+ def _extract_import_from_ast(self, node: ast.ImportFrom, source_id: str, edges: list[Edge]) -> None:
731
+ """Extract from ... import statements (AST)."""
732
+ if node.module:
733
+ module_name = node.module
734
+ edges.append(
735
+ Edge(
736
+ id=f"edge:import:{source_id}:{module_name}",
737
+ source_id=source_id,
738
+ target_id="", # Will be resolved
739
+ type="imports",
740
+ resolved=False,
741
+ raw_reference=module_name,
742
+ )
743
+ )
744
+
745
+ def _extract_inheritance_ts(self, node, class_id: str, edges: list[Edge]) -> None:
746
+ """Extract class inheritance (Tree-sitter)."""
747
+ for child in node.children:
748
+ if child.type == "argument_list":
749
+ for arg in child.children:
750
+ if arg.type == "identifier":
751
+ parent_name = arg.text.decode("utf-8")
752
+ edges.append(
753
+ Edge(
754
+ id=f"edge:inherits:{class_id}:{parent_name}",
755
+ source_id=class_id,
756
+ target_id="", # Will be resolved
757
+ type="inherits",
758
+ resolved=False,
759
+ raw_reference=parent_name,
760
+ )
761
+ )
762
+
763
+ def _extract_inheritance_ast(self, node: ast.ClassDef, class_id: str, edges: list[Edge]) -> None:
764
+ """Extract class inheritance (AST)."""
765
+ for base in node.bases:
766
+ if isinstance(base, ast.Name):
767
+ parent_name = base.id
768
+ edges.append(
769
+ Edge(
770
+ id=f"edge:inherits:{class_id}:{parent_name}",
771
+ source_id=class_id,
772
+ target_id="", # Will be resolved
773
+ type="inherits",
774
+ resolved=False,
775
+ raw_reference=parent_name,
776
+ )
777
+ )
778
+
779
+
780
+ # --------------------------------------------------------------------------- #
781
+ # Main Graph Manager
782
+ # --------------------------------------------------------------------------- #
783
+ class GraphManager:
784
+ """Main entry point for code knowledge graph operations."""
785
+
786
+ def __init__(self, workspace: str, db_path: str, languages: list[str]):
787
+ self.workspace = workspace
788
+ self.db = GraphDB(db_path)
789
+ self.graph = CodeGraph()
790
+ self.module_index = ModulePathIndex(workspace)
791
+ self.parser = PythonParser()
792
+ self.languages = languages
793
+ self._loaded = False
794
+
795
+ def load(self) -> None:
796
+ """Load the full graph from SQLite into memory."""
797
+ if self._loaded:
798
+ return
799
+ nodes = self.db.load_all_nodes()
800
+ edges = self.db.load_all_edges()
801
+ for node in nodes:
802
+ self.graph.add_node(node)
803
+ for edge in edges:
804
+ self.graph.add_edge(edge)
805
+ self._loaded = True
806
+
807
+ def build_initial(self) -> None:
808
+ """Build the initial graph for the workspace."""
809
+ self.module_index.build()
810
+ self._build_graph()
811
+ self.load()
812
+
813
+ def _build_graph(self) -> None:
814
+ """Build graph by parsing all supported language files."""
815
+ if "python" in self.languages or not self.languages:
816
+ self._build_python_graph()
817
+
818
+ def _build_python_graph(self) -> None:
819
+ """Build graph for Python files."""
820
+ workspace_path = Path(self.workspace)
821
+ for py_file in workspace_path.rglob("*.py"):
822
+ if "__pycache__" in str(py_file) or ".venv" in str(py_file):
823
+ continue
824
+ self._update_file(str(py_file))
825
+ # Resolve cross-file references
826
+ self._resolve_cross_file_references()
827
+
828
+ def _update_file(self, file_path: str) -> None:
829
+ """Update graph for a single file (wholesale replace)."""
830
+ # Delete existing nodes/edges for this file
831
+ deleted_ids = self.db.delete_nodes_for_file(file_path)
832
+ if deleted_ids:
833
+ self.graph.remove_nodes(deleted_ids)
834
+
835
+ # Parse file
836
+ nodes, edges = self.parser.parse_file(file_path)
837
+
838
+ # Insert into DB and in-memory graph
839
+ for node in nodes:
840
+ self.db.insert_node(node)
841
+ self.graph.add_node(node)
842
+ for edge in edges:
843
+ self.db.insert_edge(edge)
844
+ self.graph.add_edge(edge)
845
+
846
+ def _resolve_cross_file_references(self) -> None:
847
+ """Resolve cross-file import/call/inheritance references."""
848
+ # Resolve imports
849
+ for edge in list(self.graph.edge_by_id.values()):
850
+ if not edge.resolved and edge.type in ("imports", "calls", "inherits"):
851
+ target_path = self.module_index.resolve(edge.raw_reference or "")
852
+ if target_path:
853
+ # Find target node
854
+ target_node = self._find_target_node(edge.raw_reference or "", target_path, edge.type)
855
+ if target_node:
856
+ # Update edge
857
+ edge.target_id = target_node.id
858
+ edge.resolved = True
859
+ self.db.insert_edge(edge)
860
+ # Add the resolved edge to the in-memory graph
861
+ self.graph.graph.add_edge(
862
+ edge.source_id,
863
+ edge.target_id,
864
+ key=edge.id,
865
+ type=edge.type,
866
+ resolved=edge.resolved,
867
+ raw_reference=edge.raw_reference,
868
+ )
869
+
870
+ def _find_target_node(self, reference: str, file_path: str, edge_type: str) -> Optional[Node]:
871
+ """Find a target node by reference and file path."""
872
+ nodes_in_file = self.graph.get_nodes_by_file(file_path)
873
+ if edge_type == "imports":
874
+ # For imports, find the module node
875
+ for node in nodes_in_file:
876
+ if node.type == "Module":
877
+ return node
878
+ elif edge_type == "calls":
879
+ # For calls, find a function with matching name
880
+ ref_name = reference.split(".")[-1]
881
+ for node in nodes_in_file:
882
+ if node.type == "Function" and node.name == ref_name:
883
+ return node
884
+ elif edge_type == "inherits":
885
+ # For inheritance, find a class with matching name
886
+ for node in nodes_in_file:
887
+ if node.type == "Class" and node.name == reference:
888
+ return node
889
+ return None
890
+
891
+ def invalidate_file(self, file_path: str) -> None:
892
+ """Invalidate and re-parse a file."""
893
+ self._update_file(file_path)
894
+ self._resolve_cross_file_references()
895
+
896
+ def query_neighbors(self, node_id: str, hop_limit: int = 1, edge_types: Optional[list[str]] = None) -> list[Node]:
897
+ """Query neighbors up to hop_limit."""
898
+ if hop_limit < 1:
899
+ return []
900
+ visited = {node_id}
901
+ current_level = {node_id}
902
+ result = []
903
+
904
+ for hop in range(hop_limit):
905
+ next_level = set()
906
+ for nid in current_level:
907
+ neighbors = self.graph.get_neighbors(nid, edge_type=None, direction="out")
908
+ for neighbor in neighbors:
909
+ if neighbor.id not in visited:
910
+ visited.add(neighbor.id)
911
+ next_level.add(neighbor.id)
912
+ result.append(neighbor)
913
+ current_level = next_level
914
+ if not current_level:
915
+ break
916
+
917
+ return result
918
+
919
+ def get_node_by_qualified_name(self, qualified_name: str) -> Optional[Node]:
920
+ """Find a node by its qualified name."""
921
+ for node in self.graph.node_by_id.values():
922
+ if node.qualified_name == qualified_name:
923
+ return node
924
+ return None
925
+
926
+ def close(self) -> None:
927
+ """Clean up resources."""
928
+ self.db.close()