deepagents 0.2.2__tar.gz → 0.2.5__tar.gz
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.
- {deepagents-0.2.2/src/deepagents.egg-info → deepagents-0.2.5}/PKG-INFO +7 -7
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/backends/composite.py +32 -42
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/backends/filesystem.py +92 -86
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/backends/protocol.py +39 -13
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/backends/state.py +59 -58
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/backends/store.py +74 -67
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/backends/utils.py +55 -70
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/graph.py +1 -1
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/middleware/filesystem.py +49 -47
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/middleware/resumable_shell.py +5 -4
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/middleware/subagents.py +1 -2
- deepagents-0.2.5/libs/deepagents-cli/README.md +3 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/README.md +196 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/__init__.py +5 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/__main__.py +6 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/agent.py +278 -0
- {deepagents-0.2.2/src/deepagents/middleware → deepagents-0.2.5/libs/deepagents-cli/deepagents_cli}/agent_memory.py +16 -12
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/commands.py +89 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/config.py +118 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/default_agent_prompt.md +110 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/execution.py +636 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/file_ops.py +347 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/input.py +270 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/main.py +226 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/py.typed +0 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/token_utils.py +63 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/tools.py +140 -0
- deepagents-0.2.5/libs/deepagents-cli/deepagents_cli/ui.py +489 -0
- deepagents-0.2.5/libs/deepagents-cli/tests/test_file_ops.py +119 -0
- deepagents-0.2.5/libs/deepagents-cli/tests/test_placeholder.py +5 -0
- {deepagents-0.2.2 → deepagents-0.2.5/libs/deepagents.egg-info}/PKG-INFO +7 -7
- deepagents-0.2.5/libs/deepagents.egg-info/SOURCES.txt +41 -0
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents.egg-info/requires.txt +0 -7
- deepagents-0.2.5/libs/deepagents.egg-info/top_level.txt +2 -0
- {deepagents-0.2.2 → deepagents-0.2.5}/pyproject.toml +25 -16
- deepagents-0.2.2/src/deepagents.egg-info/SOURCES.txt +0 -24
- deepagents-0.2.2/src/deepagents.egg-info/top_level.txt +0 -1
- deepagents-0.2.2/tests/test_middleware.py +0 -1134
- {deepagents-0.2.2 → deepagents-0.2.5}/LICENSE +0 -0
- {deepagents-0.2.2 → deepagents-0.2.5}/README.md +0 -0
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/__init__.py +0 -0
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/backends/__init__.py +1 -1
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/middleware/__init__.py +0 -0
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents/middleware/patch_tool_calls.py +0 -0
- {deepagents-0.2.2/src → deepagents-0.2.5/libs}/deepagents.egg-info/dependency_links.txt +0 -0
- {deepagents-0.2.2 → deepagents-0.2.5}/setup.cfg +0 -0
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepagents
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
4
4
|
Summary: General purpose 'deep agent' with sub-agent spawning, todo list capabilities, and mock file system. Built on LangGraph.
|
|
5
5
|
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://docs.langchain.com/oss/python/deepagents/overview
|
|
7
|
+
Project-URL: Documentation, https://reference.langchain.com/python/deepagents/
|
|
8
|
+
Project-URL: Source, https://github.com/langchain-ai/deepagents
|
|
9
|
+
Project-URL: Twitter, https://x.com/LangChainAI
|
|
10
|
+
Project-URL: Slack, https://www.langchain.com/join-community
|
|
11
|
+
Project-URL: Reddit, https://www.reddit.com/r/LangChain/
|
|
6
12
|
Requires-Python: <4.0,>=3.11
|
|
7
13
|
Description-Content-Type: text/markdown
|
|
8
14
|
License-File: LICENSE
|
|
@@ -10,12 +16,6 @@ Requires-Dist: langchain-anthropic<2.0.0,>=1.0.0
|
|
|
10
16
|
Requires-Dist: langchain<2.0.0,>=1.0.2
|
|
11
17
|
Requires-Dist: langchain-core<2.0.0,>=1.0.0
|
|
12
18
|
Requires-Dist: wcmatch
|
|
13
|
-
Provides-Extra: dev
|
|
14
|
-
Requires-Dist: pytest; extra == "dev"
|
|
15
|
-
Requires-Dist: pytest-cov; extra == "dev"
|
|
16
|
-
Requires-Dist: build; extra == "dev"
|
|
17
|
-
Requires-Dist: twine; extra == "dev"
|
|
18
|
-
Requires-Dist: langchain-openai; extra == "dev"
|
|
19
19
|
Dynamic: license-file
|
|
20
20
|
|
|
21
21
|
# 🧠🤖Deep Agents
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
"""CompositeBackend: Route operations to different backends based on path prefix."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
from deepagents.backends.protocol import BackendProtocol, WriteResult, EditResult
|
|
3
|
+
from deepagents.backends.protocol import BackendProtocol, EditResult, WriteResult
|
|
6
4
|
from deepagents.backends.state import StateBackend
|
|
7
5
|
from deepagents.backends.utils import FileInfo, GrepMatch
|
|
8
6
|
|
|
9
7
|
|
|
10
8
|
class CompositeBackend:
|
|
11
|
-
|
|
12
9
|
def __init__(
|
|
13
10
|
self,
|
|
14
11
|
default: BackendProtocol | StateBackend,
|
|
@@ -19,16 +16,16 @@ class CompositeBackend:
|
|
|
19
16
|
|
|
20
17
|
# Virtual routes
|
|
21
18
|
self.routes = routes
|
|
22
|
-
|
|
19
|
+
|
|
23
20
|
# Sort routes by length (longest first) for correct prefix matching
|
|
24
21
|
self.sorted_routes = sorted(routes.items(), key=lambda x: len(x[0]), reverse=True)
|
|
25
22
|
|
|
26
23
|
def _get_backend_and_key(self, key: str) -> tuple[BackendProtocol, str]:
|
|
27
24
|
"""Determine which backend handles this key and strip prefix.
|
|
28
|
-
|
|
25
|
+
|
|
29
26
|
Args:
|
|
30
27
|
key: Original file path
|
|
31
|
-
|
|
28
|
+
|
|
32
29
|
Returns:
|
|
33
30
|
Tuple of (backend, stripped_key) where stripped_key has the route
|
|
34
31
|
prefix removed (but keeps leading slash).
|
|
@@ -38,12 +35,12 @@ class CompositeBackend:
|
|
|
38
35
|
if key.startswith(prefix):
|
|
39
36
|
# Strip full prefix and ensure a leading slash remains
|
|
40
37
|
# e.g., "/memories/notes.txt" → "/notes.txt"; "/memories/" → "/"
|
|
41
|
-
suffix = key[len(prefix):]
|
|
38
|
+
suffix = key[len(prefix) :]
|
|
42
39
|
stripped_key = f"/{suffix}" if suffix else "/"
|
|
43
40
|
return backend, stripped_key
|
|
44
|
-
|
|
41
|
+
|
|
45
42
|
return self.default, key
|
|
46
|
-
|
|
43
|
+
|
|
47
44
|
def ls_info(self, path: str) -> list[FileInfo]:
|
|
48
45
|
"""List files and directories in the specified directory (non-recursive).
|
|
49
46
|
|
|
@@ -58,7 +55,7 @@ class CompositeBackend:
|
|
|
58
55
|
for route_prefix, backend in self.sorted_routes:
|
|
59
56
|
if path.startswith(route_prefix.rstrip("/")):
|
|
60
57
|
# Query only the matching routed backend
|
|
61
|
-
suffix = path[len(route_prefix):]
|
|
58
|
+
suffix = path[len(route_prefix) :]
|
|
62
59
|
search_path = f"/{suffix}" if suffix else "/"
|
|
63
60
|
infos = backend.ls_info(search_path)
|
|
64
61
|
prefixed: list[FileInfo] = []
|
|
@@ -74,12 +71,14 @@ class CompositeBackend:
|
|
|
74
71
|
results.extend(self.default.ls_info(path))
|
|
75
72
|
for route_prefix, backend in self.sorted_routes:
|
|
76
73
|
# Add the route itself as a directory (e.g., /memories/)
|
|
77
|
-
results.append(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
74
|
+
results.append(
|
|
75
|
+
{
|
|
76
|
+
"path": route_prefix,
|
|
77
|
+
"is_dir": True,
|
|
78
|
+
"size": 0,
|
|
79
|
+
"modified_at": "",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
83
82
|
|
|
84
83
|
results.sort(key=lambda x: x.get("path", ""))
|
|
85
84
|
return results
|
|
@@ -87,15 +86,14 @@ class CompositeBackend:
|
|
|
87
86
|
# Path doesn't match a route: query only default backend
|
|
88
87
|
return self.default.ls_info(path)
|
|
89
88
|
|
|
90
|
-
|
|
91
89
|
def read(
|
|
92
|
-
self,
|
|
90
|
+
self,
|
|
93
91
|
file_path: str,
|
|
94
92
|
offset: int = 0,
|
|
95
93
|
limit: int = 2000,
|
|
96
94
|
) -> str:
|
|
97
95
|
"""Read file content, routing to appropriate backend.
|
|
98
|
-
|
|
96
|
+
|
|
99
97
|
Args:
|
|
100
98
|
file_path: Absolute file path
|
|
101
99
|
offset: Line offset to start reading from (0-indexed)
|
|
@@ -105,17 +103,16 @@ class CompositeBackend:
|
|
|
105
103
|
backend, stripped_key = self._get_backend_and_key(file_path)
|
|
106
104
|
return backend.read(stripped_key, offset=offset, limit=limit)
|
|
107
105
|
|
|
108
|
-
|
|
109
106
|
def grep_raw(
|
|
110
107
|
self,
|
|
111
108
|
pattern: str,
|
|
112
|
-
path:
|
|
113
|
-
glob:
|
|
109
|
+
path: str | None = None,
|
|
110
|
+
glob: str | None = None,
|
|
114
111
|
) -> list[GrepMatch] | str:
|
|
115
112
|
# If path targets a specific route, search only that backend
|
|
116
113
|
for route_prefix, backend in self.sorted_routes:
|
|
117
114
|
if path is not None and path.startswith(route_prefix.rstrip("/")):
|
|
118
|
-
search_path = path[len(route_prefix) - 1:]
|
|
115
|
+
search_path = path[len(route_prefix) - 1 :]
|
|
119
116
|
raw = backend.grep_raw(pattern, search_path if search_path else "/", glob)
|
|
120
117
|
if isinstance(raw, str):
|
|
121
118
|
return raw
|
|
@@ -137,19 +134,16 @@ class CompositeBackend:
|
|
|
137
134
|
all_matches.extend({**m, "path": f"{route_prefix[:-1]}{m['path']}"} for m in raw)
|
|
138
135
|
|
|
139
136
|
return all_matches
|
|
140
|
-
|
|
137
|
+
|
|
141
138
|
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
|
142
139
|
results: list[FileInfo] = []
|
|
143
140
|
|
|
144
141
|
# Route based on path, not pattern
|
|
145
142
|
for route_prefix, backend in self.sorted_routes:
|
|
146
143
|
if path.startswith(route_prefix.rstrip("/")):
|
|
147
|
-
search_path = path[len(route_prefix) - 1:]
|
|
144
|
+
search_path = path[len(route_prefix) - 1 :]
|
|
148
145
|
infos = backend.glob_info(pattern, search_path if search_path else "/")
|
|
149
|
-
return [
|
|
150
|
-
{**fi, "path": f"{route_prefix[:-1]}{fi['path']}"}
|
|
151
|
-
for fi in infos
|
|
152
|
-
]
|
|
146
|
+
return [{**fi, "path": f"{route_prefix[:-1]}{fi['path']}"} for fi in infos]
|
|
153
147
|
|
|
154
148
|
# Path doesn't match any specific route - search default backend AND all routed backends
|
|
155
149
|
results.extend(self.default.glob_info(pattern, path))
|
|
@@ -162,11 +156,10 @@ class CompositeBackend:
|
|
|
162
156
|
results.sort(key=lambda x: x.get("path", ""))
|
|
163
157
|
return results
|
|
164
158
|
|
|
165
|
-
|
|
166
159
|
def write(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
160
|
+
self,
|
|
161
|
+
file_path: str,
|
|
162
|
+
content: str,
|
|
170
163
|
) -> WriteResult:
|
|
171
164
|
"""Create a new file, routing to appropriate backend.
|
|
172
165
|
|
|
@@ -191,11 +184,11 @@ class CompositeBackend:
|
|
|
191
184
|
return res
|
|
192
185
|
|
|
193
186
|
def edit(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
187
|
+
self,
|
|
188
|
+
file_path: str,
|
|
189
|
+
old_string: str,
|
|
190
|
+
new_string: str,
|
|
191
|
+
replace_all: bool = False,
|
|
199
192
|
) -> EditResult:
|
|
200
193
|
"""Edit a file, routing to appropriate backend.
|
|
201
194
|
|
|
@@ -219,6 +212,3 @@ class CompositeBackend:
|
|
|
219
212
|
except Exception:
|
|
220
213
|
pass
|
|
221
214
|
return res
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
@@ -7,26 +7,24 @@ Security and search upgrades:
|
|
|
7
7
|
and optional glob include filtering, while preserving virtual path behavior
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import json
|
|
10
11
|
import os
|
|
11
12
|
import re
|
|
12
|
-
import json
|
|
13
13
|
import subprocess
|
|
14
14
|
from datetime import datetime
|
|
15
15
|
from pathlib import Path
|
|
16
|
-
from typing import Optional
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
import wcmatch.glob as wcglob
|
|
18
|
+
|
|
19
|
+
from deepagents.backends.protocol import BackendProtocol, EditResult, FileInfo, GrepMatch, WriteResult
|
|
20
|
+
from deepagents.backends.utils import (
|
|
19
21
|
check_empty_content,
|
|
20
22
|
format_content_with_line_numbers,
|
|
21
23
|
perform_string_replacement,
|
|
22
24
|
)
|
|
23
|
-
import wcmatch.glob as wcglob
|
|
24
|
-
from deepagents.backends.utils import FileInfo, GrepMatch
|
|
25
|
-
from deepagents.backends.protocol import WriteResult, EditResult
|
|
26
|
-
|
|
27
25
|
|
|
28
26
|
|
|
29
|
-
class FilesystemBackend:
|
|
27
|
+
class FilesystemBackend(BackendProtocol):
|
|
30
28
|
"""Backend that reads and writes files directly from the filesystem.
|
|
31
29
|
|
|
32
30
|
Files are accessed using their actual filesystem paths. Relative paths are
|
|
@@ -35,13 +33,13 @@ class FilesystemBackend:
|
|
|
35
33
|
"""
|
|
36
34
|
|
|
37
35
|
def __init__(
|
|
38
|
-
self,
|
|
39
|
-
root_dir:
|
|
36
|
+
self,
|
|
37
|
+
root_dir: str | Path | None = None,
|
|
40
38
|
virtual_mode: bool = False,
|
|
41
39
|
max_file_size_mb: int = 10,
|
|
42
40
|
) -> None:
|
|
43
41
|
"""Initialize filesystem backend.
|
|
44
|
-
|
|
42
|
+
|
|
45
43
|
Args:
|
|
46
44
|
root_dir: Optional root directory for file operations. If provided,
|
|
47
45
|
all file paths will be resolved relative to this directory.
|
|
@@ -118,32 +116,36 @@ class FilesystemBackend:
|
|
|
118
116
|
if is_file:
|
|
119
117
|
try:
|
|
120
118
|
st = child_path.stat()
|
|
121
|
-
results.append(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
119
|
+
results.append(
|
|
120
|
+
{
|
|
121
|
+
"path": abs_path,
|
|
122
|
+
"is_dir": False,
|
|
123
|
+
"size": int(st.st_size),
|
|
124
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
127
|
except OSError:
|
|
128
128
|
results.append({"path": abs_path, "is_dir": False})
|
|
129
129
|
elif is_dir:
|
|
130
130
|
try:
|
|
131
131
|
st = child_path.stat()
|
|
132
|
-
results.append(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
results.append(
|
|
133
|
+
{
|
|
134
|
+
"path": abs_path + "/",
|
|
135
|
+
"is_dir": True,
|
|
136
|
+
"size": 0,
|
|
137
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
138
|
+
}
|
|
139
|
+
)
|
|
138
140
|
except OSError:
|
|
139
141
|
results.append({"path": abs_path + "/", "is_dir": True})
|
|
140
142
|
else:
|
|
141
143
|
# Virtual mode: strip cwd prefix
|
|
142
144
|
if abs_path.startswith(cwd_str):
|
|
143
|
-
relative_path = abs_path[len(cwd_str):]
|
|
145
|
+
relative_path = abs_path[len(cwd_str) :]
|
|
144
146
|
elif abs_path.startswith(str(self.cwd)):
|
|
145
147
|
# Handle case where cwd doesn't end with /
|
|
146
|
-
relative_path = abs_path[len(str(self.cwd)):].lstrip("/")
|
|
148
|
+
relative_path = abs_path[len(str(self.cwd)) :].lstrip("/")
|
|
147
149
|
else:
|
|
148
150
|
# Path is outside cwd, return as-is or skip
|
|
149
151
|
relative_path = abs_path
|
|
@@ -153,23 +155,27 @@ class FilesystemBackend:
|
|
|
153
155
|
if is_file:
|
|
154
156
|
try:
|
|
155
157
|
st = child_path.stat()
|
|
156
|
-
results.append(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
results.append(
|
|
159
|
+
{
|
|
160
|
+
"path": virt_path,
|
|
161
|
+
"is_dir": False,
|
|
162
|
+
"size": int(st.st_size),
|
|
163
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
164
|
+
}
|
|
165
|
+
)
|
|
162
166
|
except OSError:
|
|
163
167
|
results.append({"path": virt_path, "is_dir": False})
|
|
164
168
|
elif is_dir:
|
|
165
169
|
try:
|
|
166
170
|
st = child_path.stat()
|
|
167
|
-
results.append(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
results.append(
|
|
172
|
+
{
|
|
173
|
+
"path": virt_path + "/",
|
|
174
|
+
"is_dir": True,
|
|
175
|
+
"size": 0,
|
|
176
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
177
|
+
}
|
|
178
|
+
)
|
|
173
179
|
except OSError:
|
|
174
180
|
results.append({"path": virt_path + "/", "is_dir": True})
|
|
175
181
|
except (OSError, PermissionError):
|
|
@@ -180,15 +186,15 @@ class FilesystemBackend:
|
|
|
180
186
|
return results
|
|
181
187
|
|
|
182
188
|
# Removed legacy ls() convenience to keep lean surface
|
|
183
|
-
|
|
189
|
+
|
|
184
190
|
def read(
|
|
185
|
-
self,
|
|
191
|
+
self,
|
|
186
192
|
file_path: str,
|
|
187
193
|
offset: int = 0,
|
|
188
194
|
limit: int = 2000,
|
|
189
195
|
) -> str:
|
|
190
196
|
"""Read file content with line numbers.
|
|
191
|
-
|
|
197
|
+
|
|
192
198
|
Args:
|
|
193
199
|
file_path: Absolute or relative file path
|
|
194
200
|
offset: Line offset to start reading from (0-indexed)
|
|
@@ -196,10 +202,10 @@ class FilesystemBackend:
|
|
|
196
202
|
Formatted file content with line numbers, or error message.
|
|
197
203
|
"""
|
|
198
204
|
resolved_path = self._resolve_path(file_path)
|
|
199
|
-
|
|
205
|
+
|
|
200
206
|
if not resolved_path.exists() or not resolved_path.is_file():
|
|
201
207
|
return f"Error: File '{file_path}' not found"
|
|
202
|
-
|
|
208
|
+
|
|
203
209
|
try:
|
|
204
210
|
# Open with O_NOFOLLOW where available to avoid symlink traversal
|
|
205
211
|
try:
|
|
@@ -208,27 +214,27 @@ class FilesystemBackend:
|
|
|
208
214
|
content = f.read()
|
|
209
215
|
except OSError:
|
|
210
216
|
# Fallback to normal open if O_NOFOLLOW unsupported or fails
|
|
211
|
-
with open(resolved_path,
|
|
217
|
+
with open(resolved_path, encoding="utf-8") as f:
|
|
212
218
|
content = f.read()
|
|
213
|
-
|
|
219
|
+
|
|
214
220
|
empty_msg = check_empty_content(content)
|
|
215
221
|
if empty_msg:
|
|
216
222
|
return empty_msg
|
|
217
|
-
|
|
223
|
+
|
|
218
224
|
lines = content.splitlines()
|
|
219
225
|
start_idx = offset
|
|
220
226
|
end_idx = min(start_idx + limit, len(lines))
|
|
221
|
-
|
|
227
|
+
|
|
222
228
|
if start_idx >= len(lines):
|
|
223
229
|
return f"Error: Line offset {offset} exceeds file length ({len(lines)} lines)"
|
|
224
|
-
|
|
230
|
+
|
|
225
231
|
selected_lines = lines[start_idx:end_idx]
|
|
226
232
|
return format_content_with_line_numbers(selected_lines, start_line=start_idx + 1)
|
|
227
233
|
except (OSError, UnicodeDecodeError) as e:
|
|
228
234
|
return f"Error reading file '{file_path}': {e}"
|
|
229
|
-
|
|
235
|
+
|
|
230
236
|
def write(
|
|
231
|
-
self,
|
|
237
|
+
self,
|
|
232
238
|
file_path: str,
|
|
233
239
|
content: str,
|
|
234
240
|
) -> WriteResult:
|
|
@@ -236,10 +242,10 @@ class FilesystemBackend:
|
|
|
236
242
|
Returns WriteResult. External storage sets files_update=None.
|
|
237
243
|
"""
|
|
238
244
|
resolved_path = self._resolve_path(file_path)
|
|
239
|
-
|
|
245
|
+
|
|
240
246
|
if resolved_path.exists():
|
|
241
247
|
return WriteResult(error=f"Cannot write to {file_path} because it already exists. Read and then make an edit, or write to a new path.")
|
|
242
|
-
|
|
248
|
+
|
|
243
249
|
try:
|
|
244
250
|
# Create parent directories if needed
|
|
245
251
|
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -251,13 +257,13 @@ class FilesystemBackend:
|
|
|
251
257
|
fd = os.open(resolved_path, flags, 0o644)
|
|
252
258
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
253
259
|
f.write(content)
|
|
254
|
-
|
|
260
|
+
|
|
255
261
|
return WriteResult(path=file_path, files_update=None)
|
|
256
262
|
except (OSError, UnicodeEncodeError) as e:
|
|
257
263
|
return WriteResult(error=f"Error writing file '{file_path}': {e}")
|
|
258
|
-
|
|
264
|
+
|
|
259
265
|
def edit(
|
|
260
|
-
self,
|
|
266
|
+
self,
|
|
261
267
|
file_path: str,
|
|
262
268
|
old_string: str,
|
|
263
269
|
new_string: str,
|
|
@@ -267,10 +273,10 @@ class FilesystemBackend:
|
|
|
267
273
|
Returns EditResult. External storage sets files_update=None.
|
|
268
274
|
"""
|
|
269
275
|
resolved_path = self._resolve_path(file_path)
|
|
270
|
-
|
|
276
|
+
|
|
271
277
|
if not resolved_path.exists() or not resolved_path.is_file():
|
|
272
278
|
return EditResult(error=f"Error: File '{file_path}' not found")
|
|
273
|
-
|
|
279
|
+
|
|
274
280
|
try:
|
|
275
281
|
# Read securely
|
|
276
282
|
try:
|
|
@@ -278,16 +284,16 @@ class FilesystemBackend:
|
|
|
278
284
|
with os.fdopen(fd, "r", encoding="utf-8") as f:
|
|
279
285
|
content = f.read()
|
|
280
286
|
except OSError:
|
|
281
|
-
with open(resolved_path,
|
|
287
|
+
with open(resolved_path, encoding="utf-8") as f:
|
|
282
288
|
content = f.read()
|
|
283
|
-
|
|
289
|
+
|
|
284
290
|
result = perform_string_replacement(content, old_string, new_string, replace_all)
|
|
285
|
-
|
|
291
|
+
|
|
286
292
|
if isinstance(result, str):
|
|
287
293
|
return EditResult(error=result)
|
|
288
|
-
|
|
294
|
+
|
|
289
295
|
new_content, occurrences = result
|
|
290
|
-
|
|
296
|
+
|
|
291
297
|
# Write securely
|
|
292
298
|
flags = os.O_WRONLY | os.O_TRUNC
|
|
293
299
|
if hasattr(os, "O_NOFOLLOW"):
|
|
@@ -295,18 +301,18 @@ class FilesystemBackend:
|
|
|
295
301
|
fd = os.open(resolved_path, flags)
|
|
296
302
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
297
303
|
f.write(new_content)
|
|
298
|
-
|
|
304
|
+
|
|
299
305
|
return EditResult(path=file_path, files_update=None, occurrences=int(occurrences))
|
|
300
306
|
except (OSError, UnicodeDecodeError, UnicodeEncodeError) as e:
|
|
301
307
|
return EditResult(error=f"Error editing file '{file_path}': {e}")
|
|
302
|
-
|
|
308
|
+
|
|
303
309
|
# Removed legacy grep() convenience to keep lean surface
|
|
304
310
|
|
|
305
311
|
def grep_raw(
|
|
306
312
|
self,
|
|
307
313
|
pattern: str,
|
|
308
|
-
path:
|
|
309
|
-
glob:
|
|
314
|
+
path: str | None = None,
|
|
315
|
+
glob: str | None = None,
|
|
310
316
|
) -> list[GrepMatch] | str:
|
|
311
317
|
# Validate regex
|
|
312
318
|
try:
|
|
@@ -334,9 +340,7 @@ class FilesystemBackend:
|
|
|
334
340
|
matches.append({"path": fpath, "line": int(line_num), "text": line_text})
|
|
335
341
|
return matches
|
|
336
342
|
|
|
337
|
-
def _ripgrep_search(
|
|
338
|
-
self, pattern: str, base_full: Path, include_glob: Optional[str]
|
|
339
|
-
) -> Optional[dict[str, list[tuple[int, str]]]]:
|
|
343
|
+
def _ripgrep_search(self, pattern: str, base_full: Path, include_glob: str | None) -> dict[str, list[tuple[int, str]]] | None:
|
|
340
344
|
cmd = ["rg", "--json"]
|
|
341
345
|
if include_glob:
|
|
342
346
|
cmd.extend(["--glob", include_glob])
|
|
@@ -381,9 +385,7 @@ class FilesystemBackend:
|
|
|
381
385
|
|
|
382
386
|
return results
|
|
383
387
|
|
|
384
|
-
def _python_search(
|
|
385
|
-
self, pattern: str, base_full: Path, include_glob: Optional[str]
|
|
386
|
-
) -> dict[str, list[tuple[int, str]]]:
|
|
388
|
+
def _python_search(self, pattern: str, base_full: Path, include_glob: str | None) -> dict[str, list[tuple[int, str]]]:
|
|
387
389
|
try:
|
|
388
390
|
regex = re.compile(pattern)
|
|
389
391
|
except re.error:
|
|
@@ -418,7 +420,7 @@ class FilesystemBackend:
|
|
|
418
420
|
results.setdefault(virt_path, []).append((line_num, line))
|
|
419
421
|
|
|
420
422
|
return results
|
|
421
|
-
|
|
423
|
+
|
|
422
424
|
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
|
423
425
|
if pattern.startswith("/"):
|
|
424
426
|
pattern = pattern.lstrip("/")
|
|
@@ -441,12 +443,14 @@ class FilesystemBackend:
|
|
|
441
443
|
if not self.virtual_mode:
|
|
442
444
|
try:
|
|
443
445
|
st = matched_path.stat()
|
|
444
|
-
results.append(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
446
|
+
results.append(
|
|
447
|
+
{
|
|
448
|
+
"path": abs_path,
|
|
449
|
+
"is_dir": False,
|
|
450
|
+
"size": int(st.st_size),
|
|
451
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
452
|
+
}
|
|
453
|
+
)
|
|
450
454
|
except OSError:
|
|
451
455
|
results.append({"path": abs_path, "is_dir": False})
|
|
452
456
|
else:
|
|
@@ -454,20 +458,22 @@ class FilesystemBackend:
|
|
|
454
458
|
if not cwd_str.endswith("/"):
|
|
455
459
|
cwd_str += "/"
|
|
456
460
|
if abs_path.startswith(cwd_str):
|
|
457
|
-
relative_path = abs_path[len(cwd_str):]
|
|
461
|
+
relative_path = abs_path[len(cwd_str) :]
|
|
458
462
|
elif abs_path.startswith(str(self.cwd)):
|
|
459
|
-
relative_path = abs_path[len(str(self.cwd)):].lstrip("/")
|
|
463
|
+
relative_path = abs_path[len(str(self.cwd)) :].lstrip("/")
|
|
460
464
|
else:
|
|
461
465
|
relative_path = abs_path
|
|
462
466
|
virt = "/" + relative_path
|
|
463
467
|
try:
|
|
464
468
|
st = matched_path.stat()
|
|
465
|
-
results.append(
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
469
|
+
results.append(
|
|
470
|
+
{
|
|
471
|
+
"path": virt,
|
|
472
|
+
"is_dir": False,
|
|
473
|
+
"size": int(st.st_size),
|
|
474
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
475
|
+
}
|
|
476
|
+
)
|
|
471
477
|
except OSError:
|
|
472
478
|
results.append({"path": virt, "is_dir": False})
|
|
473
479
|
except (OSError, ValueError):
|