zoo_mcp 0.9.2__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.
- zoo_mcp/__init__.py +77 -0
- zoo_mcp/__main__.py +15 -0
- zoo_mcp/ai_tools.py +256 -0
- zoo_mcp/kcl_docs.py +268 -0
- zoo_mcp/kcl_samples.py +313 -0
- zoo_mcp/server.py +760 -0
- zoo_mcp/utils/__init__.py +0 -0
- zoo_mcp/utils/image_utils.py +100 -0
- zoo_mcp/zoo_tools.py +1179 -0
- zoo_mcp-0.9.2.dist-info/METADATA +154 -0
- zoo_mcp-0.9.2.dist-info/RECORD +14 -0
- zoo_mcp-0.9.2.dist-info/WHEEL +4 -0
- zoo_mcp-0.9.2.dist-info/entry_points.txt +3 -0
- zoo_mcp-0.9.2.dist-info/licenses/LICENSE +21 -0
zoo_mcp/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Zoo Model Context Protocol (MCP) Server.
|
|
2
|
+
|
|
3
|
+
A lightweight service that enables AI assistants to execute Zoo commands through the Model Context Protocol (MCP).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import ssl
|
|
9
|
+
import sys
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
|
|
12
|
+
import truststore
|
|
13
|
+
from kittycad import KittyCAD
|
|
14
|
+
|
|
15
|
+
FORMAT = "%(asctime)s | %(levelname)-7s | %(filename)s:%(lineno)d | %(funcName)s | %(message)s"
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(
|
|
18
|
+
level=logging.INFO, format=FORMAT, handlers=[logging.StreamHandler(sys.stderr)]
|
|
19
|
+
)
|
|
20
|
+
logger = logging.getLogger("zoo_mcp")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
__version__ = version("zoo_mcp")
|
|
25
|
+
except PackageNotFoundError:
|
|
26
|
+
# package is not installed
|
|
27
|
+
logger.error("zoo-mcp package is not installed.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ZooMCPException(Exception):
|
|
31
|
+
"""Custom exception for Zoo MCP Server."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
35
|
+
kittycad_client = KittyCAD(verify_ssl=ctx)
|
|
36
|
+
# set the websocket receive timeout to 5 minutes
|
|
37
|
+
kittycad_client.websocket_recv_timeout = 300
|
|
38
|
+
|
|
39
|
+
httpx_logger = logging.getLogger("httpx")
|
|
40
|
+
httpx_logger.setLevel(logging.WARNING)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _initialize_kcl_docs() -> None:
|
|
44
|
+
"""Initialize KCL documentation cache at module load time."""
|
|
45
|
+
from zoo_mcp.kcl_docs import initialize_docs_cache
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
try:
|
|
49
|
+
loop = asyncio.get_running_loop()
|
|
50
|
+
# Already in an event loop, schedule the task
|
|
51
|
+
loop.create_task(initialize_docs_cache())
|
|
52
|
+
except RuntimeError:
|
|
53
|
+
# No running loop, use asyncio.run()
|
|
54
|
+
asyncio.run(initialize_docs_cache())
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.warning(f"Failed to initialize KCL docs cache: {e}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _initialize_kcl_samples() -> None:
|
|
60
|
+
"""Initialize KCL samples cache at module load time."""
|
|
61
|
+
from zoo_mcp.kcl_samples import initialize_samples_cache
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
try:
|
|
65
|
+
loop = asyncio.get_running_loop()
|
|
66
|
+
# Already in an event loop, schedule the task
|
|
67
|
+
loop.create_task(initialize_samples_cache())
|
|
68
|
+
except RuntimeError:
|
|
69
|
+
# No running loop, use asyncio.run()
|
|
70
|
+
asyncio.run(initialize_samples_cache())
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.warning(f"Failed to initialize KCL samples cache: {e}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Initialize caches when module is imported
|
|
76
|
+
_initialize_kcl_docs()
|
|
77
|
+
_initialize_kcl_samples()
|
zoo_mcp/__main__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from zoo_mcp import logger
|
|
4
|
+
from zoo_mcp.server import mcp
|
|
5
|
+
|
|
6
|
+
if __name__ == "__main__":
|
|
7
|
+
try:
|
|
8
|
+
logger.info("Starting MCP server...")
|
|
9
|
+
mcp.run(transport="stdio")
|
|
10
|
+
|
|
11
|
+
except KeyboardInterrupt:
|
|
12
|
+
logger.info("Shutting down MCP server...")
|
|
13
|
+
except Exception as e:
|
|
14
|
+
logger.exception("Server encountered an error: %s", e)
|
|
15
|
+
sys.exit(1)
|
zoo_mcp/ai_tools.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from kittycad.models import (
|
|
6
|
+
ApiCallStatus,
|
|
7
|
+
FileExportFormat,
|
|
8
|
+
TextToCadCreateBody,
|
|
9
|
+
TextToCadMultiFileIterationBody,
|
|
10
|
+
)
|
|
11
|
+
from kittycad.models.ml_copilot_server_message import EndOfStream, Reasoning, ToolOutput
|
|
12
|
+
from kittycad.models.reasoning_message import (
|
|
13
|
+
OptionCreatedKclFile,
|
|
14
|
+
OptionDeletedKclFile,
|
|
15
|
+
OptionDesignPlan,
|
|
16
|
+
OptionFeatureTreeOutline,
|
|
17
|
+
OptionGeneratedKclCode,
|
|
18
|
+
OptionKclCodeError,
|
|
19
|
+
OptionKclCodeExamples,
|
|
20
|
+
OptionKclDocs,
|
|
21
|
+
OptionMarkdown,
|
|
22
|
+
OptionText,
|
|
23
|
+
OptionUpdatedKclFile,
|
|
24
|
+
)
|
|
25
|
+
from kittycad.models.text_to_cad_response import (
|
|
26
|
+
OptionTextToCad,
|
|
27
|
+
OptionTextToCadMultiFileIteration,
|
|
28
|
+
)
|
|
29
|
+
from websockets.exceptions import ConnectionClosedError
|
|
30
|
+
|
|
31
|
+
from zoo_mcp import ZooMCPException, kittycad_client, logger
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def log_websocket_message(conn_id: str) -> bool:
|
|
35
|
+
logger.info("Connecting to Text-To-CAD websocket...")
|
|
36
|
+
with kittycad_client.ml.ml_reasoning_ws(id=conn_id) as ws:
|
|
37
|
+
logger.info(
|
|
38
|
+
"Successfully connected to Text-To-CAD websocket with id %s", conn_id
|
|
39
|
+
)
|
|
40
|
+
while True:
|
|
41
|
+
try:
|
|
42
|
+
message = ws.recv()
|
|
43
|
+
if isinstance(message.root, Reasoning):
|
|
44
|
+
message_option = message.root.reasoning.root
|
|
45
|
+
match message_option:
|
|
46
|
+
case OptionCreatedKclFile():
|
|
47
|
+
logger.info(
|
|
48
|
+
"Created %s: %s"
|
|
49
|
+
% (message_option.file_name, message_option.content),
|
|
50
|
+
)
|
|
51
|
+
case OptionDeletedKclFile():
|
|
52
|
+
logger.info("Deleted %s", message_option.file_name)
|
|
53
|
+
case OptionDesignPlan():
|
|
54
|
+
design_steps = " ".join(
|
|
55
|
+
[
|
|
56
|
+
f"Editing: {step.filepath_to_edit} with these instruction {step.edit_instructions}"
|
|
57
|
+
for step in message_option.steps
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
logger.info("Design Plan: %s", design_steps)
|
|
61
|
+
case OptionFeatureTreeOutline():
|
|
62
|
+
logger.info(
|
|
63
|
+
"Feature Tree Outline: %s", message_option.content
|
|
64
|
+
)
|
|
65
|
+
case OptionGeneratedKclCode():
|
|
66
|
+
logger.info("Generated KCL code: %s", message_option.code)
|
|
67
|
+
case OptionKclCodeError():
|
|
68
|
+
logger.info("KCL Code Error: %s", message_option.error)
|
|
69
|
+
case OptionKclDocs():
|
|
70
|
+
logger.info("KCL Docs: %s", message_option.content)
|
|
71
|
+
case OptionKclCodeExamples():
|
|
72
|
+
logger.info("KCL Code Examples: %s", message_option.content)
|
|
73
|
+
case OptionMarkdown():
|
|
74
|
+
logger.info(message_option.content)
|
|
75
|
+
case OptionText():
|
|
76
|
+
logger.info(message_option.content)
|
|
77
|
+
case OptionUpdatedKclFile():
|
|
78
|
+
logger.info(
|
|
79
|
+
"Updated %s: %s"
|
|
80
|
+
% (message_option.file_name, message_option.content),
|
|
81
|
+
)
|
|
82
|
+
case _:
|
|
83
|
+
logger.info(
|
|
84
|
+
"Received unhandled reasoning message: %s",
|
|
85
|
+
type(message_option),
|
|
86
|
+
)
|
|
87
|
+
if isinstance(message.root, ToolOutput):
|
|
88
|
+
tool_result = message.root.result.root
|
|
89
|
+
if tool_result.error:
|
|
90
|
+
logger.info(
|
|
91
|
+
"Tool: %s, Error: %s"
|
|
92
|
+
% (tool_result.type, tool_result.error)
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
logger.info(
|
|
96
|
+
"Tool: %s, Output: %s"
|
|
97
|
+
% (tool_result.type, tool_result.outputs)
|
|
98
|
+
)
|
|
99
|
+
if isinstance(message.root, EndOfStream):
|
|
100
|
+
logger.info("Text-To-CAD reasoning complete.")
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
except ConnectionClosedError as e:
|
|
104
|
+
logger.info(
|
|
105
|
+
"Text To CAD could still be running but the websocket connection closed with error: %s",
|
|
106
|
+
e,
|
|
107
|
+
)
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.info(
|
|
112
|
+
"Text To CAD could still be running but an unexpected error occurred: %s",
|
|
113
|
+
e,
|
|
114
|
+
)
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def text_to_cad(prompt: str) -> str:
|
|
119
|
+
"""Send a prompt to Zoo's Text-To-CAD create endpoint
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
prompt (str): a description of the CAD model to be created
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A string containing the complete KCL code of the CAD model if Text-To-CAD was successful, otherwise an error
|
|
126
|
+
message from Text-To-CAD
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
logger.info("Sending prompt to Text-To-CAD")
|
|
130
|
+
|
|
131
|
+
# send prompt via the kittycad client
|
|
132
|
+
t2c = kittycad_client.ml.create_text_to_cad(
|
|
133
|
+
output_format=FileExportFormat.STEP,
|
|
134
|
+
kcl=True,
|
|
135
|
+
body=TextToCadCreateBody(
|
|
136
|
+
prompt=prompt,
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# get the response based on the request id
|
|
141
|
+
result = kittycad_client.ml.get_text_to_cad_part_for_user(id=t2c.id)
|
|
142
|
+
|
|
143
|
+
# check if the request has either completed or failed, otherwise sleep and try again
|
|
144
|
+
time_start = time.time()
|
|
145
|
+
ws_complete = False
|
|
146
|
+
while result.root.status not in [ApiCallStatus.COMPLETED, ApiCallStatus.FAILED]:
|
|
147
|
+
if (
|
|
148
|
+
result.root.status == ApiCallStatus.QUEUED
|
|
149
|
+
and (time.time() - time_start) % 5 == 0
|
|
150
|
+
):
|
|
151
|
+
logger.info("Text-To-CAD queued...")
|
|
152
|
+
if result.root.status == ApiCallStatus.IN_PROGRESS:
|
|
153
|
+
logger.info("Text-To-CAD in progress...")
|
|
154
|
+
if not ws_complete:
|
|
155
|
+
ws_complete = log_websocket_message(t2c.id)
|
|
156
|
+
logger.info(
|
|
157
|
+
"Waiting for Text-To-CAD to complete... status %s", result.root.status
|
|
158
|
+
)
|
|
159
|
+
result = kittycad_client.ml.get_text_to_cad_part_for_user(id=t2c.id)
|
|
160
|
+
await asyncio.sleep(1)
|
|
161
|
+
|
|
162
|
+
logger.info("Received response from Text-To-CAD")
|
|
163
|
+
|
|
164
|
+
# get the data object (root) of the response
|
|
165
|
+
response = result.root
|
|
166
|
+
|
|
167
|
+
# check the data type of the response
|
|
168
|
+
if not isinstance(response, OptionTextToCad):
|
|
169
|
+
return "Error: Text-to-CAD response is not of type OptionTextToCad."
|
|
170
|
+
|
|
171
|
+
# if Text To CAD was successful return the KCL code, otherwise return the error
|
|
172
|
+
if response.status == ApiCallStatus.COMPLETED:
|
|
173
|
+
if response.code is None:
|
|
174
|
+
return "Error: Text-to-CAD response is null."
|
|
175
|
+
return response.code
|
|
176
|
+
else:
|
|
177
|
+
if response.error is None:
|
|
178
|
+
return "Error: Text-to-CAD response is null."
|
|
179
|
+
return response.error
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def edit_kcl_project(
|
|
183
|
+
prompt: str,
|
|
184
|
+
proj_path: Path | str,
|
|
185
|
+
) -> dict | str:
|
|
186
|
+
"""Send a prompt and a KCL project to Zoo's Text-To-CAD edit KCL project endpoint. The proj_path will upload all contained files to the endpoint. There must be a main.kcl file in the root of the project.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
prompt (str): A description of the changes to be made to the KCL project associated with the provided KCL files.
|
|
190
|
+
proj_path (Path | str): A path to a directory containing a main.kcl file. All contained files (found recursively) will be sent to the endpoint.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
dict | str: A dictionary containing the complete KCL code of the CAD model if Text-To-CAD edit KCL project was successful.
|
|
194
|
+
Each key in the dict refers to a KCL file path relative to the project path, and each value is the complete KCL code for that file.
|
|
195
|
+
If unsuccessful, returns an error message from Text-To-CAD.
|
|
196
|
+
"""
|
|
197
|
+
logger.info("Sending KCL code prompt to Text-To-CAD edit kcl project")
|
|
198
|
+
|
|
199
|
+
logger.info("Finding all files in project path")
|
|
200
|
+
proj_path = Path(proj_path)
|
|
201
|
+
file_paths = list(proj_path.rglob("*"))
|
|
202
|
+
file_paths = [fp for fp in file_paths if fp.is_file()]
|
|
203
|
+
logger.info("Found %s files in project path", len(file_paths))
|
|
204
|
+
|
|
205
|
+
if not file_paths:
|
|
206
|
+
logger.error("No files paths provided or found in project path")
|
|
207
|
+
raise ZooMCPException("No file paths provided or found in project path")
|
|
208
|
+
|
|
209
|
+
if ".kcl" not in [fp.suffix for fp in file_paths]:
|
|
210
|
+
logger.error("No .kcl files found in the provided project path")
|
|
211
|
+
raise ZooMCPException("No .kcl files found in the provided project path")
|
|
212
|
+
|
|
213
|
+
if not (proj_path / "main.kcl").is_file():
|
|
214
|
+
logger.error("No main.kcl file found in the root of the provided project path")
|
|
215
|
+
raise ZooMCPException(
|
|
216
|
+
"No main.kcl file found in the root of the provided project path"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
file_attachments = {
|
|
220
|
+
str(fp.relative_to(proj_path)): str(fp.resolve()) for fp in file_paths
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
t2cmfi = kittycad_client.ml.create_text_to_cad_multi_file_iteration(
|
|
224
|
+
body=TextToCadMultiFileIterationBody(
|
|
225
|
+
source_ranges=[],
|
|
226
|
+
prompt=prompt,
|
|
227
|
+
),
|
|
228
|
+
file_attachments=file_attachments,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
log_websocket_message(t2cmfi.id)
|
|
232
|
+
|
|
233
|
+
# get the response based on the request id
|
|
234
|
+
result = kittycad_client.ml.get_text_to_cad_part_for_user(id=t2cmfi.id)
|
|
235
|
+
|
|
236
|
+
# check if the request has either completed or failed, otherwise sleep and try again
|
|
237
|
+
while result.root.status not in [ApiCallStatus.COMPLETED, ApiCallStatus.FAILED]:
|
|
238
|
+
result = kittycad_client.ml.get_text_to_cad_part_for_user(id=t2cmfi.id)
|
|
239
|
+
await asyncio.sleep(1)
|
|
240
|
+
|
|
241
|
+
# get the data object (root) of the response
|
|
242
|
+
response = result.root
|
|
243
|
+
|
|
244
|
+
# check the data type of the response
|
|
245
|
+
if not isinstance(response, OptionTextToCadMultiFileIteration):
|
|
246
|
+
return "Error: Text-to-CAD response is not of type OptionTextToCadMultiFileIteration."
|
|
247
|
+
|
|
248
|
+
# if Text To CAD iteration was successful return the KCL code, otherwise return the error
|
|
249
|
+
if response.status == ApiCallStatus.COMPLETED:
|
|
250
|
+
if response.outputs is None:
|
|
251
|
+
return "Error: Text-to-CAD edit kcl project response is null."
|
|
252
|
+
return response.outputs
|
|
253
|
+
else:
|
|
254
|
+
if response.error is None:
|
|
255
|
+
return "Error: Text-to-CAD edit kcl project response is null."
|
|
256
|
+
return response.error
|
zoo_mcp/kcl_docs.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""KCL Documentation fetching and search.
|
|
2
|
+
|
|
3
|
+
This module fetches KCL documentation from the modeling-app GitHub repository
|
|
4
|
+
at server startup and provides search functionality for LLMs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from zoo_mcp import logger
|
|
14
|
+
|
|
15
|
+
GITHUB_TREE_URL = (
|
|
16
|
+
"https://api.github.com/repos/KittyCAD/modeling-app/git/trees/main?recursive=1"
|
|
17
|
+
)
|
|
18
|
+
RAW_CONTENT_BASE = "https://raw.githubusercontent.com/KittyCAD/modeling-app/main/"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class KCLDocs:
|
|
23
|
+
"""Container for documentation data."""
|
|
24
|
+
|
|
25
|
+
docs: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
index: dict[str, list[str]] = field(
|
|
27
|
+
default_factory=lambda: {
|
|
28
|
+
"kcl-lang": [],
|
|
29
|
+
"kcl-std-functions": [],
|
|
30
|
+
"kcl-std-types": [],
|
|
31
|
+
"kcl-std-consts": [],
|
|
32
|
+
"kcl-std-modules": [],
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_instance: ClassVar["KCLDocs | None"] = None
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def get(cls) -> "KCLDocs":
|
|
40
|
+
"""Get the cached docs instance, or empty cache if not initialized."""
|
|
41
|
+
return cls._instance if cls._instance is not None else cls()
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
async def initialize(cls) -> None:
|
|
45
|
+
"""Initialize the docs cache from GitHub."""
|
|
46
|
+
if cls._instance is None:
|
|
47
|
+
cls._instance = await _fetch_docs_from_github()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _categorize_doc_path(path: str) -> str | None:
|
|
51
|
+
"""Categorize a doc path into one of the index categories."""
|
|
52
|
+
if path.startswith("docs/kcl-lang/"):
|
|
53
|
+
return "kcl-lang"
|
|
54
|
+
elif path.startswith("docs/kcl-std/functions/"):
|
|
55
|
+
return "kcl-std-functions"
|
|
56
|
+
elif path.startswith("docs/kcl-std/types/"):
|
|
57
|
+
return "kcl-std-types"
|
|
58
|
+
elif path.startswith("docs/kcl-std/consts/"):
|
|
59
|
+
return "kcl-std-consts"
|
|
60
|
+
elif path.startswith("docs/kcl-std/modules/"):
|
|
61
|
+
return "kcl-std-modules"
|
|
62
|
+
elif path.startswith("docs/kcl-std/"):
|
|
63
|
+
# Other kcl-std files (index.md, README.md)
|
|
64
|
+
return None
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_title(content: str) -> str:
|
|
69
|
+
"""Extract the title from Markdown content (first # heading)."""
|
|
70
|
+
for line in content.split("\n"):
|
|
71
|
+
line = line.strip()
|
|
72
|
+
if line.startswith("# "):
|
|
73
|
+
return line[2:].strip()
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_excerpt(content: str, query: str, context_chars: int = 200) -> str:
|
|
78
|
+
"""Extract an excerpt around the first match of query in content."""
|
|
79
|
+
query_lower = query.lower()
|
|
80
|
+
content_lower = content.lower()
|
|
81
|
+
|
|
82
|
+
pos = content_lower.find(query_lower)
|
|
83
|
+
if pos == -1:
|
|
84
|
+
# Return first context_chars of content as fallback
|
|
85
|
+
return content[:context_chars].strip() + "..."
|
|
86
|
+
|
|
87
|
+
# Find start and end positions for excerpt
|
|
88
|
+
start = max(0, pos - context_chars // 2)
|
|
89
|
+
end = min(len(content), pos + len(query) + context_chars // 2)
|
|
90
|
+
|
|
91
|
+
# Adjust to word boundaries
|
|
92
|
+
if start > 0:
|
|
93
|
+
# Find the start of the word
|
|
94
|
+
while start > 0 and content[start - 1] not in " \n\t":
|
|
95
|
+
start -= 1
|
|
96
|
+
|
|
97
|
+
if end < len(content):
|
|
98
|
+
# Find the end of the word
|
|
99
|
+
while end < len(content) and content[end] not in " \n\t":
|
|
100
|
+
end += 1
|
|
101
|
+
|
|
102
|
+
excerpt = content[start:end].strip()
|
|
103
|
+
|
|
104
|
+
# Add ellipsis
|
|
105
|
+
prefix = "..." if start > 0 else ""
|
|
106
|
+
suffix = "..." if end < len(content) else ""
|
|
107
|
+
|
|
108
|
+
return f"{prefix}{excerpt}{suffix}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _fetch_doc_content(
|
|
112
|
+
client: httpx.AsyncClient, path: str
|
|
113
|
+
) -> tuple[str, str | None]:
|
|
114
|
+
"""Fetch a single doc file's content."""
|
|
115
|
+
url = f"{RAW_CONTENT_BASE}{path}"
|
|
116
|
+
try:
|
|
117
|
+
response = await client.get(url)
|
|
118
|
+
response.raise_for_status()
|
|
119
|
+
return path, response.text
|
|
120
|
+
except httpx.HTTPError as e:
|
|
121
|
+
logger.warning(f"Failed to fetch {path}: {e}")
|
|
122
|
+
return path, None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _fetch_docs_from_github() -> KCLDocs:
|
|
126
|
+
"""Fetch all docs from GitHub and return a KCLDocs."""
|
|
127
|
+
docs = KCLDocs()
|
|
128
|
+
|
|
129
|
+
logger.info("Fetching KCL documentation from GitHub...")
|
|
130
|
+
|
|
131
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
132
|
+
# 1. Get file tree from GitHub API
|
|
133
|
+
try:
|
|
134
|
+
response = await client.get(GITHUB_TREE_URL)
|
|
135
|
+
response.raise_for_status()
|
|
136
|
+
tree_data = response.json()
|
|
137
|
+
except httpx.HTTPError as e:
|
|
138
|
+
logger.warning(f"Failed to fetch GitHub tree: {e}")
|
|
139
|
+
return docs
|
|
140
|
+
|
|
141
|
+
# 2. Filter for docs/*.md files
|
|
142
|
+
doc_paths: list[str] = []
|
|
143
|
+
for item in tree_data.get("tree", []):
|
|
144
|
+
path = item.get("path", "")
|
|
145
|
+
if (
|
|
146
|
+
path.startswith("docs/")
|
|
147
|
+
and path.endswith(".md")
|
|
148
|
+
and item.get("type") == "blob"
|
|
149
|
+
):
|
|
150
|
+
doc_paths.append(path)
|
|
151
|
+
|
|
152
|
+
logger.info(f"Found {len(doc_paths)} documentation files")
|
|
153
|
+
|
|
154
|
+
# 3. Fetch raw content in parallel
|
|
155
|
+
tasks = [_fetch_doc_content(client, path) for path in doc_paths]
|
|
156
|
+
results = await asyncio.gather(*tasks)
|
|
157
|
+
|
|
158
|
+
# 4. Populate cache and index
|
|
159
|
+
for path, content in results:
|
|
160
|
+
if content is not None:
|
|
161
|
+
docs.docs[path] = content
|
|
162
|
+
|
|
163
|
+
# Categorize the doc
|
|
164
|
+
category = _categorize_doc_path(path)
|
|
165
|
+
if category and category in docs.index:
|
|
166
|
+
docs.index[category].append(path)
|
|
167
|
+
|
|
168
|
+
# Sort the index lists
|
|
169
|
+
for category in docs.index:
|
|
170
|
+
docs.index[category].sort()
|
|
171
|
+
|
|
172
|
+
logger.info(f"KCL documentation cache initialized with {len(docs.docs)} files")
|
|
173
|
+
return docs
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def initialize_docs_cache() -> None:
|
|
177
|
+
"""Initialize the docs cache from GitHub."""
|
|
178
|
+
await KCLDocs.initialize()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def list_available_docs() -> dict[str, list[str]]:
|
|
182
|
+
"""Return categorized list of available documentation.
|
|
183
|
+
|
|
184
|
+
Returns a dictionary with the following categories:
|
|
185
|
+
- kcl-lang: KCL language documentation (syntax, types, functions, etc.)
|
|
186
|
+
- kcl-std-functions: Standard library function documentation
|
|
187
|
+
- kcl-std-types: Standard library type documentation
|
|
188
|
+
- kcl-std-consts: Standard library constants documentation
|
|
189
|
+
- kcl-std-modules: Standard library module documentation
|
|
190
|
+
|
|
191
|
+
Each category contains a list of documentation file paths that can be
|
|
192
|
+
retrieved using get_kcl_doc().
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
dict: Categories mapped to lists of available documentation paths.
|
|
196
|
+
"""
|
|
197
|
+
return KCLDocs.get().index
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def search_docs(query: str, max_results: int = 5) -> list[dict]:
|
|
201
|
+
"""Search docs by keyword.
|
|
202
|
+
|
|
203
|
+
Searches across all KCL language and standard library documentation
|
|
204
|
+
for the given query. Returns relevant excerpts with surrounding context.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
query (str): The search query (case-insensitive).
|
|
208
|
+
max_results (int): Maximum number of results to return (default: 5).
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
list[dict]: List of search results, each containing:
|
|
212
|
+
- path: The documentation file path
|
|
213
|
+
- title: The document title (from first heading)
|
|
214
|
+
- excerpt: A relevant excerpt with the match highlighted in context
|
|
215
|
+
- match_count: Number of times the query appears in the document
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
if not query or not query.strip():
|
|
219
|
+
return [{"error": "Empty search query"}]
|
|
220
|
+
|
|
221
|
+
query = query.strip()
|
|
222
|
+
query_lower = query.lower()
|
|
223
|
+
results: list[dict] = []
|
|
224
|
+
|
|
225
|
+
for path, content in KCLDocs.get().docs.items():
|
|
226
|
+
content_lower = content.lower()
|
|
227
|
+
|
|
228
|
+
# Count matches
|
|
229
|
+
match_count = content_lower.count(query_lower)
|
|
230
|
+
if match_count > 0:
|
|
231
|
+
title = _extract_title(content)
|
|
232
|
+
excerpt = _extract_excerpt(content, query)
|
|
233
|
+
|
|
234
|
+
results.append(
|
|
235
|
+
{
|
|
236
|
+
"path": path,
|
|
237
|
+
"title": title,
|
|
238
|
+
"excerpt": excerpt,
|
|
239
|
+
"match_count": match_count,
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Sort by match count (descending)
|
|
244
|
+
results.sort(key=lambda x: x["match_count"], reverse=True)
|
|
245
|
+
|
|
246
|
+
return results[:max_results]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def get_doc_content(doc_path: str) -> str | None:
|
|
250
|
+
"""Get the full content of a specific KCL documentation file.
|
|
251
|
+
|
|
252
|
+
Use list_kcl_docs() to see available documentation paths, or
|
|
253
|
+
search_kcl_docs() to find relevant documentation by keyword.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
doc_path (str): The path to the documentation file
|
|
257
|
+
(e.g., "docs/kcl-lang/functions.md" or "docs/kcl-std/functions/extrude.md")
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
str: The full Markdown content of the documentation file,
|
|
261
|
+
or an error message if not found.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
# Basic validation to prevent path traversal
|
|
265
|
+
if ".." in doc_path or not doc_path.startswith("docs/"):
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
return KCLDocs.get().docs.get(doc_path)
|