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
morphsdk/_client.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Main client classes for the Morph SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ._base import AsyncBaseClient, BaseClient, _configure_logging
|
|
9
|
+
from ._config import ClientConfig, RetryConfig
|
|
10
|
+
from ._constants import API_KEY_ENV_VAR, DEBUG_ENV_VAR, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT
|
|
11
|
+
from ._errors import AuthenticationError
|
|
12
|
+
from .resources.browser import AsyncBrowserResource, BrowserResource
|
|
13
|
+
from .resources.compact import acompact as _acompact_fn
|
|
14
|
+
from .resources.compact import compact as _compact_fn
|
|
15
|
+
from .resources.edit import AsyncEditResource, EditResource
|
|
16
|
+
from .resources.explore import AsyncExploreResource, ExploreResource
|
|
17
|
+
from .resources.git import AsyncGitResource, GitResource
|
|
18
|
+
from .resources.github import AsyncGitHubResource, GitHubResource
|
|
19
|
+
from .resources.grep import AsyncGrepResource, GrepResource
|
|
20
|
+
from .resources.mobile import AsyncMobileResource, MobileResource
|
|
21
|
+
from .resources.reflex import AsyncReflexResource, ReflexResource
|
|
22
|
+
from .resources.router import AsyncRouterResource, RouterResource
|
|
23
|
+
from .resources.search import AsyncSearchResource, SearchResource
|
|
24
|
+
from .types.compact import CompactResult
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Morph(BaseClient):
|
|
28
|
+
"""Synchronous Morph SDK client.
|
|
29
|
+
|
|
30
|
+
Usage::
|
|
31
|
+
|
|
32
|
+
morph = Morph(api_key="sk-...")
|
|
33
|
+
|
|
34
|
+
# Edit files on disk
|
|
35
|
+
result = morph.edit.file(path="src/app.py", instruction="Fix bug", code_edit="...")
|
|
36
|
+
|
|
37
|
+
# Semantic codebase search
|
|
38
|
+
result = morph.search.code(query="auth middleware", repo_id="my-project")
|
|
39
|
+
|
|
40
|
+
# WarpGrep code search
|
|
41
|
+
result = morph.grep.read_github_file(github="vercel/next.js", path="package.json")
|
|
42
|
+
|
|
43
|
+
# Browser automation
|
|
44
|
+
result = morph.browser.run(task="Test login flow", url="https://app.example.com")
|
|
45
|
+
|
|
46
|
+
# Model routing
|
|
47
|
+
result = morph.router.select_model(input="Explain quicksort")
|
|
48
|
+
|
|
49
|
+
# Context compression
|
|
50
|
+
result = morph.compact(input="Long text to compress...")
|
|
51
|
+
|
|
52
|
+
# Framework tool creation
|
|
53
|
+
from morphsdk.adapters.openai import OpenAIAdapter
|
|
54
|
+
tools = OpenAIAdapter.edit_file_tool()
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
edit: EditResource
|
|
58
|
+
search: SearchResource
|
|
59
|
+
grep: GrepResource
|
|
60
|
+
browser: BrowserResource
|
|
61
|
+
git: GitResource
|
|
62
|
+
github: GitHubResource
|
|
63
|
+
mobile: MobileResource
|
|
64
|
+
router: RouterResource
|
|
65
|
+
explore: ExploreResource
|
|
66
|
+
reflex: ReflexResource
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
api_key: str | None = None,
|
|
72
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
73
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
74
|
+
debug: bool = False,
|
|
75
|
+
github_installation_id: str | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
resolved_key = api_key or os.environ.get(API_KEY_ENV_VAR)
|
|
78
|
+
if not resolved_key:
|
|
79
|
+
raise AuthenticationError(
|
|
80
|
+
f"API key required. Pass api_key= or set {API_KEY_ENV_VAR} environment variable."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Bridge debug=True to env var for logger
|
|
84
|
+
if debug and os.environ.get(DEBUG_ENV_VAR) != "1":
|
|
85
|
+
os.environ[DEBUG_ENV_VAR] = "1"
|
|
86
|
+
_configure_logging()
|
|
87
|
+
|
|
88
|
+
config = ClientConfig(
|
|
89
|
+
api_key=resolved_key,
|
|
90
|
+
timeout=timeout,
|
|
91
|
+
max_retries=max_retries,
|
|
92
|
+
debug=debug,
|
|
93
|
+
retry_config=RetryConfig(max_retries=max_retries),
|
|
94
|
+
github_installation_id=github_installation_id,
|
|
95
|
+
)
|
|
96
|
+
super().__init__(config)
|
|
97
|
+
|
|
98
|
+
# Bind resources
|
|
99
|
+
self.edit = EditResource(self)
|
|
100
|
+
self.search = SearchResource(self)
|
|
101
|
+
self.grep = GrepResource(self)
|
|
102
|
+
self.browser = BrowserResource(self)
|
|
103
|
+
self.git = GitResource(self)
|
|
104
|
+
self.github = GitHubResource(
|
|
105
|
+
self, installation_id=github_installation_id
|
|
106
|
+
)
|
|
107
|
+
self.mobile = MobileResource(self)
|
|
108
|
+
self.router = RouterResource(self)
|
|
109
|
+
self.explore = ExploreResource(self)
|
|
110
|
+
self.reflex = ReflexResource(self)
|
|
111
|
+
|
|
112
|
+
def compact(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
input: str | list[dict[str, Any]] | None = None,
|
|
116
|
+
messages: list[dict[str, Any]] | None = None,
|
|
117
|
+
query: str | None = None,
|
|
118
|
+
compression_ratio: float = 0.5,
|
|
119
|
+
preserve_recent: int = 2,
|
|
120
|
+
include_line_ranges: bool = True,
|
|
121
|
+
include_markers: bool = True,
|
|
122
|
+
model: str | None = None,
|
|
123
|
+
timeout: float | None = None,
|
|
124
|
+
) -> CompactResult:
|
|
125
|
+
"""Compact messages or text for context compression.
|
|
126
|
+
|
|
127
|
+
Accepts either a plain string, a list of message dicts via *input*,
|
|
128
|
+
or a separate *messages* keyword. Returns per-message compacted and
|
|
129
|
+
kept line ranges.
|
|
130
|
+
"""
|
|
131
|
+
return _compact_fn(
|
|
132
|
+
self,
|
|
133
|
+
input=input,
|
|
134
|
+
messages=messages,
|
|
135
|
+
query=query,
|
|
136
|
+
compression_ratio=compression_ratio,
|
|
137
|
+
preserve_recent=preserve_recent,
|
|
138
|
+
include_line_ranges=include_line_ranges,
|
|
139
|
+
include_markers=include_markers,
|
|
140
|
+
model=model,
|
|
141
|
+
timeout=timeout,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class AsyncMorph(AsyncBaseClient):
|
|
146
|
+
"""Asynchronous Morph SDK client.
|
|
147
|
+
|
|
148
|
+
Mirrors :class:`Morph` exactly, with every resource method as ``async def``
|
|
149
|
+
(streaming methods return an ``AsyncIterator``).
|
|
150
|
+
|
|
151
|
+
Usage::
|
|
152
|
+
|
|
153
|
+
async with AsyncMorph(api_key="sk-...") as morph:
|
|
154
|
+
result = await morph.edit.file(
|
|
155
|
+
path="src/app.py", instruction="Fix bug", code_edit="..."
|
|
156
|
+
)
|
|
157
|
+
result = await morph.search.code(query="auth middleware", repo_id="proj")
|
|
158
|
+
result = await morph.router.select_model(input="Explain quicksort")
|
|
159
|
+
result = await morph.compact(input="Long text to compress...")
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
edit: AsyncEditResource
|
|
163
|
+
search: AsyncSearchResource
|
|
164
|
+
grep: AsyncGrepResource
|
|
165
|
+
browser: AsyncBrowserResource
|
|
166
|
+
git: AsyncGitResource
|
|
167
|
+
github: AsyncGitHubResource
|
|
168
|
+
mobile: AsyncMobileResource
|
|
169
|
+
router: AsyncRouterResource
|
|
170
|
+
explore: AsyncExploreResource
|
|
171
|
+
reflex: AsyncReflexResource
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
api_key: str | None = None,
|
|
177
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
178
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
179
|
+
debug: bool = False,
|
|
180
|
+
github_installation_id: str | None = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
resolved_key = api_key or os.environ.get(API_KEY_ENV_VAR)
|
|
183
|
+
if not resolved_key:
|
|
184
|
+
raise AuthenticationError(
|
|
185
|
+
f"API key required. Pass api_key= or set {API_KEY_ENV_VAR} environment variable."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Bridge debug=True to env var for logger
|
|
189
|
+
if debug and os.environ.get(DEBUG_ENV_VAR) != "1":
|
|
190
|
+
os.environ[DEBUG_ENV_VAR] = "1"
|
|
191
|
+
_configure_logging()
|
|
192
|
+
|
|
193
|
+
config = ClientConfig(
|
|
194
|
+
api_key=resolved_key,
|
|
195
|
+
timeout=timeout,
|
|
196
|
+
max_retries=max_retries,
|
|
197
|
+
debug=debug,
|
|
198
|
+
retry_config=RetryConfig(max_retries=max_retries),
|
|
199
|
+
github_installation_id=github_installation_id,
|
|
200
|
+
)
|
|
201
|
+
super().__init__(config)
|
|
202
|
+
|
|
203
|
+
# Bind async resources
|
|
204
|
+
self.edit = AsyncEditResource(self)
|
|
205
|
+
self.search = AsyncSearchResource(self)
|
|
206
|
+
self.grep = AsyncGrepResource(self)
|
|
207
|
+
self.browser = AsyncBrowserResource(self)
|
|
208
|
+
self.git = AsyncGitResource(self)
|
|
209
|
+
self.github = AsyncGitHubResource(self, installation_id=github_installation_id)
|
|
210
|
+
self.mobile = AsyncMobileResource(self)
|
|
211
|
+
self.router = AsyncRouterResource(self)
|
|
212
|
+
self.explore = AsyncExploreResource(self)
|
|
213
|
+
self.reflex = AsyncReflexResource(self)
|
|
214
|
+
|
|
215
|
+
async def compact(
|
|
216
|
+
self,
|
|
217
|
+
*,
|
|
218
|
+
input: str | list[dict[str, Any]] | None = None,
|
|
219
|
+
messages: list[dict[str, Any]] | None = None,
|
|
220
|
+
query: str | None = None,
|
|
221
|
+
compression_ratio: float = 0.5,
|
|
222
|
+
preserve_recent: int = 2,
|
|
223
|
+
include_line_ranges: bool = True,
|
|
224
|
+
include_markers: bool = True,
|
|
225
|
+
model: str | None = None,
|
|
226
|
+
timeout: float | None = None,
|
|
227
|
+
) -> CompactResult:
|
|
228
|
+
"""Compact messages or text for context compression.
|
|
229
|
+
|
|
230
|
+
Accepts either a plain string, a list of message dicts via *input*,
|
|
231
|
+
or a separate *messages* keyword. Returns per-message compacted and
|
|
232
|
+
kept line ranges.
|
|
233
|
+
"""
|
|
234
|
+
return await _acompact_fn(
|
|
235
|
+
self,
|
|
236
|
+
input=input,
|
|
237
|
+
messages=messages,
|
|
238
|
+
query=query,
|
|
239
|
+
compression_ratio=compression_ratio,
|
|
240
|
+
preserve_recent=preserve_recent,
|
|
241
|
+
include_line_ranges=include_line_ranges,
|
|
242
|
+
include_markers=include_markers,
|
|
243
|
+
model=model,
|
|
244
|
+
timeout=timeout,
|
|
245
|
+
)
|
morphsdk/_config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Configuration types for the Morph SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from ._constants import (
|
|
9
|
+
DEFAULT_BACKOFF_MULTIPLIER,
|
|
10
|
+
DEFAULT_INITIAL_DELAY,
|
|
11
|
+
DEFAULT_MAX_DELAY,
|
|
12
|
+
DEFAULT_MAX_RETRIES,
|
|
13
|
+
DEFAULT_TIMEOUT,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RetryConfig:
|
|
19
|
+
"""Configuration for automatic request retries."""
|
|
20
|
+
|
|
21
|
+
max_retries: int = DEFAULT_MAX_RETRIES
|
|
22
|
+
initial_delay: float = DEFAULT_INITIAL_DELAY
|
|
23
|
+
max_delay: float = DEFAULT_MAX_DELAY
|
|
24
|
+
backoff_multiplier: float = DEFAULT_BACKOFF_MULTIPLIER
|
|
25
|
+
on_retry: Callable[[int, Exception], None] | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ClientConfig:
|
|
30
|
+
"""Internal resolved configuration for the Morph client."""
|
|
31
|
+
|
|
32
|
+
api_key: str
|
|
33
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
34
|
+
max_retries: int = DEFAULT_MAX_RETRIES
|
|
35
|
+
debug: bool = False
|
|
36
|
+
retry_config: RetryConfig = field(default_factory=RetryConfig)
|
|
37
|
+
github_installation_id: str | None = None
|
morphsdk/_constants.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Constants for the Morph SDK."""
|
|
2
|
+
|
|
3
|
+
# API base URLs
|
|
4
|
+
API_BASE_URL = "https://api.morphllm.com"
|
|
5
|
+
REPOS_BASE_URL = "https://repos.morphllm.com"
|
|
6
|
+
BROWSER_BASE_URL = "https://browser.morphllm.com"
|
|
7
|
+
|
|
8
|
+
# Default timeouts (seconds)
|
|
9
|
+
DEFAULT_TIMEOUT = 30.0
|
|
10
|
+
COMPACT_TIMEOUT = 120.0
|
|
11
|
+
BROWSER_TIMEOUT = 1000.0
|
|
12
|
+
ROUTER_TIMEOUT = 5.0
|
|
13
|
+
|
|
14
|
+
# Default retry config
|
|
15
|
+
DEFAULT_MAX_RETRIES = 3
|
|
16
|
+
DEFAULT_INITIAL_DELAY = 1.0
|
|
17
|
+
DEFAULT_MAX_DELAY = 30.0
|
|
18
|
+
DEFAULT_BACKOFF_MULTIPLIER = 2.0
|
|
19
|
+
|
|
20
|
+
# Models
|
|
21
|
+
FAST_APPLY_MODEL_FAST = "morph-v3-fast"
|
|
22
|
+
FAST_APPLY_MODEL_LARGE = "morph-v3-large"
|
|
23
|
+
WARP_GREP_MODEL = "morph-warp-grep-v2.1"
|
|
24
|
+
COMPACT_MODEL = "morph-compactor"
|
|
25
|
+
DEFAULT_BROWSER_MODEL = "morph-computer-use-v0"
|
|
26
|
+
|
|
27
|
+
# WarpGrep agent config
|
|
28
|
+
WARP_GREP_MAX_TURNS = 6
|
|
29
|
+
# Matches the TS AGENT_CONFIG.TIMEOUT_MS default (60_000 ms). Overridable via
|
|
30
|
+
# the MORPH_WARP_GREP_TIMEOUT env var (expressed in milliseconds, like TS).
|
|
31
|
+
WARP_GREP_TIMEOUT = 60.0
|
|
32
|
+
WARP_GREP_MAX_CONTEXT_CHARS = 321_600
|
|
33
|
+
WARP_GREP_MAX_OUTPUT_LINES = 200
|
|
34
|
+
WARP_GREP_MAX_LIST_RESULTS = 500
|
|
35
|
+
WARP_GREP_MAX_READ_LINES = 800
|
|
36
|
+
WARP_GREP_MAX_LIST_DEPTH = 3
|
|
37
|
+
|
|
38
|
+
# Default excludes for grep
|
|
39
|
+
DEFAULT_EXCLUDES = [
|
|
40
|
+
".git", "node_modules", "__pycache__", "dist", "build",
|
|
41
|
+
".next", ".cache", ".vscode", ".idea",
|
|
42
|
+
"*.lock", "*.min.js", "*.min.css",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# API key env var
|
|
46
|
+
API_KEY_ENV_VAR = "MORPH_API_KEY"
|
|
47
|
+
|
|
48
|
+
# Debug/logging env vars
|
|
49
|
+
DEBUG_ENV_VAR = "MORPH_DEBUG"
|
|
50
|
+
LOG_FILE_ENV_VAR = "MORPH_LOG_FILE"
|
|
51
|
+
|
|
52
|
+
# Git proxy
|
|
53
|
+
DEFAULT_GIT_PROXY_URL = "https://repos.morphllm.com"
|
morphsdk/_errors.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Error hierarchy for the Morph SDK.
|
|
2
|
+
|
|
3
|
+
All SDK errors inherit from MorphError. HTTP errors are mapped to specific
|
|
4
|
+
subclasses based on status code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MorphError(Exception):
|
|
11
|
+
"""Base error for all Morph SDK errors."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
status_code: int | None = None,
|
|
18
|
+
code: str | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.message = message
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.code = code
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
parts = [f"message={self.message!r}"]
|
|
27
|
+
if self.status_code is not None:
|
|
28
|
+
parts.append(f"status_code={self.status_code}")
|
|
29
|
+
if self.code is not None:
|
|
30
|
+
parts.append(f"code={self.code!r}")
|
|
31
|
+
return f"{self.__class__.__name__}({', '.join(parts)})"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthenticationError(MorphError):
|
|
35
|
+
"""401 - Invalid or missing API key."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str = "Invalid or missing API key") -> None:
|
|
38
|
+
super().__init__(message, status_code=401, code="authentication_error")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PermissionDeniedError(MorphError):
|
|
42
|
+
"""403 - Insufficient permissions."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str = "Permission denied") -> None:
|
|
45
|
+
super().__init__(message, status_code=403, code="permission_denied")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NotFoundError(MorphError):
|
|
49
|
+
"""404 - Resource not found."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str = "Resource not found") -> None:
|
|
52
|
+
super().__init__(message, status_code=404, code="not_found")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RateLimitError(MorphError):
|
|
56
|
+
"""429 - Rate limit exceeded."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
message: str = "Rate limit exceeded",
|
|
61
|
+
*,
|
|
62
|
+
retry_after: float | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
super().__init__(message, status_code=429, code="rate_limit")
|
|
65
|
+
self.retry_after = retry_after
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ValidationError(MorphError):
|
|
69
|
+
"""400 - Invalid request parameters."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, message: str = "Validation error") -> None:
|
|
72
|
+
super().__init__(message, status_code=400, code="validation_error")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class APIConnectionError(MorphError):
|
|
76
|
+
"""Network-level connection failure."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, message: str = "Connection error") -> None:
|
|
79
|
+
super().__init__(message, code="connection_error")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class APITimeoutError(MorphError):
|
|
83
|
+
"""Request timed out."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, message: str = "Request timed out") -> None:
|
|
86
|
+
super().__init__(message, code="timeout")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class InternalError(MorphError):
|
|
90
|
+
"""500+ - Server-side error."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, message: str = "Internal server error", *, status_code: int = 500) -> None:
|
|
93
|
+
super().__init__(message, status_code=status_code, code="internal_error")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def raise_for_status(status_code: int, message: str) -> None:
|
|
97
|
+
"""Raise the appropriate MorphError subclass for an HTTP status code."""
|
|
98
|
+
if status_code == 401:
|
|
99
|
+
raise AuthenticationError(message)
|
|
100
|
+
elif status_code == 403:
|
|
101
|
+
raise PermissionDeniedError(message)
|
|
102
|
+
elif status_code == 404:
|
|
103
|
+
raise NotFoundError(message)
|
|
104
|
+
elif status_code == 429:
|
|
105
|
+
raise RateLimitError(message)
|
|
106
|
+
elif status_code == 400:
|
|
107
|
+
raise ValidationError(message)
|
|
108
|
+
elif status_code >= 500:
|
|
109
|
+
raise InternalError(message, status_code=status_code)
|
|
110
|
+
else:
|
|
111
|
+
raise MorphError(message, status_code=status_code)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Provider implementations for WarpGrep and other multi-backend resources.
|
|
2
|
+
|
|
3
|
+
A WarpGrep *provider* is the filesystem backend the agent loop searches:
|
|
4
|
+
|
|
5
|
+
* :class:`LocalRipgrepProvider` -- a local checkout via ``rg``.
|
|
6
|
+
* :class:`RemoteCommandsProvider` -- user-supplied command callables, including
|
|
7
|
+
the HTTP code-storage backend built by
|
|
8
|
+
:func:`create_code_storage_http_commands`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .base import (
|
|
14
|
+
GrepResult,
|
|
15
|
+
ListDirectoryEntry,
|
|
16
|
+
ReadResult,
|
|
17
|
+
WarpGrepProvider,
|
|
18
|
+
)
|
|
19
|
+
from .code_storage_http import (
|
|
20
|
+
CodeStorageHttpConfig,
|
|
21
|
+
create_code_storage_http_commands,
|
|
22
|
+
)
|
|
23
|
+
from .local import LocalRipgrepProvider
|
|
24
|
+
from .remote import RemoteCommands, RemoteCommandsProvider
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"WarpGrepProvider",
|
|
28
|
+
"GrepResult",
|
|
29
|
+
"ReadResult",
|
|
30
|
+
"ListDirectoryEntry",
|
|
31
|
+
"LocalRipgrepProvider",
|
|
32
|
+
"RemoteCommands",
|
|
33
|
+
"RemoteCommandsProvider",
|
|
34
|
+
"CodeStorageHttpConfig",
|
|
35
|
+
"create_code_storage_http_commands",
|
|
36
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Shared filename-filtering and path helpers for WarpGrep providers.
|
|
2
|
+
|
|
3
|
+
Ported from the TypeScript ``local.ts`` / ``remote.ts`` ``shouldSkip`` logic and
|
|
4
|
+
``paths.ts`` repo-containment helpers. Kept private to ``_providers``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
# Directories/files to always skip (exact name match).
|
|
12
|
+
SKIP_NAMES = frozenset(
|
|
13
|
+
{
|
|
14
|
+
# Version control
|
|
15
|
+
".git", ".svn", ".hg", ".bzr",
|
|
16
|
+
# Dependencies
|
|
17
|
+
"node_modules", "bower_components", ".pnpm", ".yarn",
|
|
18
|
+
"vendor", "Pods", ".bundle",
|
|
19
|
+
# Python
|
|
20
|
+
"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache",
|
|
21
|
+
".venv", "venv", ".tox", ".nox", ".eggs",
|
|
22
|
+
# Build outputs
|
|
23
|
+
"dist", "build", "out", "output", "target", "_build",
|
|
24
|
+
".next", ".nuxt", ".output", ".vercel", ".netlify",
|
|
25
|
+
# Cache
|
|
26
|
+
".cache", ".parcel-cache", ".turbo", ".nx", ".gradle",
|
|
27
|
+
# IDE
|
|
28
|
+
".idea", ".vscode", ".vs",
|
|
29
|
+
# Coverage
|
|
30
|
+
"coverage", ".coverage", "htmlcov", ".nyc_output",
|
|
31
|
+
# Temp
|
|
32
|
+
"tmp", "temp", ".tmp", ".temp",
|
|
33
|
+
# Lock files
|
|
34
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb",
|
|
35
|
+
"Cargo.lock", "Gemfile.lock", "poetry.lock",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# File extensions/suffixes to skip.
|
|
40
|
+
SKIP_EXTENSIONS = frozenset(
|
|
41
|
+
{
|
|
42
|
+
".min.js", ".min.css", ".bundle.js",
|
|
43
|
+
".wasm", ".so", ".dll", ".pyc",
|
|
44
|
+
".map", ".js.map",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def should_skip(name: str, allow_names: frozenset[str] | None = None) -> bool:
|
|
50
|
+
"""Return ``True`` if a directory/file name should be skipped during listing."""
|
|
51
|
+
if allow_names is not None and name in allow_names:
|
|
52
|
+
return False
|
|
53
|
+
if name in SKIP_NAMES:
|
|
54
|
+
return True
|
|
55
|
+
if name.startswith("."):
|
|
56
|
+
return True
|
|
57
|
+
return any(name.endswith(ext) for ext in SKIP_EXTENSIONS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_under_repo(repo_root: str, target_path: str) -> str:
|
|
61
|
+
"""Resolve *target_path* against *repo_root*, raising if it escapes the root.
|
|
62
|
+
|
|
63
|
+
Mirrors ``resolveUnderRepo`` + ``ensureWithinRepo`` from ``paths.ts``. Like
|
|
64
|
+
Node's ``path.resolve`` this is purely lexical -- it does **not** dereference
|
|
65
|
+
symlinks, so callers can still detect a symlinked target afterwards.
|
|
66
|
+
"""
|
|
67
|
+
abs_root = os.path.abspath(repo_root)
|
|
68
|
+
resolved = os.path.abspath(os.path.join(abs_root, target_path))
|
|
69
|
+
rel = os.path.relpath(resolved, abs_root)
|
|
70
|
+
if rel == ".." or rel.startswith(".." + os.sep) or os.path.isabs(rel):
|
|
71
|
+
raise ValueError(f"Path outside repository root: {resolved}")
|
|
72
|
+
return resolved
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def to_repo_relative(repo_root: str, abs_path: str) -> str:
|
|
76
|
+
"""Return *abs_path* relative to *repo_root* (``toRepoRelative`` in ``paths.ts``)."""
|
|
77
|
+
return os.path.relpath(os.path.abspath(abs_path), os.path.abspath(repo_root))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_textual_file(file_path: str, max_bytes: int = 2_000_000) -> bool:
|
|
81
|
+
"""Heuristic: a regular, non-oversized file with no NUL byte in its first 512 bytes."""
|
|
82
|
+
try:
|
|
83
|
+
st = os.stat(file_path)
|
|
84
|
+
if not os.path.isfile(file_path):
|
|
85
|
+
return False
|
|
86
|
+
if st.st_size > max_bytes:
|
|
87
|
+
return False
|
|
88
|
+
with open(file_path, "rb") as fh:
|
|
89
|
+
chunk = fh.read(512)
|
|
90
|
+
return b"\x00" not in chunk
|
|
91
|
+
except OSError:
|
|
92
|
+
return False
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Provider abstraction for the WarpGrep agent loop.
|
|
2
|
+
|
|
3
|
+
A :class:`WarpGrepProvider` is the filesystem backend the agent searches
|
|
4
|
+
against. Two implementations ship with the SDK:
|
|
5
|
+
|
|
6
|
+
* :class:`~morphsdk._providers.local.LocalRipgrepProvider` -- shells out to
|
|
7
|
+
``rg`` against a local checkout.
|
|
8
|
+
* :class:`~morphsdk._providers.remote.RemoteCommandsProvider` -- delegates to
|
|
9
|
+
user-supplied command callables (e.g. a sandbox or the HTTP code-storage
|
|
10
|
+
backend via :func:`~morphsdk._providers.code_storage_http.create_code_storage_http_commands`).
|
|
11
|
+
|
|
12
|
+
The method names mirror the TypeScript ``WarpGrepProvider`` interface and the
|
|
13
|
+
result shapes match it byte-for-byte (the agent depends on the exact line
|
|
14
|
+
formats: ``"path:line:content"`` for grep, ``"lineNumber|content"`` for read).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class GrepResult:
|
|
26
|
+
"""Result of a :meth:`WarpGrepProvider.grep` call.
|
|
27
|
+
|
|
28
|
+
``lines`` are ripgrep-style with context: ``"path:line:content"`` for
|
|
29
|
+
matches and ``"path-line-content"`` for context lines. ``error`` carries a
|
|
30
|
+
graceful, agent-readable message (e.g. ripgrep not installed) instead of
|
|
31
|
+
raising.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
lines: list[str] = field(default_factory=list)
|
|
35
|
+
error: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ReadResult:
|
|
40
|
+
"""Result of a :meth:`WarpGrepProvider.read` call.
|
|
41
|
+
|
|
42
|
+
``lines`` are in ``"lineNumber|content"`` format. ``error`` carries a
|
|
43
|
+
graceful message (file not found, symlink, unreadable, ...).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
lines: list[str] = field(default_factory=list)
|
|
47
|
+
error: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ListDirectoryEntry:
|
|
52
|
+
"""A single entry returned by :meth:`WarpGrepProvider.list_directory`."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
path: str
|
|
56
|
+
type: Literal["file", "dir"]
|
|
57
|
+
depth: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class WarpGrepProvider(ABC):
|
|
61
|
+
"""Filesystem backend the WarpGrep agent searches against."""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
async def grep(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
pattern: str,
|
|
68
|
+
path: str,
|
|
69
|
+
glob: str | None = None,
|
|
70
|
+
context_lines: int | None = None,
|
|
71
|
+
case_sensitive: bool | None = None,
|
|
72
|
+
) -> GrepResult:
|
|
73
|
+
"""Run a regex grep with an optional file glob filter."""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
async def read(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
path: str,
|
|
80
|
+
start_line: int | None = None,
|
|
81
|
+
end_line: int | None = None,
|
|
82
|
+
) -> ReadResult:
|
|
83
|
+
"""Read lines from a file (1-indexed, inclusive range)."""
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
async def list_directory(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
path: str,
|
|
90
|
+
pattern: str | None = None,
|
|
91
|
+
max_results: int | None = None,
|
|
92
|
+
max_depth: int | None = None,
|
|
93
|
+
) -> list[ListDirectoryEntry]:
|
|
94
|
+
"""List directory contents as a flat, depth-annotated tree."""
|