morphsdk 0.2.5__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 (61) hide show
  1. morphsdk/__init__.py +54 -0
  2. morphsdk/_agent/__init__.py +64 -0
  3. morphsdk/_agent/config.py +52 -0
  4. morphsdk/_agent/explore.py +276 -0
  5. morphsdk/_agent/github.py +57 -0
  6. morphsdk/_agent/helpers.py +133 -0
  7. morphsdk/_agent/parser.py +163 -0
  8. morphsdk/_agent/runner.py +524 -0
  9. morphsdk/_agent/tools.py +171 -0
  10. morphsdk/_agent/types.py +126 -0
  11. morphsdk/_base.py +309 -0
  12. morphsdk/_client.py +245 -0
  13. morphsdk/_config.py +37 -0
  14. morphsdk/_constants.py +53 -0
  15. morphsdk/_errors.py +111 -0
  16. morphsdk/_providers/__init__.py +36 -0
  17. morphsdk/_providers/_filter.py +92 -0
  18. morphsdk/_providers/base.py +94 -0
  19. morphsdk/_providers/code_storage_http.py +104 -0
  20. morphsdk/_providers/local.py +270 -0
  21. morphsdk/_providers/remote.py +161 -0
  22. morphsdk/_version.py +1 -0
  23. morphsdk/adapters/__init__.py +1 -0
  24. morphsdk/adapters/anthropic.py +360 -0
  25. morphsdk/adapters/langchain.py +120 -0
  26. morphsdk/adapters/openai.py +500 -0
  27. morphsdk/py.typed +0 -0
  28. morphsdk/resources/__init__.py +0 -0
  29. morphsdk/resources/browser.py +919 -0
  30. morphsdk/resources/compact.py +133 -0
  31. morphsdk/resources/edit.py +506 -0
  32. morphsdk/resources/explore.py +333 -0
  33. morphsdk/resources/git.py +861 -0
  34. morphsdk/resources/github.py +1214 -0
  35. morphsdk/resources/grep.py +583 -0
  36. morphsdk/resources/mobile.py +134 -0
  37. morphsdk/resources/reflex.py +414 -0
  38. morphsdk/resources/router.py +124 -0
  39. morphsdk/resources/search.py +110 -0
  40. morphsdk/tracing/__init__.py +70 -0
  41. morphsdk/tracing/_otel.py +101 -0
  42. morphsdk/tracing/core.py +249 -0
  43. morphsdk/tracing/interaction.py +284 -0
  44. morphsdk/tracing/otel.py +75 -0
  45. morphsdk/tracing/reflex.py +58 -0
  46. morphsdk/tracing/types.py +163 -0
  47. morphsdk/types/__init__.py +140 -0
  48. morphsdk/types/browser.py +118 -0
  49. morphsdk/types/compact.py +41 -0
  50. morphsdk/types/edit.py +31 -0
  51. morphsdk/types/explore.py +42 -0
  52. morphsdk/types/git.py +25 -0
  53. morphsdk/types/github.py +111 -0
  54. morphsdk/types/grep.py +41 -0
  55. morphsdk/types/mobile.py +25 -0
  56. morphsdk/types/reflex.py +137 -0
  57. morphsdk/types/router.py +21 -0
  58. morphsdk/types/search.py +33 -0
  59. morphsdk-0.2.5.dist-info/METADATA +226 -0
  60. morphsdk-0.2.5.dist-info/RECORD +61 -0
  61. morphsdk-0.2.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,104 @@
