code-graph-builder 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 (93) hide show
  1. code_graph_builder/__init__.py +82 -0
  2. code_graph_builder/builder.py +366 -0
  3. code_graph_builder/cgb_cli.py +32 -0
  4. code_graph_builder/cli.py +564 -0
  5. code_graph_builder/commands_cli.py +1288 -0
  6. code_graph_builder/config.py +340 -0
  7. code_graph_builder/constants.py +708 -0
  8. code_graph_builder/embeddings/__init__.py +40 -0
  9. code_graph_builder/embeddings/qwen3_embedder.py +573 -0
  10. code_graph_builder/embeddings/vector_store.py +584 -0
  11. code_graph_builder/examples/__init__.py +0 -0
  12. code_graph_builder/examples/example_configuration.py +276 -0
  13. code_graph_builder/examples/example_kuzu_usage.py +109 -0
  14. code_graph_builder/examples/example_semantic_search_full.py +347 -0
  15. code_graph_builder/examples/generate_wiki.py +915 -0
  16. code_graph_builder/examples/graph_export_example.py +100 -0
  17. code_graph_builder/examples/rag_example.py +206 -0
  18. code_graph_builder/examples/test_cli_demo.py +129 -0
  19. code_graph_builder/examples/test_embedding_api.py +153 -0
  20. code_graph_builder/examples/test_kuzu_local.py +190 -0
  21. code_graph_builder/examples/test_rag_redis.py +390 -0
  22. code_graph_builder/graph_updater.py +605 -0
  23. code_graph_builder/guidance/__init__.py +1 -0
  24. code_graph_builder/guidance/agent.py +123 -0
  25. code_graph_builder/guidance/prompts.py +74 -0
  26. code_graph_builder/guidance/toolset.py +264 -0
  27. code_graph_builder/language_spec.py +536 -0
  28. code_graph_builder/mcp/__init__.py +21 -0
  29. code_graph_builder/mcp/api_doc_generator.py +764 -0
  30. code_graph_builder/mcp/file_editor.py +207 -0
  31. code_graph_builder/mcp/pipeline.py +777 -0
  32. code_graph_builder/mcp/server.py +161 -0
  33. code_graph_builder/mcp/tools.py +1800 -0
  34. code_graph_builder/models.py +115 -0
  35. code_graph_builder/parser_loader.py +344 -0
  36. code_graph_builder/parsers/__init__.py +7 -0
  37. code_graph_builder/parsers/call_processor.py +306 -0
  38. code_graph_builder/parsers/call_resolver.py +139 -0
  39. code_graph_builder/parsers/definition_processor.py +796 -0
  40. code_graph_builder/parsers/factory.py +119 -0
  41. code_graph_builder/parsers/import_processor.py +293 -0
  42. code_graph_builder/parsers/structure_processor.py +145 -0
  43. code_graph_builder/parsers/type_inference.py +143 -0
  44. code_graph_builder/parsers/utils.py +134 -0
  45. code_graph_builder/rag/__init__.py +68 -0
  46. code_graph_builder/rag/camel_agent.py +429 -0
  47. code_graph_builder/rag/client.py +298 -0
  48. code_graph_builder/rag/config.py +239 -0
  49. code_graph_builder/rag/cypher_generator.py +67 -0
  50. code_graph_builder/rag/llm_backend.py +210 -0
  51. code_graph_builder/rag/markdown_generator.py +352 -0
  52. code_graph_builder/rag/prompt_templates.py +440 -0
  53. code_graph_builder/rag/rag_engine.py +640 -0
  54. code_graph_builder/rag/review_report.md +172 -0
  55. code_graph_builder/rag/tests/__init__.py +3 -0
  56. code_graph_builder/rag/tests/test_camel_agent.py +313 -0
  57. code_graph_builder/rag/tests/test_client.py +221 -0
  58. code_graph_builder/rag/tests/test_config.py +177 -0
  59. code_graph_builder/rag/tests/test_markdown_generator.py +240 -0
  60. code_graph_builder/rag/tests/test_prompt_templates.py +160 -0
  61. code_graph_builder/services/__init__.py +39 -0
  62. code_graph_builder/services/graph_service.py +465 -0
  63. code_graph_builder/services/kuzu_service.py +665 -0
  64. code_graph_builder/services/memory_service.py +171 -0
  65. code_graph_builder/settings.py +75 -0
  66. code_graph_builder/tests/ACCEPTANCE_CRITERIA_PHASE2.md +401 -0
  67. code_graph_builder/tests/__init__.py +1 -0
  68. code_graph_builder/tests/run_acceptance_check.py +378 -0
  69. code_graph_builder/tests/test_api_find.py +231 -0
  70. code_graph_builder/tests/test_api_find_integration.py +226 -0
  71. code_graph_builder/tests/test_basic.py +78 -0
  72. code_graph_builder/tests/test_c_api_extraction.py +388 -0
  73. code_graph_builder/tests/test_call_resolution_scenarios.py +504 -0
  74. code_graph_builder/tests/test_embedder.py +411 -0
  75. code_graph_builder/tests/test_integration_semantic.py +434 -0
  76. code_graph_builder/tests/test_mcp_protocol.py +298 -0
  77. code_graph_builder/tests/test_mcp_user_flow.py +190 -0
  78. code_graph_builder/tests/test_rag.py +404 -0
  79. code_graph_builder/tests/test_settings.py +135 -0
  80. code_graph_builder/tests/test_step1_graph_build.py +264 -0
  81. code_graph_builder/tests/test_step2_api_docs.py +323 -0
  82. code_graph_builder/tests/test_step3_embedding.py +278 -0
  83. code_graph_builder/tests/test_vector_store.py +552 -0
  84. code_graph_builder/tools/__init__.py +40 -0
  85. code_graph_builder/tools/graph_query.py +495 -0
  86. code_graph_builder/tools/semantic_search.py +387 -0
  87. code_graph_builder/types.py +333 -0
  88. code_graph_builder/utils/__init__.py +0 -0
  89. code_graph_builder/utils/path_utils.py +30 -0
  90. code_graph_builder-0.2.0.dist-info/METADATA +321 -0
  91. code_graph_builder-0.2.0.dist-info/RECORD +93 -0
  92. code_graph_builder-0.2.0.dist-info/WHEEL +4 -0
  93. code_graph_builder-0.2.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,1800 @@
