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
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."""