1
+ """HTTP-backed :class:`RemoteCommands` for the Morph code-storage service.
2
+
3
+ Translates grep/read/list into POST requests against the code-search endpoints
4
+ so the WarpGrep agent can search GitHub repos indexed by Morph.
5
+
6
+ Wire contract (must match the TypeScript ``createCodeStorageHttpCommands``):
7
+
8
+ * ``POST {base_url}/api/code-search/{repo_id}/grep`` body ``{pattern, path, glob, branch}``
9
+ * ``POST {base_url}/api/code-search/{repo_id}/read`` body ``{path, start, end, branch}``
10
+ * ``POST {base_url}/api/code-search/{repo_id}/list`` body ``{path, maxDepth, branch}``
11
+
12
+ Each endpoint returns ``{"stdout": "..."}`` on success. Non-2xx responses are
13
+ returned as ``"Error: ..."`` strings (so the agent handles them gracefully); a
14
+ ``{"error": ...}`` body on a 2xx response raises.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from urllib.parse import quote
20
+
21
+ import httpx
22
+
23
+ from .remote import RemoteCommands
24
+
25
+
26
+ class CodeStorageHttpConfig:
27
+ """Connection config for the code-search HTTP backend."""
28
+
29
+ def __init__(self, *, base_url: str, repo_id: str, branch: str) -> None:
30
+ self.base_url = base_url
31
+ self.repo_id = repo_id
32
+ self.branch = branch
33
+
34
+
35
+ async def _post(
36
+ client: httpx.AsyncClient,
37
+ url: str,
38
+ body: dict[str, object],
39
+ op: str,
40
+ ) -> str:
41
+ res = await client.post(url, json=body, headers={"Content-Type": "application/json"})
42
+ if not res.is_success:
43
+ try:
44
+ text = res.text
45
+ except Exception: # noqa: BLE001
46
+ text = res.reason_phrase
47
+ # Return errors as content (not raised) so the agent can recover.
48
+ try:
49
+ parsed = res.json()
50
+ if isinstance(parsed, dict) and parsed.get("error"):
51
+ return f"Error: {parsed['error']}"
52
+ except Exception: # noqa: BLE001 - not JSON, fall through
53
+ pass
54
+ return f"Error: {op} failed ({res.status_code}): {text}"
55
+
56
+ data = res.json()
57
+ if isinstance(data, dict) and data.get("error"):
58
+ raise RuntimeError(str(data["error"]))
59
+ return str(data["stdout"])
60
+
61
+
62
+ def create_code_storage_http_commands(
63
+ config: CodeStorageHttpConfig,
64
+ *,
65
+ client: httpx.AsyncClient | None = None,
66
+ ) -> RemoteCommands:
67
+ """Build a :class:`RemoteCommands` bundle backed by the code-search HTTP API.
68
+
69
+ Pass an existing ``httpx.AsyncClient`` to reuse a connection pool; otherwise a
70
+ short-lived client is created per call.
71
+ """
72
+ base_url = config.base_url
73
+ branch = config.branch
74
+ encoded_repo_id = quote(config.repo_id, safe="")
75
+ prefix = f"{base_url}/api/code-search/{encoded_repo_id}"
76
+
77
+ async def _call(url: str, body: dict[str, object], op: str) -> str:
78
+ if client is not None:
79
+ return await _post(client, url, body, op)
80
+ async with httpx.AsyncClient() as owned:
81
+ return await _post(owned, url, body, op)
82
+
83
+ async def grep(pattern: str, path: str, glob: str | None) -> str:
84
+ return await _call(
85
+ f"{prefix}/grep",
86
+ {"pattern": pattern, "path": path, "glob": glob, "branch": branch},
87
+ "grep",
88
+ )
89
+
90
+ async def read(path: str, start: int, end: int) -> str:
91
+ return await _call(
92
+ f"{prefix}/read",
93
+ {"path": path, "start": start, "end": end, "branch": branch},
94
+ "read",
95
+ )
96
+
97
+ async def list_dir(path: str, max_depth: int) -> str:
98
+ return await _call(
99
+ f"{prefix}/list",
100
+ {"path": path, "maxDepth": max_depth, "branch": branch},
101
+ "list",
102
+ )
103
+
104
+ return RemoteCommands(grep=grep, read=read, list_dir=list_dir)
@@ -0,0 +1,270 @@
1
+ """Local ripgrep-backed WarpGrep provider.
2
+
3
+ Faithful port of the TypeScript ``LocalRipgrepProvider``: it shells out to
4
+ ``rg`` for grep, walks the tree for ``list_directory``, and reads files with
5
+ 1-indexed inclusive line ranges. There is intentionally **no** Python-native
6
+ search fallback -- the TS does not have one either; when ``rg`` is unavailable
7
+ it returns a graceful, agent-readable error.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import math
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import time
19
+ from dataclasses import dataclass
20
+
21
+ from morphsdk._constants import (
22
+ DEFAULT_EXCLUDES,
23
+ WARP_GREP_MAX_LIST_DEPTH,
24
+ WARP_GREP_MAX_OUTPUT_LINES,
25
+ WARP_GREP_MAX_READ_LINES,
26
+ )
27
+
28
+ from ._filter import (
29
+ is_textual_file,
30
+ resolve_under_repo,
31
+ should_skip,
32
+ to_repo_relative,
33
+ )
34
+ from .base import GrepResult, ListDirectoryEntry, ReadResult, WarpGrepProvider
35
+
36
+ # Directory walks abort after this many milliseconds (mirrors LIST_TIMEOUT_MS).
37
+ _LIST_TIMEOUT_MS = 2_000
38
+
39
+ _RIPGREP_NOT_AVAILABLE = (
40
+ "[RIPGREP NOT AVAILABLE] ripgrep (rg) is required but failed to execute. "
41
+ "Please install it:\n"
42
+ " • macOS: brew install ripgrep\n"
43
+ " • Ubuntu/Debian: apt install ripgrep\n"
44
+ " • Windows: choco install ripgrep\n"
45
+ " • Or visit: https://github.com/BurntSushi/ripgrep#installation"
46
+ )
47
+
48
+
49
+ @dataclass
50
+ class _ExecResult:
51
+ stdout: str
52
+ stderr: str
53
+ exit_code: int
54
+
55
+
56
+ def _run_ripgrep(args: list[str], cwd: str) -> _ExecResult:
57
+ """Invoke ``rg`` synchronously. Returns ``exit_code == -1`` if it cannot spawn."""
58
+ rg = shutil.which("rg")
59
+ if rg is None:
60
+ return _ExecResult(stdout="", stderr="ripgrep (rg) not found on PATH.", exit_code=-1)
61
+ try:
62
+ proc = subprocess.run(
63
+ [rg, *args],
64
+ cwd=cwd,
65
+ stdin=subprocess.DEVNULL,
66
+ capture_output=True,
67
+ text=True,
68
+ )
69
+ except OSError as err: # pragma: no cover - spawn failure is environment-specific
70
+ return _ExecResult(stdout="", stderr=f"Failed to spawn ripgrep: {err}", exit_code=-1)
71
+ return _ExecResult(stdout=proc.stdout, stderr=proc.stderr, exit_code=proc.returncode)
72
+
73
+
74
+ def _truncate(lines: list[str], limit: int, unit: str) -> list[str]:
75
+ """Truncate *lines* to *limit*, appending a marker line when it overflows."""
76
+ if len(lines) <= limit:
77
+ return lines
78
+ truncated = lines[:limit]
79
+ truncated.append(f"... (output truncated at {limit} of {len(lines)} {unit})")
80
+ return truncated
81
+
82
+
83
+ class LocalRipgrepProvider(WarpGrepProvider):
84
+ """Searches a local checkout via ``rg`` and direct filesystem access."""
85
+
86
+ def __init__(
87
+ self,
88
+ repo_root: str,
89
+ excludes: list[str] | None = None,
90
+ *,
91
+ allow_names: list[str] | None = None,
92
+ ) -> None:
93
+ self._repo_root = repo_root
94
+ self._excludes = DEFAULT_EXCLUDES if excludes is None else excludes
95
+ self._allow_names = frozenset(allow_names) if allow_names else None
96
+
97
+ async def grep(
98
+ self,
99
+ *,
100
+ pattern: str,
101
+ path: str,
102
+ glob: str | None = None,
103
+ context_lines: int | None = None,
104
+ case_sensitive: bool | None = None,
105
+ ) -> GrepResult:
106
+ try:
107
+ abs_path = resolve_under_repo(self._repo_root, path)
108
+ except ValueError as err:
109
+ return GrepResult(error=f"[PATH ERROR] {err}")
110
+
111
+ if not os.path.exists(abs_path):
112
+ return GrepResult()
113
+
114
+ if abs_path == os.path.abspath(self._repo_root):
115
+ target_arg = "."
116
+ else:
117
+ target_arg = to_repo_relative(self._repo_root, abs_path)
118
+
119
+ context = str(context_lines) if context_lines is not None else "1"
120
+ args = [
121
+ "--no-config",
122
+ "--no-heading",
123
+ "--with-filename",
124
+ "--line-number",
125
+ "--color=never",
126
+ "--trim",
127
+ "--max-columns=400",
128
+ "-C",
129
+ context,
130
+ ]
131
+ if case_sensitive is False:
132
+ args.append("--ignore-case")
133
+ if glob:
134
+ args += ["--glob", glob]
135
+ for exclude in self._excludes:
136
+ if self._allow_names is not None and exclude in self._allow_names:
137
+ continue
138
+ args += ["-g", f"!{exclude}"]
139
+ args.append(pattern)
140
+ args.append(target_arg or ".")
141
+
142
+ res = await asyncio.to_thread(_run_ripgrep, args, self._repo_root)
143
+
144
+ if res.exit_code == -1:
145
+ detail = "\nExit code: -1" + (f"\nDetails: {res.stderr}" if res.stderr else "")
146
+ return GrepResult(error=_RIPGREP_NOT_AVAILABLE + detail)
147
+ # rg exits 1 when there are simply no matches -- that is not an error.
148
+ if res.exit_code not in (0, 1):
149
+ suffix = f": {res.stderr}" if res.stderr else ""
150
+ return GrepResult(
151
+ error=f"[RIPGREP ERROR] grep failed with exit code {res.exit_code}{suffix}"
152
+ )
153
+
154
+ lines = [line for line in res.stdout.strip().splitlines() if line]
155
+ return GrepResult(lines=_truncate(lines, WARP_GREP_MAX_OUTPUT_LINES, "lines"))
156
+
157
+ async def read(
158
+ self,
159
+ *,
160
+ path: str,
161
+ start_line: int | None = None,
162
+ end_line: int | None = None,
163
+ ) -> ReadResult:
164
+ try:
165
+ abs_path = resolve_under_repo(self._repo_root, path)
166
+ except ValueError as err:
167
+ return ReadResult(error=f"[PATH ERROR] {err}")
168
+
169
+ if not os.path.isfile(abs_path):
170
+ return ReadResult(
171
+ error=(
172
+ f'[FILE NOT FOUND] You tried to read "{path}" but there is no file at this '
173
+ "path. Double-check the path exists and is spelled correctly."
174
+ )
175
+ )
176
+ if os.path.islink(abs_path):
177
+ return ReadResult(
178
+ error=(
179
+ f'[SYMLINK] You tried to read "{path}" but this is a symlink. '
180
+ "Try reading the actual file it points to instead."
181
+ )
182
+ )
183
+ if not is_textual_file(abs_path):
184
+ return ReadResult(
185
+ error=(
186
+ f'[UNREADABLE FILE] You tried to read "{path}" but this file is either too '
187
+ "large or not a text file, so it cannot be read. Try a different file."
188
+ )
189
+ )
190
+
191
+ try:
192
+ content = await asyncio.to_thread(_read_text, abs_path)
193
+ except OSError as err:
194
+ return ReadResult(error=f'[READ ERROR] Failed to read "{path}": {err}')
195
+
196
+ # Preserve newline-driven line count, matching JS split(/\r?\n/).
197
+ file_lines = content.split("\n")
198
+ total = len(file_lines)
199
+
200
+ start_valid = start_line is None or (math.isfinite(start_line) and start_line > 0)
201
+ end_valid = end_line is None or (math.isfinite(end_line) and end_line > 0)
202
+
203
+ start = 1
204
+ end = total
205
+ if start_valid and end_valid:
206
+ start = start_line if start_line is not None else 1
207
+ end = min(end_line if end_line is not None else total, total)
208
+ if (start > total and total > 0) or start > end:
209
+ start, end = 1, total
210
+
211
+ out = [f"{i}|{file_lines[i - 1] if i - 1 < total else ''}" for i in range(start, end + 1)]
212
+ return ReadResult(lines=_truncate(out, WARP_GREP_MAX_READ_LINES, "lines"))
213
+
214
+ async def list_directory(
215
+ self,
216
+ *,
217
+ path: str,
218
+ pattern: str | None = None,
219
+ max_results: int | None = None,
220
+ max_depth: int | None = None,
221
+ ) -> list[ListDirectoryEntry]:
222
+ try:
223
+ abs_path = resolve_under_repo(self._repo_root, path)
224
+ except ValueError:
225
+ return []
226
+ if not os.path.isdir(abs_path):
227
+ return []
228
+
229
+ limit = max_results if max_results is not None else WARP_GREP_MAX_OUTPUT_LINES
230
+ depth_cap = max_depth if max_depth is not None else WARP_GREP_MAX_LIST_DEPTH
231
+ regex = re.compile(pattern) if pattern else None
232
+
233
+ results: list[ListDirectoryEntry] = []
234
+ deadline = time.monotonic() + _LIST_TIMEOUT_MS / 1000
235
+
236
+ def walk(directory: str, depth: int) -> None:
237
+ if time.monotonic() > deadline:
238
+ return
239
+ if depth > depth_cap or len(results) >= limit:
240
+ return
241
+ try:
242
+ entries = sorted(os.scandir(directory), key=lambda e: e.name)
243
+ except OSError:
244
+ return
245
+ for entry in entries:
246
+ if time.monotonic() > deadline or len(results) >= limit:
247
+ break
248
+ if should_skip(entry.name, self._allow_names):
249
+ continue
250
+ if regex is not None and not regex.search(entry.name):
251
+ continue
252
+ is_dir = entry.is_dir(follow_symlinks=False)
253
+ results.append(
254
+ ListDirectoryEntry(
255
+ name=entry.name,
256
+ path=to_repo_relative(self._repo_root, entry.path),
257
+ type="dir" if is_dir else "file",
258
+ depth=depth,
259
+ )
260
+ )
261
+ if is_dir:
262
+ walk(entry.path, depth + 1)
263
+
264
+ await asyncio.to_thread(walk, abs_path, 0)
265
+ return results
266
+
267
+
268
+ def _read_text(file_path: str) -> str:
269
+ with open(file_path, encoding="utf-8", errors="replace") as fh:
270
+ return fh.read()
@@ -0,0 +1,161 @@
1
+ """Remote-command-backed WarpGrep provider.
2
+
3
+ Wraps three user-supplied async callables (``grep`` / ``read`` / ``list_dir``)
4
+ that each return raw stdout, and handles all parsing/formatting internally --
5
+ mirroring the TypeScript ``RemoteCommandsProvider``. Pair it with
6
+ :func:`~morphsdk._providers.code_storage_http.create_code_storage_http_commands`
7
+ to search GitHub repos indexed by the Morph code-storage service, or supply your
8
+ own sandbox commands.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from collections.abc import Awaitable
15
+ from typing import Callable
16
+
17
+ from morphsdk._constants import (
18
+ WARP_GREP_MAX_LIST_DEPTH,
19
+ WARP_GREP_MAX_OUTPUT_LINES,
20
+ WARP_GREP_MAX_READ_LINES,
21
+ )
22
+
23
+ from ._filter import should_skip
24
+ from .base import GrepResult, ListDirectoryEntry, ReadResult, WarpGrepProvider
25
+
26
+ # Sentinel matching the TS read default (`end ?? 1_000_000`).
27
+ _READ_END_DEFAULT = 1_000_000
28
+
29
+ #: ``grep(pattern, path, glob)`` -> raw stdout.
30
+ GrepCommand = Callable[[str, str, "str | None"], Awaitable[str]]
31
+ #: ``read(path, start, end)`` -> raw file content.
32
+ ReadCommand = Callable[[str, int, int], Awaitable[str]]
33
+ #: ``list_dir(path, max_depth)`` -> raw newline-delimited paths.
34
+ ListDirCommand = Callable[[str, int], Awaitable[str]]
35
+
36
+
37
+ class RemoteCommands:
38
+ """Bundle of the three remote command callables consumed by the provider."""
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ grep: GrepCommand,
44
+ read: ReadCommand,
45
+ list_dir: ListDirCommand,
46
+ ) -> None:
47
+ self.grep = grep
48
+ self.read = read
49
+ self.list_dir = list_dir
50
+
51
+
52
+ def _truncate(lines: list[str], limit: int, unit: str) -> list[str]:
53
+ if len(lines) <= limit:
54
+ return lines
55
+ truncated = lines[:limit]
56
+ truncated.append(f"... (output truncated at {limit} of {len(lines)} {unit})")
57
+ return truncated
58
+
59
+
60
+ class RemoteCommandsProvider(WarpGrepProvider):
61
+ """Adapts a :class:`RemoteCommands` bundle into a full provider."""
62
+
63
+ def __init__(self, repo_root: str, commands: RemoteCommands) -> None:
64
+ self._repo_root = repo_root
65
+ self._commands = commands
66
+
67
+ async def grep(
68
+ self,
69
+ *,
70
+ pattern: str,
71
+ path: str,
72
+ glob: str | None = None,
73
+ context_lines: int | None = None,
74
+ case_sensitive: bool | None = None,
75
+ ) -> GrepResult:
76
+ try:
77
+ stdout = await self._commands.grep(pattern, path, glob)
78
+ except Exception as err: # noqa: BLE001 - surface as graceful agent error
79
+ return GrepResult(error=f"[GREP ERROR] {err}")
80
+ lines = [line for line in (stdout or "").strip().splitlines() if line]
81
+ return GrepResult(lines=_truncate(lines, WARP_GREP_MAX_OUTPUT_LINES, "lines"))
82
+
83
+ async def read(
84
+ self,
85
+ *,
86
+ path: str,
87
+ start_line: int | None = None,
88
+ end_line: int | None = None,
89
+ ) -> ReadResult:
90
+ start_valid = start_line is None or start_line > 0
91
+ end_valid = end_line is None or end_line > 0
92
+ range_valid = (
93
+ start_valid
94
+ and end_valid
95
+ and (start_line is None or end_line is None or start_line <= end_line)
96
+ )
97
+
98
+ start = start_line if (range_valid and start_line is not None) else 1
99
+ end = end_line if (range_valid and end_line is not None) else _READ_END_DEFAULT
100
+
101
+ try:
102
+ stdout = await self._commands.read(path, start, end)
103
+ except Exception as err: # noqa: BLE001
104
+ return ReadResult(error=f"[READ ERROR] {err}")
105
+
106
+ content_lines = (stdout or "").split("\n")
107
+ # Drop the trailing empty element left by a trailing newline (e.g. sed output).
108
+ if content_lines and content_lines[-1] == "":
109
+ content_lines.pop()
110
+
111
+ lines = [f"{start + idx}|{content}" for idx, content in enumerate(content_lines)]
112
+ return ReadResult(lines=_truncate(lines, WARP_GREP_MAX_READ_LINES, "lines"))
113
+
114
+ async def list_directory(
115
+ self,
116
+ *,
117
+ path: str,
118
+ pattern: str | None = None,
119
+ max_results: int | None = None,
120
+ max_depth: int | None = None,
121
+ ) -> list[ListDirectoryEntry]:
122
+ depth = max_depth if max_depth is not None else WARP_GREP_MAX_LIST_DEPTH
123
+ limit = max_results if max_results is not None else WARP_GREP_MAX_OUTPUT_LINES
124
+
125
+ try:
126
+ stdout = await self._commands.list_dir(path, depth)
127
+ except Exception: # noqa: BLE001 - mirror LocalRipgrepProvider's empty-on-error
128
+ return []
129
+
130
+ paths = [p for p in (stdout or "").strip().splitlines() if p]
131
+ regex = re.compile(pattern) if pattern else None
132
+ entries: list[ListDirectoryEntry] = []
133
+
134
+ for full_path in paths:
135
+ if full_path == path or full_path == self._repo_root:
136
+ continue
137
+ name = full_path.rsplit("/", 1)[-1]
138
+ if should_skip(name):
139
+ continue
140
+ if regex is not None and not regex.search(name):
141
+ continue
142
+
143
+ relative = full_path
144
+ if full_path.startswith(self._repo_root):
145
+ relative = full_path[len(self._repo_root) :].lstrip("/")
146
+
147
+ entry_depth = max(0, len([p for p in relative.split("/") if p]) - 1)
148
+ # Sandbox `find` rarely reports type; infer from a trailing extension.
149
+ has_extension = "." in name and not name.startswith(".")
150
+ entries.append(
151
+ ListDirectoryEntry(
152
+ name=name,
153
+ path=relative,
154
+ type="file" if has_extension else "dir",
155
+ depth=entry_depth,
156
+ )
157
+ )
158
+ if len(entries) >= limit:
159
+ break
160
+
161
+ return entries
morphsdk/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.5"
@@ -0,0 +1 @@
1
+ """Framework adapters for generating tool definitions compatible with LLM SDKs."""