1
+ """MCP tool registry and handler implementations for Code Graph Builder.
2
+
3
+ Architecture: workspace-based, dynamic service loading.
4
+
5
+ Workspace layout:
6
+ {CGB_WORKSPACE}/ default: ~/.code-graph-builder/
7
+ active.txt name of the currently active artifact dir
8
+ {repo_name}_{hash8}/
9
+ meta.json {repo_path, indexed_at, wiki_page_count}
10
+ graph.db KùzuDB database
11
+ vectors.pkl embedding cache
12
+ {repo_name}_structure.pkl wiki structure cache
13
+ wiki/
14
+ index.md
15
+ wiki/
16
+ page-1.md
17
+ ...
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import pickle
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from loguru import logger
30
+
31
+ from ..embeddings.qwen3_embedder import Qwen3Embedder
32
+ from ..embeddings.vector_store import MemoryVectorStore, VectorRecord
33
+ from ..rag.cypher_generator import CypherGenerator
34
+ from ..rag.llm_backend import create_llm_backend
35
+ from ..services.kuzu_service import KuzuIngestor
36
+ from ..tools.semantic_search import SemanticSearchService
37
+ from .file_editor import FileEditor
38
+ from .pipeline import (
39
+ ProgressCb,
40
+ artifact_dir_for,
41
+ build_graph,
42
+ build_vector_index,
43
+ generate_api_docs_step,
44
+ generate_descriptions_step,
45
+ run_wiki_generation,
46
+ save_meta,
47
+ )
48
+
49
+
50
+ @dataclass
51
+ class ToolDefinition:
52
+ name: str
53
+ description: str
54
+ input_schema: dict[str, Any]
55
+
56
+
57
+ class ToolError(Exception):
58
+ """Error raised by tool handlers.
59
+
60
+ The MCP framework catches exceptions and returns ``CallToolResult`` with
61
+ ``isError=True``, so the agent can detect errors via the protocol-level
62
+ flag instead of having to parse JSON response bodies.
63
+ """
64
+
65
+ def __init__(self, error_data: dict[str, Any] | str) -> None:
66
+ if isinstance(error_data, str):
67
+ error_data = {"error": error_data}
68
+ self.error_data = error_data
69
+ super().__init__(json.dumps(error_data, ensure_ascii=False, default=str))
70
+
71
+
72
+ def _load_vector_store(vectors_path: Path) -> MemoryVectorStore:
73
+ """Load MemoryVectorStore from a pickle cache file."""
74
+ if not vectors_path.exists():
75
+ raise FileNotFoundError(f"Vectors file not found: {vectors_path}")
76
+
77
+ with open(vectors_path, "rb") as fh:
78
+ data = pickle.load(fh)
79
+
80
+ if isinstance(data, dict) and "vector_store" in data:
81
+ store = data["vector_store"]
82
+ if isinstance(store, MemoryVectorStore):
83
+ return store
84
+ raise RuntimeError(
85
+ f"'vector_store' key found but value is not MemoryVectorStore: {type(store)}"
86
+ )
87
+
88
+ if not isinstance(data, list) or len(data) == 0:
89
+ raise RuntimeError(
90
+ f"Unexpected vectors file content: expected non-empty list, got {type(data)}"
91
+ )
92
+
93
+ first = data[0]
94
+ if isinstance(first, VectorRecord):
95
+ dimension = len(first.embedding)
96
+ store = MemoryVectorStore(dimension=dimension)
97
+ store.store_embeddings_batch(data)
98
+ return store
99
+
100
+ if isinstance(first, dict) and "embedding" in first:
101
+ dimension = len(first["embedding"])
102
+ store = MemoryVectorStore(dimension=dimension)
103
+ for idx, item in enumerate(data):
104
+ store.store_embedding(
105
+ node_id=item.get("node_id", idx),
106
+ qualified_name=item.get("qualified_name", str(idx)),
107
+ embedding=item["embedding"],
108
+ metadata={
109
+ k: v
110
+ for k, v in item.items()
111
+ if k not in ("node_id", "qualified_name", "embedding")
112
+ and isinstance(v, (str, int, float, type(None)))
113
+ },
114
+ )
115
+ return store
116
+
117
+ raise RuntimeError(
118
+ f"Unrecognised vectors file format. First element type: {type(first)}"
119
+ )
120
+
121
+
122
+ class MCPToolsRegistry:
123
+ """Registry that manages workspace-based repo services and tool handlers."""
124
+
125
+ def __init__(self, workspace: Path) -> None:
126
+ self._workspace = workspace
127
+ self._workspace.mkdir(parents=True, exist_ok=True)
128
+
129
+ self._ingestor: KuzuIngestor | None = None
130
+ self._cypher_gen: CypherGenerator | None = None
131
+ self._semantic_service: SemanticSearchService | None = None
132
+ self._file_editor: FileEditor | None = None
133
+ self._active_repo_path: Path | None = None
134
+ self._active_artifact_dir: Path | None = None
135
+
136
+ self._try_auto_load()
137
+
138
+ def _try_auto_load(self) -> None:
139
+ """Try to load the last active repo from workspace."""
140
+ active_file = self._workspace / "active.txt"
141
+ if not active_file.exists():
142
+ return
143
+ artifact_dir_name = active_file.read_text(encoding="utf-8").strip()
144
+ artifact_dir = self._workspace / artifact_dir_name
145
+ if artifact_dir.exists():
146
+ try:
147
+ self._load_services(artifact_dir)
148
+ logger.info(f"Auto-loaded repo from: {artifact_dir}")
149
+ except Exception as exc:
150
+ logger.warning(f"Graph/LLM services unavailable: {exc}")
151
+
152
+ def _load_services(self, artifact_dir: Path) -> None:
153
+ """Load KuzuIngestor + CypherGenerator + SemanticSearchService from artifact dir."""
154
+ meta_file = artifact_dir / "meta.json"
155
+ if not meta_file.exists():
156
+ raise FileNotFoundError(f"meta.json not found in {artifact_dir}")
157
+
158
+ meta = json.loads(meta_file.read_text(encoding="utf-8"))
159
+ repo_path = Path(meta["repo_path"])
160
+ db_path = artifact_dir / "graph.db"
161
+ vectors_path = artifact_dir / "vectors.pkl"
162
+
163
+ self.close()
164
+ self._active_repo_path = repo_path
165
+ self._active_artifact_dir = artifact_dir
166
+ try:
167
+ self._file_editor = FileEditor(repo_path)
168
+ except Exception as exc:
169
+ logger.warning(f"File editor unavailable: {exc}")
170
+
171
+ ingestor = KuzuIngestor(db_path)
172
+ ingestor.__enter__()
173
+ self._ingestor = ingestor
174
+
175
+ llm = create_llm_backend()
176
+ cypher_gen: CypherGenerator | None = None
177
+ if llm.available:
178
+ cypher_gen = CypherGenerator(llm)
179
+ else:
180
+ logger.warning("LLM not configured — query_code_graph will be unavailable")
181
+
182
+ semantic_service: SemanticSearchService | None = None
183
+ if vectors_path.exists():
184
+ try:
185
+ vector_store = _load_vector_store(vectors_path)
186
+ from ..embeddings.qwen3_embedder import create_embedder
187
+ embedder = create_embedder(batch_size=10)
188
+ semantic_service = SemanticSearchService(
189
+ embedder=embedder,
190
+ vector_store=vector_store,
191
+ graph_service=ingestor,
192
+ )
193
+ logger.info(f"Loaded vector store: {vector_store.get_stats()}")
194
+ except Exception as exc:
195
+ logger.warning(
196
+ f"Semantic search unavailable: {exc}. "
197
+ "Check DASHSCOPE_API_KEY or EMBEDDING_API_KEY / OPENAI_API_KEY."
198
+ )
199
+
200
+ self._cypher_gen = cypher_gen
201
+ self._semantic_service = semantic_service
202
+
203
+ def _set_active(self, artifact_dir: Path) -> None:
204
+ """Mark artifact_dir as active in workspace."""
205
+ (self._workspace / "active.txt").write_text(
206
+ artifact_dir.name, encoding="utf-8"
207
+ )
208
+
209
+ def close(self) -> None:
210
+ if self._ingestor is not None:
211
+ try:
212
+ self._ingestor.__exit__(None, None, None)
213
+ except Exception:
214
+ pass
215
+ self._ingestor = None
216
+ self._file_editor = None
217
+
218
+ def _require_active(self) -> None:
219
+ """Raise :class:`ToolError` when no repository has been indexed."""
220
+ if self._ingestor is None:
221
+ raise ToolError("No repository indexed yet. Call initialize_repository first.")
222
+
223
+ def _require_repo_path(self) -> None:
224
+ """Raise :class:`ToolError` when no repository path is set."""
225
+ if self._active_repo_path is None:
226
+ raise ToolError("No repository path set. Call initialize_repository first.")
227
+
228
+ def tools(self) -> list[ToolDefinition]:
229
+ defs: list[ToolDefinition] = [
230
+ ToolDefinition(
231
+ name="initialize_repository",
232
+ description=(
233
+ "Index a code repository: builds the knowledge graph, generates vector "
234
+ "embeddings, and produces a multi-page wiki. "
235
+ "Must be called before using any query tools. "
236
+ "Takes 2-10 minutes depending on repo size."
237
+ ),
238
+ input_schema={
239
+ "type": "object",
240
+ "properties": {
241
+ "repo_path": {
242
+ "type": "string",
243
+ "description": "Absolute path to the repository to index.",
244
+ },
245
+ "rebuild": {
246
+ "type": "boolean",
247
+ "description": (
248
+ "If true, force-rebuild graph, embeddings, and wiki "
249
+ "even if cached data exists. Default: false."
250
+ ),
251
+ },
252
+ "wiki_mode": {
253
+ "type": "string",
254
+ "enum": ["comprehensive", "concise"],
255
+ "description": (
256
+ "comprehensive: 8-10 wiki pages (default). "
257
+ "concise: 4-5 wiki pages."
258
+ ),
259
+ },
260
+ "backend": {
261
+ "type": "string",
262
+ "enum": ["kuzu", "memgraph", "memory"],
263
+ "description": (
264
+ "Graph database backend. Default: kuzu (embedded, no Docker)."
265
+ ),
266
+ },
267
+ "skip_wiki": {
268
+ "type": "boolean",
269
+ "description": (
270
+ "Skip wiki generation (graph + embeddings only). "
271
+ "Use generate_wiki later to create wiki separately. "
272
+ "Default: false."
273
+ ),
274
+ },
275
+ "skip_embed": {
276
+ "type": "boolean",
277
+ "description": (
278
+ "Skip embeddings and wiki (graph only, fastest). "
279
+ "Semantic search will be unavailable. Default: false."
280
+ ),
281
+ },
282
+ },
283
+ "required": ["repo_path"],
284
+ },
285
+ ),
286
+ ToolDefinition(
287
+ name="get_repository_info",
288
+ description=(
289
+ "Return information about the currently active (indexed) repository, "
290
+ "including graph statistics (node/relationship counts), wiki pages, "
291
+ "and service availability."
292
+ ),
293
+ input_schema={"type": "object", "properties": {}, "required": []},
294
+ ),
295
+ ToolDefinition(
296
+ name="list_repositories",
297
+ description=(
298
+ "List all previously indexed repositories in the workspace. "
299
+ "Shows repo name, path, last indexed time, which pipeline steps "
300
+ "have been completed (graph, api_docs, embeddings, wiki), and "
301
+ "which one is currently active. Use this to discover available "
302
+ "repos and switch between them with switch_repository."
303
+ ),
304
+ input_schema={"type": "object", "properties": {}, "required": []},
305
+ ),
306
+ ToolDefinition(
307
+ name="switch_repository",
308
+ description=(
309
+ "Switch the active repository to a previously indexed one. "
310
+ "After switching, all query tools (query_code_graph, semantic_search, "
311
+ "list_wiki_pages, etc.) will operate on the selected repo. "
312
+ "Use list_repositories first to see available repos."
313
+ ),
314
+ input_schema={
315
+ "type": "object",
316
+ "properties": {
317
+ "repo_name": {
318
+ "type": "string",
319
+ "description": (
320
+ "Repository name or artifact directory name "
321
+ "(e.g. 'my-project' or 'my-project_a1b2c3d4'). "
322
+ "Use list_repositories to see available names."
323
+ ),
324
+ },
325
+ },
326
+ "required": ["repo_name"],
327
+ },
328
+ ),
329
+ ToolDefinition(
330
+ name="query_code_graph",
331
+ description=(
332
+ "Translate a natural-language question into Cypher and execute it "
333
+ "against the code knowledge graph. Returns raw graph rows."
334
+ ),
335
+ input_schema={
336
+ "type": "object",
337
+ "properties": {
338
+ "question": {
339
+ "type": "string",
340
+ "description": "Natural language question about the codebase.",
341
+ }
342
+ },
343
+ "required": ["question"],
344
+ },
345
+ ),
346
+ ToolDefinition(
347
+ name="get_code_snippet",
348
+ description=(
349
+ "Retrieve source code of a function, method, or class by fully qualified name."
350
+ ),
351
+ input_schema={
352
+ "type": "object",
353
+ "properties": {
354
+ "qualified_name": {
355
+ "type": "string",
356
+ "description": "Fully qualified name, e.g. 'mymodule.MyClass.my_method'.",
357
+ }
358
+ },
359
+ "required": ["qualified_name"],
360
+ },
361
+ ),
362
+ ToolDefinition(
363
+ name="semantic_search",
364
+ description=(
365
+ "Search the codebase semantically using vector embeddings. "
366
+ "Returns the most relevant functions/classes for the query. "
367
+ "Available after initialize_repository completes."
368
+ ),
369
+ input_schema={
370
+ "type": "object",
371
+ "properties": {
372
+ "query": {
373
+ "type": "string",
374
+ "description": "Natural language description of what to find.",
375
+ },
376
+ "top_k": {
377
+ "type": "integer",
378
+ "description": "Number of results. Default: 5.",
379
+ },
380
+ "entity_types": {
381
+ "type": "array",
382
+ "items": {"type": "string"},
383
+ "description": "Filter by type: 'Function', 'Class', 'Method', etc.",
384
+ },
385
+ },
386
+ "required": ["query"],
387
+ },
388
+ ),
389
+ ToolDefinition(
390
+ name="list_wiki_pages",
391
+ description="List all generated wiki pages for the active repository.",
392
+ input_schema={"type": "object", "properties": {}, "required": []},
393
+ ),
394
+ ToolDefinition(
395
+ name="get_wiki_page",
396
+ description="Read the content of a generated wiki page.",
397
+ input_schema={
398
+ "type": "object",
399
+ "properties": {
400
+ "page_id": {
401
+ "type": "string",
402
+ "description": (
403
+ "Page ID (e.g. 'page-1') or 'index' for the summary page. "
404
+ "Use list_wiki_pages to see available pages."
405
+ ),
406
+ }
407
+ },
408
+ "required": ["page_id"],
409
+ },
410
+ ),
411
+ ToolDefinition(
412
+ name="locate_function",
413
+ description=(
414
+ "Locate a function or method in the repository using Tree-sitter AST. "
415
+ "Returns the source code, start/end line numbers, and qualified name."
416
+ ),
417
+ input_schema={
418
+ "type": "object",
419
+ "properties": {
420
+ "file_path": {
421
+ "type": "string",
422
+ "description": "Relative path from repo root.",
423
+ },
424
+ "function_name": {
425
+ "type": "string",
426
+ "description": (
427
+ "Function or method name. "
428
+ "Use 'ClassName.method' to disambiguate overloads."
429
+ ),
430
+ },
431
+ "line_number": {
432
+ "type": "integer",
433
+ "description": "Optional: line number to disambiguate overloads.",
434
+ },
435
+ },
436
+ "required": ["file_path", "function_name"],
437
+ },
438
+ ),
439
+ ToolDefinition(
440
+ name="list_api_interfaces",
441
+ description=(
442
+ "List public API interfaces for a module or the entire project. "
443
+ "Returns function signatures, struct/union/enum definitions with "
444
+ "members, typedef declarations, and macro definitions. Particularly "
445
+ "useful for C codebases to understand module boundaries."
446
+ ),
447
+ input_schema={
448
+ "type": "object",
449
+ "properties": {
450
+ "module": {
451
+ "type": "string",
452
+ "description": (
453
+ "Module qualified name to query. "
454
+ "If omitted, returns APIs across all modules."
455
+ ),
456
+ },
457
+ "visibility": {
458
+ "type": "string",
459
+ "enum": ["public", "static", "extern", "all"],
460
+ "description": (
461
+ "Filter by visibility: 'public' (default) for functions "
462
+ "declared in headers, 'extern' for non-static functions "
463
+ "not in headers, 'static' for file-local functions, "
464
+ "'all' for everything."
465
+ ),
466
+ },
467
+ "include_types": {
468
+ "type": "boolean",
469
+ "description": (
470
+ "Include struct/union/enum definitions and typedefs. "
471
+ "Defaults to true."
472
+ ),
473
+ },
474
+ },
475
+ "required": [],
476
+ },
477
+ ),
478
+ ToolDefinition(
479
+ name="list_api_docs",
480
+ description=(
481
+ "List available API documentation. Returns the L1 module index "
482
+ "or the L2 module detail page listing all interfaces in that module. "
483
+ "Use this for efficient hierarchical browsing: first list modules, "
484
+ "then drill into a specific module."
485
+ ),
486
+ input_schema={
487
+ "type": "object",
488
+ "properties": {
489
+ "module": {
490
+ "type": "string",
491
+ "description": (
492
+ "Module qualified name (e.g. 'project.api'). "
493
+ "If omitted, returns the L1 index listing all modules."
494
+ ),
495
+ },
496
+ },
497
+ "required": [],
498
+ },
499
+ ),
500
+ ToolDefinition(
501
+ name="get_api_doc",
502
+ description=(
503
+ "Read the detailed API documentation for a specific function. "
504
+ "Includes signature, docstring, and full call graph "
505
+ "(who calls it and what it calls)."
506
+ ),
507
+ input_schema={
508
+ "type": "object",
509
+ "properties": {
510
+ "qualified_name": {
511
+ "type": "string",
512
+ "description": (
513
+ "Fully qualified function name "
514
+ "(e.g. 'project.api.api_init')."
515
+ ),
516
+ },
517
+ },
518
+ "required": ["qualified_name"],
519
+ },
520
+ ),
521
+ ToolDefinition(
522
+ name="find_api",
523
+ description=(
524
+ "Find relevant APIs by natural language description. "
525
+ "Combines semantic vector search with API documentation lookup "
526
+ "in a single call — returns matching functions along with their "
527
+ "signatures, docstrings, and call graphs. "
528
+ "Equivalent to running semantic_search + get_api_doc for each result."
529
+ ),
530
+ input_schema={
531
+ "type": "object",
532
+ "properties": {
533
+ "query": {
534
+ "type": "string",
535
+ "description": "Natural language description of the API to find.",
536
+ },
537
+ "top_k": {
538
+ "type": "integer",
539
+ "description": "Number of results. Default: 5.",
540
+ },
541
+ },
542
+ "required": ["query"],
543
+ },
544
+ ),
545
+ ToolDefinition(
546
+ name="generate_wiki",
547
+ description=(
548
+ "Regenerate the wiki using existing graph and embeddings. "
549
+ "Use this when wiki generation failed or you want to regenerate "
550
+ "with different settings, without rebuilding the graph or embeddings. "
551
+ "Requires initialize_repository to have been run at least once."
552
+ ),
553
+ input_schema={
554
+ "type": "object",
555
+ "properties": {
556
+ "wiki_mode": {
557
+ "type": "string",
558
+ "enum": ["comprehensive", "concise"],
559
+ "description": (
560
+ "comprehensive: 8-10 wiki pages (default). "
561
+ "concise: 4-5 wiki pages."
562
+ ),
563
+ },
564
+ "rebuild": {
565
+ "type": "boolean",
566
+ "description": (
567
+ "If true, force-regenerate wiki structure and all pages "
568
+ "even if cached. Default: false (regenerates pages only)."
569
+ ),
570
+ },
571
+ },
572
+ "required": [],
573
+ },
574
+ ),
575
+ ToolDefinition(
576
+ name="rebuild_embeddings",
577
+ description=(
578
+ "Rebuild vector embeddings using the existing knowledge graph. "
579
+ "Use this when embeddings are missing, corrupted, or when you "
580
+ "want to re-embed after changing the embedding model/config. "
581
+ "Requires a graph to have been built first "
582
+ "(via initialize_repository or build_graph)."
583
+ ),
584
+ input_schema={
585
+ "type": "object",
586
+ "properties": {
587
+ "rebuild": {
588
+ "type": "boolean",
589
+ "description": (
590
+ "If true, force-rebuild embeddings even if cached. "
591
+ "Default: false (reuses cache if available)."
592
+ ),
593
+ },
594
+ },
595
+ "required": [],
596
+ },
597
+ ),
598
+ ToolDefinition(
599
+ name="build_graph",
600
+ description=(
601
+ "Build the code knowledge graph from source code using "
602
+ "Tree-sitter AST parsing. This is step 1 of the pipeline. "
603
+ "After building, use generate_api_docs, rebuild_embeddings, "
604
+ "and generate_wiki as separate steps."
605
+ ),
606
+ input_schema={
607
+ "type": "object",
608
+ "properties": {
609
+ "repo_path": {
610
+ "type": "string",
611
+ "description": "Absolute path to the repository to index.",
612
+ },
613
+ "rebuild": {
614
+ "type": "boolean",
615
+ "description": (
616
+ "If true, force-rebuild graph even if cached. "
617
+ "Default: false."
618
+ ),
619
+ },
620
+ "backend": {
621
+ "type": "string",
622
+ "enum": ["kuzu", "memgraph", "memory"],
623
+ "description": (
624
+ "Graph database backend. Default: kuzu (embedded)."
625
+ ),
626
+ },
627
+ },
628
+ "required": ["repo_path"],
629
+ },
630
+ ),
631
+ ToolDefinition(
632
+ name="generate_api_docs",
633
+ description=(
634
+ "Generate hierarchical API documentation from the existing "
635
+ "knowledge graph. Produces L1 module index, L2 per-module pages, "
636
+ "and L3 per-function detail pages with call graphs. "
637
+ "Requires only a graph database — no embeddings or LLM needed. "
638
+ "This is step 2 of the pipeline."
639
+ ),
640
+ input_schema={
641
+ "type": "object",
642
+ "properties": {
643
+ "rebuild": {
644
+ "type": "boolean",
645
+ "description": (
646
+ "If true, force-regenerate API docs even if cached. "
647
+ "Default: false."
648
+ ),
649
+ },
650
+ },
651
+ "required": [],
652
+ },
653
+ ),
654
+ ToolDefinition(
655
+ name="prepare_guidance",
656
+ description=(
657
+ "Analyze a design document and generate a code generation "
658
+ "guidance file. An internal LLM agent searches the codebase "
659
+ "for relevant APIs, similar implementations, and dependency "
660
+ "relationships, then synthesises a structured guidance "
661
+ "Markdown document for downstream code generation."
662
+ ),
663
+ input_schema={
664
+ "type": "object",
665
+ "properties": {
666
+ "design_doc": {
667
+ "type": "string",
668
+ "description": (
669
+ "The design document content (Markdown). "
670
+ "The agent reads this, researches the codebase, "
671
+ "and produces a guidance file."
672
+ ),
673
+ },
674
+ },
675
+ "required": ["design_doc"],
676
+ },
677
+ ),
678
+ ]
679
+
680
+ return defs
681
+
682
+ def get_handler(self, name: str):
683
+ handlers: dict[str, Any] = {
684
+ "initialize_repository": self._handle_initialize_repository,
685
+ "get_repository_info": self._handle_get_repository_info,
686
+ "list_repositories": self._handle_list_repositories,
687
+ "switch_repository": self._handle_switch_repository,
688
+ "query_code_graph": self._handle_query_code_graph,
689
+ "get_code_snippet": self._handle_get_code_snippet,
690
+ "semantic_search": self._handle_semantic_search,
691
+ "list_wiki_pages": self._handle_list_wiki_pages,
692
+ "get_wiki_page": self._handle_get_wiki_page,
693
+ "locate_function": self._handle_locate_function,
694
+ "list_api_interfaces": self._handle_list_api_interfaces,
695
+ "list_api_docs": self._handle_list_api_docs,
696
+ "get_api_doc": self._handle_get_api_doc,
697
+ "find_api": self._handle_find_api,
698
+ "generate_wiki": self._handle_generate_wiki,
699
+ "rebuild_embeddings": self._handle_rebuild_embeddings,
700
+ "build_graph": self._handle_build_graph,
701
+ "generate_api_docs": self._handle_generate_api_docs,
702
+ "prepare_guidance": self._handle_prepare_guidance,
703
+ }
704
+ return handlers.get(name)
705
+
706
+ # -------------------------------------------------------------------------
707
+ # initialize_repository — runs the full pipeline in a thread pool
708
+ # -------------------------------------------------------------------------
709
+
710
+ async def _handle_initialize_repository(
711
+ self,
712
+ repo_path: str,
713
+ rebuild: bool = False,
714
+ wiki_mode: str = "comprehensive",
715
+ backend: str = "kuzu",
716
+ skip_wiki: bool = False,
717
+ skip_embed: bool = False,
718
+ _progress_cb: ProgressCb = None,
719
+ ) -> dict[str, Any]:
720
+ repo = Path(repo_path).resolve()
721
+ if not repo.exists():
722
+ raise ToolError(f"Repository path does not exist: {repo}")
723
+
724
+ loop = asyncio.get_event_loop()
725
+
726
+ def sync_progress(msg: str, pct: float = 0.0) -> None:
727
+ if _progress_cb is not None:
728
+ asyncio.run_coroutine_threadsafe(_progress_cb(msg, pct), loop)
729
+
730
+ result = await loop.run_in_executor(
731
+ None,
732
+ lambda: self._run_pipeline(
733
+ repo, rebuild, wiki_mode, sync_progress,
734
+ backend=backend, skip_wiki=skip_wiki, skip_embed=skip_embed,
735
+ ),
736
+ )
737
+ return result
738
+
739
+ def _run_pipeline(
740
+ self,
741
+ repo_path: Path,
742
+ rebuild: bool,
743
+ wiki_mode: str,
744
+ progress_cb: ProgressCb = None,
745
+ backend: str = "kuzu",
746
+ skip_wiki: bool = False,
747
+ skip_embed: bool = False,
748
+ ) -> dict[str, Any]:
749
+ """Synchronous pipeline orchestrator: graph → api_docs → embeddings → wiki.
750
+
751
+ Each step calls the same standalone pipeline functions that the
752
+ individual tool handlers use, so behaviour is identical whether
753
+ invoked from ``initialize_repository`` or step-by-step.
754
+ """
755
+ from ..examples.generate_wiki import MAX_PAGES_COMPREHENSIVE, MAX_PAGES_CONCISE
756
+
757
+ if skip_embed:
758
+ skip_wiki = True # wiki requires embeddings
759
+
760
+ artifact_dir = artifact_dir_for(self._workspace, repo_path)
761
+ artifact_dir.mkdir(parents=True, exist_ok=True)
762
+
763
+ db_path = artifact_dir / "graph.db"
764
+ vectors_path = artifact_dir / "vectors.pkl"
765
+ wiki_dir = artifact_dir / "wiki"
766
+ comprehensive = wiki_mode != "concise"
767
+ max_pages = MAX_PAGES_COMPREHENSIVE if comprehensive else MAX_PAGES_CONCISE
768
+
769
+ def _step_progress(step: int, total: int, msg: str, pct: float) -> None:
770
+ if progress_cb:
771
+ progress_cb(f"[Step {step}/{total}] {msg}", pct)
772
+
773
+ total_steps = 4
774
+ if skip_embed:
775
+ total_steps = 2 # graph + api_docs only
776
+ elif skip_wiki:
777
+ total_steps = 3 # graph + api_docs + embeddings
778
+
779
+ try:
780
+ # Step 1: build graph
781
+ builder = build_graph(
782
+ repo_path, db_path, rebuild, progress_cb=lambda msg, pct: _step_progress(1, total_steps, msg, pct),
783
+ backend=backend,
784
+ )
785
+
786
+ # Step 2: generate API docs
787
+ generate_api_docs_step(
788
+ builder, artifact_dir, rebuild,
789
+ progress_cb=lambda msg, pct: _step_progress(2, total_steps, msg, pct),
790
+ )
791
+
792
+ # Step 2b: LLM description generation for undocumented functions
793
+ generate_descriptions_step(
794
+ artifact_dir=artifact_dir,
795
+ repo_path=repo_path,
796
+ progress_cb=lambda msg, pct: _step_progress(2, total_steps, msg, pct),
797
+ )
798
+
799
+ page_count = 0
800
+ index_path = wiki_dir / "index.md"
801
+ skipped = []
802
+
803
+ if not skip_embed:
804
+ # Step 3: build embeddings
805
+ vector_store, embedder, func_map = build_vector_index(
806
+ builder, repo_path, vectors_path, rebuild,
807
+ progress_cb=lambda msg, pct: _step_progress(3, total_steps, msg, pct),
808
+ )
809
+
810
+ if not skip_wiki:
811
+ # Step 4: generate wiki
812
+ index_path, page_count = run_wiki_generation(
813
+ builder=builder,
814
+ repo_path=repo_path,
815
+ output_dir=wiki_dir,
816
+ max_pages=max_pages,
817
+ rebuild=rebuild,
818
+ comprehensive=comprehensive,
819
+ vector_store=vector_store,
820
+ embedder=embedder,
821
+ func_map=func_map,
822
+ progress_cb=lambda msg, pct: _step_progress(4, total_steps, msg, pct),
823
+ )
824
+ else:
825
+ skipped.append("wiki")
826
+ _step_progress(4, total_steps, "Wiki generation skipped.", 100.0)
827
+ else:
828
+ skipped.extend(["embed", "wiki"])
829
+ _step_progress(3, total_steps, "Embedding skipped.", 40.0)
830
+ _step_progress(4, total_steps, "Wiki skipped (requires embeddings).", 100.0)
831
+
832
+ save_meta(artifact_dir, repo_path, page_count)
833
+ self._set_active(artifact_dir)
834
+ self._load_services(artifact_dir)
835
+
836
+ return {
837
+ "status": "success",
838
+ "repo_path": str(repo_path),
839
+ "artifact_dir": str(artifact_dir),
840
+ "wiki_index": str(index_path),
841
+ "wiki_pages": page_count,
842
+ "skipped": skipped,
843
+ }
844
+
845
+ except Exception as exc:
846
+ logger.exception("Pipeline failed")
847
+ raise ToolError({"error": str(exc), "status": "error"}) from exc
848
+
849
+ # -------------------------------------------------------------------------
850
+ # get_repository_info (merged: active repo metadata + graph statistics)
851
+ # -------------------------------------------------------------------------
852
+
853
+ async def _handle_get_repository_info(self) -> dict[str, Any]:
854
+ if self._active_artifact_dir is None:
855
+ raise ToolError("No active repository. Call initialize_repository first.")
856
+
857
+ meta_file = self._active_artifact_dir / "meta.json"
858
+ meta = json.loads(meta_file.read_text(encoding="utf-8")) if meta_file.exists() else {}
859
+
860
+ wiki_pages = []
861
+ wiki_subdir = self._active_artifact_dir / "wiki" / "wiki"
862
+ if wiki_subdir.exists():
863
+ wiki_pages = [p.stem for p in sorted(wiki_subdir.glob("*.md"))]
864
+
865
+ warnings: list[str] = []
866
+ if self._semantic_service is None:
867
+ warnings.append(
868
+ "Semantic search unavailable — check embedding API keys "
869
+ "(DASHSCOPE_API_KEY or EMBEDDING_API_KEY/OPENAI_API_KEY)."
870
+ )
871
+ if self._cypher_gen is None:
872
+ warnings.append(
873
+ "Cypher query unavailable — set LLM_API_KEY, OPENAI_API_KEY, "
874
+ "or MOONSHOT_API_KEY to enable natural language queries."
875
+ )
876
+
877
+ result: dict[str, Any] = {
878
+ "repo_path": str(self._active_repo_path),
879
+ "artifact_dir": str(self._active_artifact_dir),
880
+ "indexed_at": meta.get("indexed_at"),
881
+ "semantic_search_available": self._semantic_service is not None,
882
+ "cypher_query_available": self._cypher_gen is not None,
883
+ "wiki_pages": wiki_pages,
884
+ }
885
+ if warnings:
886
+ result["warnings"] = warnings
887
+
888
+ # Merge graph statistics + language stats
889
+ if self._ingestor is not None:
890
+ try:
891
+ result["graph_stats"] = self._ingestor.get_statistics()
892
+ except Exception as exc:
893
+ result["graph_stats"] = {"error": str(exc)}
894
+
895
+ # Language extraction stats
896
+ try:
897
+ file_rows = self._ingestor.query(
898
+ "MATCH (f:File) RETURN f.path AS path"
899
+ )
900
+ from ..language_spec import get_language_for_extension
901
+ lang_counts: dict[str, int] = {}
902
+ total_files = 0
903
+ for row in file_rows:
904
+ raw = row.get("result", row)
905
+ fpath = raw[0] if isinstance(raw, (list, tuple)) else raw
906
+ if isinstance(fpath, str):
907
+ ext = Path(fpath).suffix.lower()
908
+ lang = get_language_for_extension(ext)
909
+ if lang:
910
+ lang_counts[lang.value] = lang_counts.get(lang.value, 0) + 1
911
+ total_files += 1
912
+ result["language_stats"] = {
913
+ "total_code_files": total_files,
914
+ "by_language": dict(sorted(lang_counts.items(), key=lambda x: -x[1])),
915
+ }
916
+ except Exception:
917
+ pass # language stats are optional
918
+
919
+ # Supported languages
920
+ from ..constants import LANGUAGE_METADATA, LanguageStatus
921
+ result["supported_languages"] = {
922
+ "full": [m.display_name for _, m in LANGUAGE_METADATA.items() if m.status == LanguageStatus.FULL],
923
+ "in_development": [m.display_name for _, m in LANGUAGE_METADATA.items() if m.status == LanguageStatus.DEV],
924
+ }
925
+
926
+ return result
927
+
928
+ # -------------------------------------------------------------------------
929
+ # list_repositories / switch_repository
930
+ # -------------------------------------------------------------------------
931
+
932
+ async def _handle_list_repositories(self) -> dict[str, Any]:
933
+ active_name = None
934
+ active_file = self._workspace / "active.txt"
935
+ if active_file.exists():
936
+ active_name = active_file.read_text(encoding="utf-8").strip()
937
+
938
+ repos: list[dict[str, Any]] = []
939
+ for child in sorted(self._workspace.iterdir()):
940
+ if not child.is_dir():
941
+ continue
942
+ meta_file = child / "meta.json"
943
+ if not meta_file.exists():
944
+ continue
945
+ try:
946
+ meta = json.loads(meta_file.read_text(encoding="utf-8"))
947
+ except (json.JSONDecodeError, OSError):
948
+ continue
949
+
950
+ repos.append({
951
+ "artifact_dir": child.name,
952
+ "repo_name": meta.get("repo_name", child.name),
953
+ "repo_path": meta.get("repo_path", "unknown"),
954
+ "indexed_at": meta.get("indexed_at"),
955
+ "wiki_page_count": meta.get("wiki_page_count", 0),
956
+ "steps": meta.get("steps", {}),
957
+ "active": child.name == active_name,
958
+ })
959
+
960
+ return {
961
+ "workspace": str(self._workspace),
962
+ "repository_count": len(repos),
963
+ "repositories": repos,
964
+ "hint": (
965
+ "Use switch_repository with repo_name to change the active repo. "
966
+ "Use initialize_repository or build_graph to index a new repo."
967
+ ),
968
+ }
969
+
970
+ async def _handle_switch_repository(self, repo_name: str) -> dict[str, Any]:
971
+ # Try exact match on artifact_dir name first
972
+ target: Path | None = None
973
+ for child in self._workspace.iterdir():
974
+ if not child.is_dir():
975
+ continue
976
+ if child.name == repo_name:
977
+ target = child
978
+ break
979
+
980
+ # Fallback: match by repo_name in meta.json
981
+ if target is None:
982
+ for child in sorted(self._workspace.iterdir()):
983
+ if not child.is_dir():
984
+ continue
985
+ meta_file = child / "meta.json"
986
+ if not meta_file.exists():
987
+ continue
988
+ try:
989
+ meta = json.loads(meta_file.read_text(encoding="utf-8"))
990
+ except (json.JSONDecodeError, OSError):
991
+ continue
992
+ if meta.get("repo_name") == repo_name:
993
+ target = child
994
+ break
995
+
996
+ if target is None or not (target / "meta.json").exists():
997
+ raise ToolError({
998
+ "error": f"Repository not found: {repo_name}",
999
+ "hint": "Use list_repositories to see available repos.",
1000
+ })
1001
+
1002
+ try:
1003
+ self._set_active(target)
1004
+ self._load_services(target)
1005
+ except Exception as exc:
1006
+ raise ToolError({
1007
+ "error": f"Failed to switch: {exc}",
1008
+ "repo_name": repo_name,
1009
+ }) from exc
1010
+
1011
+ meta = json.loads((target / "meta.json").read_text(encoding="utf-8"))
1012
+ return {
1013
+ "status": "success",
1014
+ "active_repo": meta.get("repo_name", target.name),
1015
+ "repo_path": meta.get("repo_path"),
1016
+ "artifact_dir": str(target),
1017
+ "steps": meta.get("steps", {}),
1018
+ }
1019
+
1020
+ # -------------------------------------------------------------------------
1021
+ # query_code_graph
1022
+ # -------------------------------------------------------------------------
1023
+
1024
+ async def _handle_query_code_graph(self, question: str) -> dict[str, Any]:
1025
+ self._require_active()
1026
+
1027
+ if self._cypher_gen is None:
1028
+ raise ToolError(
1029
+ "LLM not configured. Set one of: LLM_API_KEY, OPENAI_API_KEY, "
1030
+ "or MOONSHOT_API_KEY in the MCP server environment."
1031
+ )
1032
+ assert self._ingestor is not None
1033
+
1034
+ try:
1035
+ cypher = self._cypher_gen.generate(question)
1036
+ except Exception as exc:
1037
+ raise ToolError({"error": f"Cypher generation failed: {exc}", "question": question}) from exc
1038
+
1039
+ try:
1040
+ rows = self._ingestor.query(cypher)
1041
+ serialisable = []
1042
+ for row in rows:
1043
+ raw = row.get("result", row)
1044
+ if isinstance(raw, (list, tuple)):
1045
+ serialisable.append(list(raw))
1046
+ else:
1047
+ serialisable.append(raw)
1048
+ return {
1049
+ "question": question,
1050
+ "cypher": cypher,
1051
+ "row_count": len(serialisable),
1052
+ "rows": serialisable,
1053
+ }
1054
+ except Exception as exc:
1055
+ raise ToolError({
1056
+ "error": f"Query execution failed: {exc}",
1057
+ "question": question,
1058
+ "cypher": cypher,
1059
+ }) from exc
1060
+
1061
+ # -------------------------------------------------------------------------
1062
+ # get_code_snippet
1063
+ # -------------------------------------------------------------------------
1064
+
1065
+ async def _handle_get_code_snippet(self, qualified_name: str) -> dict[str, Any]:
1066
+ self._require_active()
1067
+
1068
+ assert self._ingestor is not None
1069
+
1070
+ safe_qn = qualified_name.replace("'", "\\'")
1071
+ cypher = (
1072
+ f"MATCH (n) WHERE n.qualified_name = '{safe_qn}' "
1073
+ "RETURN n.qualified_name, n.name, n.source_code, n.path, n.start_line, n.end_line "
1074
+ "LIMIT 1"
1075
+ )
1076
+
1077
+ try:
1078
+ rows = self._ingestor.query(cypher)
1079
+ except Exception as exc:
1080
+ raise ToolError({"error": f"Graph query failed: {exc}", "qualified_name": qualified_name}) from exc
1081
+
1082
+ if not rows:
1083
+ raise ToolError({"error": "Not found", "qualified_name": qualified_name})
1084
+
1085
+ result = rows[0].get("result", [])
1086
+ qname = result[0] if len(result) > 0 else qualified_name
1087
+ name = result[1] if len(result) > 1 else None
1088
+ source_code = result[2] if len(result) > 2 else None
1089
+ file_path = result[3] if len(result) > 3 else None
1090
+ start_line = result[4] if len(result) > 4 else None
1091
+ end_line = result[5] if len(result) > 5 else None
1092
+
1093
+ if not source_code and file_path and start_line and end_line:
1094
+ fp = Path(str(file_path))
1095
+ if not fp.is_absolute() and self._active_repo_path:
1096
+ fp = self._active_repo_path / fp
1097
+ try:
1098
+ lines = fp.read_text(encoding="utf-8", errors="ignore").splitlines(keepends=True)
1099
+ s = max(0, int(start_line) - 1)
1100
+ e = min(len(lines), int(end_line))
1101
+ source_code = "".join(lines[s:e])
1102
+ except Exception:
1103
+ pass
1104
+
1105
+ return {
1106
+ "qualified_name": qname,
1107
+ "name": name,
1108
+ "file_path": file_path,
1109
+ "start_line": start_line,
1110
+ "end_line": end_line,
1111
+ "source_code": source_code,
1112
+ }
1113
+
1114
+ # -------------------------------------------------------------------------
1115
+ # semantic_search
1116
+ # -------------------------------------------------------------------------
1117
+
1118
+ async def _handle_semantic_search(
1119
+ self,
1120
+ query: str,
1121
+ top_k: int = 5,
1122
+ entity_types: list[str] | None = None,
1123
+ ) -> dict[str, Any]:
1124
+ self._require_active()
1125
+
1126
+ if self._semantic_service is None:
1127
+ raise ToolError("Semantic search not available. Re-run initialize_repository to build embeddings.")
1128
+
1129
+ try:
1130
+ results = self._semantic_service.search(query, top_k=top_k, entity_types=entity_types)
1131
+ return {
1132
+ "query": query,
1133
+ "result_count": len(results),
1134
+ "results": [
1135
+ {
1136
+ "qualified_name": r.qualified_name,
1137
+ "name": r.name,
1138
+ "type": r.type,
1139
+ "score": r.score,
1140
+ "file_path": r.file_path,
1141
+ "start_line": r.start_line,
1142
+ "end_line": r.end_line,
1143
+ "source_code": r.source_code,
1144
+ }
1145
+ for r in results
1146
+ ],
1147
+ }
1148
+ except Exception as exc:
1149
+ raise ToolError({"error": f"Semantic search failed: {exc}", "query": query}) from exc
1150
+
1151
+ # -------------------------------------------------------------------------
1152
+ # path safety helper (used by locate_function)
1153
+ # -------------------------------------------------------------------------
1154
+
1155
+ def _safe_path(self, rel_path: str) -> Path | None:
1156
+ if self._active_repo_path is None:
1157
+ return None
1158
+ target = (self._active_repo_path / rel_path).resolve()
1159
+ try:
1160
+ target.relative_to(self._active_repo_path.resolve())
1161
+ except ValueError:
1162
+ return None
1163
+ return target
1164
+
1165
+ # -------------------------------------------------------------------------
1166
+ # wiki tools
1167
+ # -------------------------------------------------------------------------
1168
+
1169
+ def _wiki_dir(self) -> Path | None:
1170
+ if self._active_artifact_dir is None:
1171
+ return None
1172
+ return self._active_artifact_dir / "wiki"
1173
+
1174
+ async def _handle_list_wiki_pages(self) -> dict[str, Any]:
1175
+ self._require_active()
1176
+
1177
+ wiki_dir = self._wiki_dir()
1178
+ if wiki_dir is None or not wiki_dir.exists():
1179
+ raise ToolError("Wiki not generated yet. Run initialize_repository first.")
1180
+
1181
+ pages = []
1182
+ wiki_subdir = wiki_dir / "wiki"
1183
+ if wiki_subdir.exists():
1184
+ for p in sorted(wiki_subdir.glob("*.md")):
1185
+ pages.append({"page_id": p.stem, "file": f"wiki/{p.name}"})
1186
+
1187
+ index_path = wiki_dir / "index.md"
1188
+ return {
1189
+ "index_available": index_path.exists(),
1190
+ "page_count": len(pages),
1191
+ "pages": pages,
1192
+ "hint": "Use get_wiki_page with page_id='index' for the summary, or a specific page-N id.",
1193
+ }
1194
+
1195
+ async def _handle_get_wiki_page(self, page_id: str) -> dict[str, Any]:
1196
+ self._require_active()
1197
+
1198
+ wiki_dir = self._wiki_dir()
1199
+ if wiki_dir is None or not wiki_dir.exists():
1200
+ raise ToolError("Wiki not generated yet. Run initialize_repository first.")
1201
+
1202
+ if page_id == "index":
1203
+ target = wiki_dir / "index.md"
1204
+ else:
1205
+ target = wiki_dir / "wiki" / f"{page_id}.md"
1206
+
1207
+ if not target.exists():
1208
+ raise ToolError({"error": f"Wiki page not found: {page_id}", "page_id": page_id})
1209
+
1210
+ content = target.read_text(encoding="utf-8", errors="ignore")
1211
+ return {
1212
+ "page_id": page_id,
1213
+ "file_path": str(target),
1214
+ "content": content,
1215
+ }
1216
+
1217
+ # -------------------------------------------------------------------------
1218
+ # locate_function
1219
+ # -------------------------------------------------------------------------
1220
+
1221
+ async def _handle_locate_function(
1222
+ self,
1223
+ file_path: str,
1224
+ function_name: str,
1225
+ line_number: int | None = None,
1226
+ ) -> dict[str, Any]:
1227
+ self._require_repo_path()
1228
+ if self._file_editor is None:
1229
+ raise ToolError("File editor not initialized.")
1230
+
1231
+ target = self._safe_path(file_path)
1232
+ if target is None:
1233
+ raise ToolError({"error": "Path outside repository root.", "file_path": file_path})
1234
+ if not target.exists():
1235
+ raise ToolError({"error": "File not found.", "file_path": file_path})
1236
+
1237
+ result = self._file_editor.locate_function(target, function_name, line_number)
1238
+ if result is None:
1239
+ raise ToolError({
1240
+ "error": f"Function '{function_name}' not found in {file_path}.",
1241
+ "file_path": file_path,
1242
+ "function_name": function_name,
1243
+ })
1244
+ return result
1245
+
1246
+ # -------------------------------------------------------------------------
1247
+ # list_api_interfaces
1248
+ # -------------------------------------------------------------------------
1249
+
1250
+ async def _handle_list_api_interfaces(
1251
+ self,
1252
+ module: str | None = None,
1253
+ visibility: str = "public",
1254
+ include_types: bool = True,
1255
+ ) -> dict[str, Any]:
1256
+ self._require_active()
1257
+
1258
+ assert self._ingestor is not None
1259
+
1260
+ vis_filter = None if visibility == "all" else visibility
1261
+
1262
+ try:
1263
+ rows = self._ingestor.fetch_module_apis(
1264
+ module_qn=module,
1265
+ visibility=vis_filter,
1266
+ )
1267
+
1268
+ # Group function results by module
1269
+ by_module: dict[str, list[dict[str, Any]]] = {}
1270
+ for row in rows:
1271
+ raw = row.get("result", row)
1272
+ if isinstance(raw, (list, tuple)) and len(raw) >= 8:
1273
+ mod_name = raw[0] or "unknown"
1274
+ entry: dict[str, Any] = {
1275
+ "name": raw[1],
1276
+ "signature": raw[2],
1277
+ "return_type": raw[3],
1278
+ "visibility": raw[4],
1279
+ "parameters": raw[5],
1280
+ "start_line": raw[6],
1281
+ "end_line": raw[7],
1282
+ "entity_type": "function",
1283
+ }
1284
+ else:
1285
+ mod_name = raw.get("module", "unknown") if isinstance(raw, dict) else "unknown"
1286
+ entry = raw if isinstance(raw, dict) else {"raw": raw}
1287
+ if isinstance(entry, dict):
1288
+ entry["entity_type"] = "function"
1289
+
1290
+ if mod_name not in by_module:
1291
+ by_module[mod_name] = []
1292
+ by_module[mod_name].append(entry)
1293
+
1294
+ # Fetch type APIs (structs, unions, enums, typedefs) if requested
1295
+ type_count = 0
1296
+ if include_types and hasattr(self._ingestor, "fetch_module_type_apis"):
1297
+ type_rows = self._ingestor.fetch_module_type_apis(module_qn=module)
1298
+ for row in type_rows:
1299
+ raw = row.get("result", row)
1300
+ if isinstance(raw, (list, tuple)) and len(raw) >= 6:
1301
+ mod_name = raw[0] or "unknown"
1302
+ entry = {
1303
+ "name": raw[1],
1304
+ "kind": raw[2],
1305
+ "signature": raw[3],
1306
+ "members": raw[4] if len(raw) > 4 else None,
1307
+ "start_line": raw[4 if len(raw) <= 5 else 5],
1308
+ "end_line": raw[5 if len(raw) <= 6 else 6],
1309
+ "entity_type": raw[2] or "type",
1310
+ }
1311
+ else:
1312
+ mod_name = raw.get("module", "unknown") if isinstance(raw, dict) else "unknown"
1313
+ entry = raw if isinstance(raw, dict) else {"raw": raw}
1314
+
1315
+ if mod_name not in by_module:
1316
+ by_module[mod_name] = []
1317
+ by_module[mod_name].append(entry)
1318
+ type_count += 1
1319
+
1320
+ total = sum(len(v) for v in by_module.values())
1321
+ return {
1322
+ "total_apis": total,
1323
+ "function_count": total - type_count,
1324
+ "type_count": type_count,
1325
+ "module_count": len(by_module),
1326
+ "visibility_filter": visibility,
1327
+ "modules": by_module,
1328
+ }
1329
+
1330
+ except Exception as exc:
1331
+ raise ToolError(f"Failed to list API interfaces: {exc}") from exc
1332
+
1333
+ # -------------------------------------------------------------------------
1334
+ # list_api_docs / get_api_doc (hierarchical API documentation)
1335
+ # -------------------------------------------------------------------------
1336
+
1337
+ def _api_docs_dir(self) -> Path | None:
1338
+ if self._active_artifact_dir is None:
1339
+ return None
1340
+ return self._active_artifact_dir / "api_docs"
1341
+
1342
+ async def _handle_list_api_docs(
1343
+ self,
1344
+ module: str | None = None,
1345
+ ) -> dict[str, Any]:
1346
+ self._require_active()
1347
+
1348
+ api_dir = self._api_docs_dir()
1349
+ if api_dir is None or not (api_dir / "index.md").exists():
1350
+ raise ToolError(
1351
+ "API docs not generated yet. "
1352
+ "Re-run initialize_repository to generate them."
1353
+ )
1354
+
1355
+ if module:
1356
+ # L2: module detail page
1357
+ safe = module.replace("/", "_").replace("\\", "_")
1358
+ target = api_dir / "modules" / f"{safe}.md"
1359
+ if not target.exists():
1360
+ raise ToolError({
1361
+ "error": f"Module doc not found: {module}",
1362
+ "module": module,
1363
+ "hint": "Use list_api_docs (no args) to see available modules.",
1364
+ })
1365
+ return {
1366
+ "level": "module",
1367
+ "module": module,
1368
+ "content": target.read_text(encoding="utf-8", errors="ignore"),
1369
+ }
1370
+
1371
+ # L1: global index
1372
+ index_path = api_dir / "index.md"
1373
+ return {
1374
+ "level": "index",
1375
+ "content": index_path.read_text(encoding="utf-8", errors="ignore"),
1376
+ }
1377
+
1378
+ async def _handle_get_api_doc(
1379
+ self,
1380
+ qualified_name: str,
1381
+ ) -> dict[str, Any]:
1382
+ self._require_active()
1383
+
1384
+ api_dir = self._api_docs_dir()
1385
+ if api_dir is None or not (api_dir / "index.md").exists():
1386
+ raise ToolError(
1387
+ "API docs not generated yet. "
1388
+ "Re-run initialize_repository to generate them."
1389
+ )
1390
+
1391
+ safe = qualified_name.replace("/", "_").replace("\\", "_")
1392
+ target = api_dir / "funcs" / f"{safe}.md"
1393
+ if not target.exists():
1394
+ raise ToolError({
1395
+ "error": f"API doc not found: {qualified_name}",
1396
+ "qualified_name": qualified_name,
1397
+ "hint": "Use list_api_docs to browse modules first.",
1398
+ })
1399
+
1400
+ return {
1401
+ "qualified_name": qualified_name,
1402
+ "content": target.read_text(encoding="utf-8", errors="ignore"),
1403
+ }
1404
+
1405
+ # -------------------------------------------------------------------------
1406
+ # find_api (aggregated: semantic search + API doc lookup)
1407
+ # -------------------------------------------------------------------------
1408
+
1409
+ async def _handle_find_api(
1410
+ self,
1411
+ query: str,
1412
+ top_k: int = 5,
1413
+ ) -> dict[str, Any]:
1414
+ self._require_active()
1415
+
1416
+ if self._semantic_service is None:
1417
+ raise ToolError(
1418
+ "Semantic search not available. "
1419
+ "Re-run initialize_repository to build embeddings."
1420
+ )
1421
+
1422
+ try:
1423
+ results = self._semantic_service.search(query, top_k=top_k)
1424
+ except Exception as exc:
1425
+ raise ToolError(
1426
+ {"error": f"Semantic search failed: {exc}", "query": query}
1427
+ ) from exc
1428
+
1429
+ api_dir = self._api_docs_dir()
1430
+ funcs_dir = api_dir / "funcs" if api_dir else None
1431
+ has_api_docs = funcs_dir is not None and funcs_dir.exists()
1432
+
1433
+ combined = []
1434
+ for r in results:
1435
+ entry: dict[str, Any] = {
1436
+ "qualified_name": r.qualified_name,
1437
+ "name": r.name,
1438
+ "type": r.type,
1439
+ "score": r.score,
1440
+ "file_path": r.file_path,
1441
+ "start_line": r.start_line,
1442
+ "end_line": r.end_line,
1443
+ "source_code": r.source_code,
1444
+ "api_doc": None,
1445
+ }
1446
+
1447
+ if has_api_docs and r.qualified_name:
1448
+ safe_qn = r.qualified_name.replace("/", "_").replace("\\", "_")
1449
+ doc_file = funcs_dir / f"{safe_qn}.md"
1450
+ if doc_file.exists():
1451
+ entry["api_doc"] = doc_file.read_text(
1452
+ encoding="utf-8", errors="ignore"
1453
+ )
1454
+
1455
+ combined.append(entry)
1456
+
1457
+ return {
1458
+ "query": query,
1459
+ "result_count": len(combined),
1460
+ "api_docs_available": has_api_docs,
1461
+ "results": combined,
1462
+ }
1463
+
1464
+ # -------------------------------------------------------------------------
1465
+ # generate_wiki (standalone wiki regeneration)
1466
+ # -------------------------------------------------------------------------
1467
+
1468
+ async def _handle_generate_wiki(
1469
+ self,
1470
+ wiki_mode: str = "comprehensive",
1471
+ rebuild: bool = False,
1472
+ _progress_cb: ProgressCb = None,
1473
+ ) -> dict[str, Any]:
1474
+ self._require_active()
1475
+
1476
+ if self._active_artifact_dir is None or self._active_repo_path is None:
1477
+ raise ToolError("No active repository. Call initialize_repository first.")
1478
+
1479
+ artifact_dir = self._active_artifact_dir
1480
+ repo_path = self._active_repo_path
1481
+ vectors_path = artifact_dir / "vectors.pkl"
1482
+
1483
+ if not vectors_path.exists():
1484
+ raise ToolError(
1485
+ "Embeddings not found. Run initialize_repository first "
1486
+ "to build the graph and embeddings."
1487
+ )
1488
+
1489
+ loop = asyncio.get_event_loop()
1490
+
1491
+ def sync_progress(msg: str, pct: float = 0.0) -> None:
1492
+ if _progress_cb is not None:
1493
+ asyncio.run_coroutine_threadsafe(_progress_cb(msg, pct), loop)
1494
+
1495
+ result = await loop.run_in_executor(
1496
+ None,
1497
+ lambda: self._run_wiki_generation(
1498
+ repo_path, artifact_dir, vectors_path,
1499
+ wiki_mode, rebuild, sync_progress,
1500
+ ),
1501
+ )
1502
+ return result
1503
+
1504
+ def _run_wiki_generation(
1505
+ self,
1506
+ repo_path: Path,
1507
+ artifact_dir: Path,
1508
+ vectors_path: Path,
1509
+ wiki_mode: str,
1510
+ rebuild: bool,
1511
+ progress_cb: ProgressCb = None,
1512
+ ) -> dict[str, Any]:
1513
+ """Synchronous wiki generation using existing graph + embeddings."""
1514
+ from ..examples.generate_wiki import MAX_PAGES_COMPREHENSIVE, MAX_PAGES_CONCISE
1515
+
1516
+ comprehensive = wiki_mode != "concise"
1517
+ max_pages = MAX_PAGES_COMPREHENSIVE if comprehensive else MAX_PAGES_CONCISE
1518
+ wiki_dir = artifact_dir / "wiki"
1519
+
1520
+ try:
1521
+ # Load existing embeddings
1522
+ with open(vectors_path, "rb") as fh:
1523
+ cache = pickle.load(fh)
1524
+ vector_store = cache["vector_store"]
1525
+ func_map: dict[int, dict] = cache["func_map"]
1526
+ from ..embeddings.qwen3_embedder import create_embedder
1527
+ embedder = create_embedder()
1528
+
1529
+ # Delete structure cache if rebuild
1530
+ structure_cache = wiki_dir / f"{repo_path.name}_structure.pkl"
1531
+ if rebuild and structure_cache.exists():
1532
+ structure_cache.unlink()
1533
+
1534
+ assert self._ingestor is not None
1535
+
1536
+ index_path, page_count = run_wiki_generation(
1537
+ builder=self._ingestor,
1538
+ repo_path=repo_path,
1539
+ output_dir=wiki_dir,
1540
+ max_pages=max_pages,
1541
+ rebuild=rebuild,
1542
+ comprehensive=comprehensive,
1543
+ vector_store=vector_store,
1544
+ embedder=embedder,
1545
+ func_map=func_map,
1546
+ progress_cb=progress_cb,
1547
+ )
1548
+
1549
+ save_meta(artifact_dir, repo_path, page_count)
1550
+
1551
+ return {
1552
+ "status": "success",
1553
+ "repo_path": str(repo_path),
1554
+ "wiki_index": str(index_path),
1555
+ "wiki_pages": page_count,
1556
+ }
1557
+
1558
+ except Exception as exc:
1559
+ logger.exception("Wiki generation failed")
1560
+ raise ToolError({"error": str(exc), "status": "error"}) from exc
1561
+
1562
+ # -------------------------------------------------------------------------
1563
+ # rebuild_embeddings (standalone embedding rebuild)
1564
+ # -------------------------------------------------------------------------
1565
+
1566
+ async def _handle_rebuild_embeddings(
1567
+ self,
1568
+ rebuild: bool = False,
1569
+ _progress_cb: ProgressCb = None,
1570
+ ) -> dict[str, Any]:
1571
+ self._require_active()
1572
+
1573
+ if self._active_artifact_dir is None or self._active_repo_path is None:
1574
+ raise ToolError("No active repository. Call initialize_repository first.")
1575
+
1576
+ artifact_dir = self._active_artifact_dir
1577
+ repo_path = self._active_repo_path
1578
+ db_path = artifact_dir / "graph.db"
1579
+
1580
+ if not db_path.exists():
1581
+ raise ToolError(
1582
+ "Graph database not found. Run initialize_repository first "
1583
+ "to build the knowledge graph."
1584
+ )
1585
+
1586
+ loop = asyncio.get_event_loop()
1587
+
1588
+ def sync_progress(msg: str, pct: float = 0.0) -> None:
1589
+ if _progress_cb is not None:
1590
+ asyncio.run_coroutine_threadsafe(_progress_cb(msg, pct), loop)
1591
+
1592
+ result = await loop.run_in_executor(
1593
+ None,
1594
+ lambda: self._run_rebuild_embeddings(
1595
+ repo_path, artifact_dir, rebuild, sync_progress,
1596
+ ),
1597
+ )
1598
+
1599
+ # Reload services so semantic search picks up new embeddings
1600
+ self._load_services(artifact_dir)
1601
+
1602
+ return result
1603
+
1604
+ def _run_rebuild_embeddings(
1605
+ self,
1606
+ repo_path: Path,
1607
+ artifact_dir: Path,
1608
+ rebuild: bool,
1609
+ progress_cb: ProgressCb = None,
1610
+ ) -> dict[str, Any]:
1611
+ """Synchronous embedding rebuild using existing graph."""
1612
+ vectors_path = artifact_dir / "vectors.pkl"
1613
+
1614
+ try:
1615
+ assert self._ingestor is not None
1616
+
1617
+ vector_store, embedder, func_map = build_vector_index(
1618
+ self._ingestor, repo_path, vectors_path, rebuild, progress_cb
1619
+ )
1620
+
1621
+ # Preserve existing wiki_page_count in meta
1622
+ meta_file = artifact_dir / "meta.json"
1623
+ page_count = 0
1624
+ if meta_file.exists():
1625
+ meta = json.loads(meta_file.read_text(encoding="utf-8"))
1626
+ page_count = meta.get("wiki_page_count", 0)
1627
+
1628
+ save_meta(artifact_dir, repo_path, page_count)
1629
+
1630
+ return {
1631
+ "status": "success",
1632
+ "repo_path": str(repo_path),
1633
+ "vectors_path": str(vectors_path),
1634
+ "embedding_count": len(vector_store),
1635
+ }
1636
+
1637
+ except Exception as exc:
1638
+ logger.exception("Embedding rebuild failed")
1639
+ raise ToolError({"error": str(exc), "status": "error"}) from exc
1640
+
1641
+ # -------------------------------------------------------------------------
1642
+ # build_graph (standalone graph build)
1643
+ # -------------------------------------------------------------------------
1644
+
1645
+ async def _handle_build_graph(
1646
+ self,
1647
+ repo_path: str,
1648
+ rebuild: bool = False,
1649
+ backend: str = "kuzu",
1650
+ _progress_cb: ProgressCb = None,
1651
+ ) -> dict[str, Any]:
1652
+ repo = Path(repo_path).resolve()
1653
+ if not repo.exists():
1654
+ raise ToolError(f"Repository path does not exist: {repo}")
1655
+
1656
+ loop = asyncio.get_event_loop()
1657
+
1658
+ def sync_progress(msg: str, pct: float = 0.0) -> None:
1659
+ if _progress_cb is not None:
1660
+ asyncio.run_coroutine_threadsafe(_progress_cb(msg, pct), loop)
1661
+
1662
+ result = await loop.run_in_executor(
1663
+ None,
1664
+ lambda: self._run_build_graph(repo, rebuild, backend, sync_progress),
1665
+ )
1666
+ return result
1667
+
1668
+ def _run_build_graph(
1669
+ self,
1670
+ repo_path: Path,
1671
+ rebuild: bool,
1672
+ backend: str,
1673
+ progress_cb: ProgressCb = None,
1674
+ ) -> dict[str, Any]:
1675
+ """Synchronous graph build. Runs in thread pool."""
1676
+ artifact_dir = artifact_dir_for(self._workspace, repo_path)
1677
+ artifact_dir.mkdir(parents=True, exist_ok=True)
1678
+ db_path = artifact_dir / "graph.db"
1679
+
1680
+ try:
1681
+ builder = build_graph(
1682
+ repo_path, db_path, rebuild, progress_cb, backend=backend,
1683
+ )
1684
+
1685
+ stats = builder.get_statistics()
1686
+ save_meta(artifact_dir, repo_path, 0)
1687
+ self._set_active(artifact_dir)
1688
+ self._load_services(artifact_dir)
1689
+
1690
+ return {
1691
+ "status": "success",
1692
+ "repo_path": str(repo_path),
1693
+ "artifact_dir": str(artifact_dir),
1694
+ "node_count": stats.get("node_count", 0),
1695
+ "relationship_count": stats.get("relationship_count", 0),
1696
+ }
1697
+
1698
+ except Exception as exc:
1699
+ logger.exception("Graph build failed")
1700
+ raise ToolError({"error": str(exc), "status": "error"}) from exc
1701
+
1702
+ # -------------------------------------------------------------------------
1703
+ # generate_api_docs (standalone API doc generation)
1704
+ # -------------------------------------------------------------------------
1705
+
1706
+ async def _handle_generate_api_docs(
1707
+ self,
1708
+ rebuild: bool = False,
1709
+ _progress_cb: ProgressCb = None,
1710
+ ) -> dict[str, Any]:
1711
+ self._require_active()
1712
+
1713
+ if self._active_artifact_dir is None or self._active_repo_path is None:
1714
+ raise ToolError("No active repository. Call build_graph or initialize_repository first.")
1715
+
1716
+ artifact_dir = self._active_artifact_dir
1717
+
1718
+ loop = asyncio.get_event_loop()
1719
+
1720
+ def sync_progress(msg: str, pct: float = 0.0) -> None:
1721
+ if _progress_cb is not None:
1722
+ asyncio.run_coroutine_threadsafe(_progress_cb(msg, pct), loop)
1723
+
1724
+ repo_path = self._active_repo_path
1725
+ result = await loop.run_in_executor(
1726
+ None,
1727
+ lambda: self._run_generate_api_docs(artifact_dir, repo_path, rebuild, sync_progress),
1728
+ )
1729
+ return result
1730
+
1731
+ def _run_generate_api_docs(
1732
+ self,
1733
+ artifact_dir: Path,
1734
+ repo_path: Path | None,
1735
+ rebuild: bool,
1736
+ progress_cb: ProgressCb = None,
1737
+ ) -> dict[str, Any]:
1738
+ """Synchronous API docs generation from existing graph."""
1739
+ try:
1740
+ assert self._ingestor is not None
1741
+
1742
+ result = generate_api_docs_step(
1743
+ self._ingestor, artifact_dir, rebuild, progress_cb,
1744
+ )
1745
+
1746
+ # LLM description generation for undocumented functions
1747
+ if repo_path is not None:
1748
+ desc_stats = generate_descriptions_step(
1749
+ artifact_dir=artifact_dir,
1750
+ repo_path=repo_path,
1751
+ progress_cb=progress_cb,
1752
+ )
1753
+ result["desc_stats"] = desc_stats
1754
+
1755
+ return {
1756
+ "status": result.get("status", "success"),
1757
+ "artifact_dir": str(artifact_dir),
1758
+ **{k: v for k, v in result.items() if k != "status"},
1759
+ }
1760
+
1761
+ except Exception as exc:
1762
+ logger.exception("API docs generation failed")
1763
+ raise ToolError({"error": str(exc), "status": "error"}) from exc
1764
+
1765
+ # -------------------------------------------------------------------------
1766
+ # prepare_guidance
1767
+ # -------------------------------------------------------------------------
1768
+
1769
+ async def _handle_prepare_guidance(
1770
+ self,
1771
+ design_doc: str,
1772
+ ) -> dict[str, Any]:
1773
+ """Run the internal GuidanceAgent to produce a code generation guidance file."""
1774
+ self._require_active()
1775
+
1776
+ llm = create_llm_backend()
1777
+ if not llm.available:
1778
+ raise ToolError(
1779
+ "LLM not configured. Set one of: LLM_API_KEY, OPENAI_API_KEY, "
1780
+ "or MOONSHOT_API_KEY to use prepare_guidance."
1781
+ )
1782
+
1783
+ from ..guidance.agent import GuidanceAgent
1784
+ from ..guidance.toolset import MCPToolSet
1785
+
1786
+ tool_set = MCPToolSet(
1787
+ semantic_service=self._semantic_service,
1788
+ cypher_gen=self._cypher_gen,
1789
+ ingestor=self._ingestor,
1790
+ artifact_dir=self._active_artifact_dir,
1791
+ )
1792
+ agent = GuidanceAgent(toolset=tool_set, llm=llm)
1793
+
1794
+ try:
1795
+ guidance = await agent.run(design_doc)
1796
+ except Exception as exc:
1797
+ logger.exception("Guidance generation failed")
1798
+ raise ToolError({"error": str(exc), "status": "error"}) from exc
1799
+
1800
+ return {"guidance": guidance}