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.
- morphsdk/__init__.py +54 -0
- morphsdk/_agent/__init__.py +64 -0
- morphsdk/_agent/config.py +52 -0
- morphsdk/_agent/explore.py +276 -0
- morphsdk/_agent/github.py +57 -0
- morphsdk/_agent/helpers.py +133 -0
- morphsdk/_agent/parser.py +163 -0
- morphsdk/_agent/runner.py +524 -0
- morphsdk/_agent/tools.py +171 -0
- morphsdk/_agent/types.py +126 -0
- morphsdk/_base.py +309 -0
- morphsdk/_client.py +245 -0
- morphsdk/_config.py +37 -0
- morphsdk/_constants.py +53 -0
- morphsdk/_errors.py +111 -0
- morphsdk/_providers/__init__.py +36 -0
- morphsdk/_providers/_filter.py +92 -0
- morphsdk/_providers/base.py +94 -0
- morphsdk/_providers/code_storage_http.py +104 -0
- morphsdk/_providers/local.py +270 -0
- morphsdk/_providers/remote.py +161 -0
- morphsdk/_version.py +1 -0
- morphsdk/adapters/__init__.py +1 -0
- morphsdk/adapters/anthropic.py +360 -0
- morphsdk/adapters/langchain.py +120 -0
- morphsdk/adapters/openai.py +500 -0
- morphsdk/py.typed +0 -0
- morphsdk/resources/__init__.py +0 -0
- morphsdk/resources/browser.py +919 -0
- morphsdk/resources/compact.py +133 -0
- morphsdk/resources/edit.py +506 -0
- morphsdk/resources/explore.py +333 -0
- morphsdk/resources/git.py +861 -0
- morphsdk/resources/github.py +1214 -0
- morphsdk/resources/grep.py +583 -0
- morphsdk/resources/mobile.py +134 -0
- morphsdk/resources/reflex.py +414 -0
- morphsdk/resources/router.py +124 -0
- morphsdk/resources/search.py +110 -0
- morphsdk/tracing/__init__.py +70 -0
- morphsdk/tracing/_otel.py +101 -0
- morphsdk/tracing/core.py +249 -0
- morphsdk/tracing/interaction.py +284 -0
- morphsdk/tracing/otel.py +75 -0
- morphsdk/tracing/reflex.py +58 -0
- morphsdk/tracing/types.py +163 -0
- morphsdk/types/__init__.py +140 -0
- morphsdk/types/browser.py +118 -0
- morphsdk/types/compact.py +41 -0
- morphsdk/types/edit.py +31 -0
- morphsdk/types/explore.py +42 -0
- morphsdk/types/git.py +25 -0
- morphsdk/types/github.py +111 -0
- morphsdk/types/grep.py +41 -0
- morphsdk/types/mobile.py +25 -0
- morphsdk/types/reflex.py +137 -0
- morphsdk/types/router.py +21 -0
- morphsdk/types/search.py +33 -0
- morphsdk-0.2.5.dist-info/METADATA +226 -0
- morphsdk-0.2.5.dist-info/RECORD +61 -0
- 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."""
|