super-code-assistant 3.3.6__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.
- commands/__init__.py +859 -0
- core/__init__.py +0 -0
- core/config.py +263 -0
- core/config_template.json +7 -0
- core/context.py +271 -0
- core/engine.py +635 -0
- core/file_state.py +279 -0
- core/llm.py +309 -0
- core/model_capabilities.py +45 -0
- core/permissions.py +204 -0
- core/sandbox/__init__.py +15 -0
- core/sandbox/blacklist.py +176 -0
- core/sandbox/config.py +38 -0
- core/sandbox/network.py +136 -0
- core/sandbox/path_protection.py +126 -0
- core/session.py +295 -0
- core/tool.py +45 -0
- features/__init__.py +0 -0
- features/compact.py +945 -0
- features/coordinator.py +105 -0
- features/cost_tracker.py +184 -0
- features/extract_memories.py +326 -0
- features/find_relevant_memories.py +376 -0
- features/git_ai.py +256 -0
- features/memory.py +531 -0
- features/memory_age.py +66 -0
- features/memory_scan.py +153 -0
- features/memory_types.py +34 -0
- features/plan.py +327 -0
- features/skills.py +300 -0
- features/worker_manager.py +232 -0
- mcp/__init__.py +0 -0
- mcp/client.py +112 -0
- mcp/loader.py +80 -0
- mcp/tool_proxy.py +59 -0
- super_code_assistant-3.3.6.dist-info/METADATA +45 -0
- super_code_assistant-3.3.6.dist-info/RECORD +61 -0
- super_code_assistant-3.3.6.dist-info/WHEEL +5 -0
- super_code_assistant-3.3.6.dist-info/entry_points.txt +2 -0
- super_code_assistant-3.3.6.dist-info/top_level.txt +7 -0
- tools/__init__.py +21 -0
- tools/agent.py +132 -0
- tools/ask_user.py +111 -0
- tools/bash.py +77 -0
- tools/file_edit.py +269 -0
- tools/file_read.py +206 -0
- tools/file_write.py +78 -0
- tools/glob_tool.py +81 -0
- tools/grep_tool.py +134 -0
- tools/plan_tools.py +75 -0
- tools/skill.py +108 -0
- tools/tool.py +44 -0
- tools/web_fetch.py +129 -0
- tools/web_search.py +220 -0
- tui/__init__.py +0 -0
- tui/app.py +726 -0
- tui/clipboard_image.py +42 -0
- tui/keylistener.py +140 -0
- tui/prompt.py +752 -0
- tui/query.py +200 -0
- tui/rendering.py +135 -0
tools/file_edit.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Edit tool — scoped string replacement with snippet_id safety (Phase 2)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from core.tool import Tool, ToolResult
|
|
6
|
+
from core.file_state import (
|
|
7
|
+
get_snippet,
|
|
8
|
+
get_file_state,
|
|
9
|
+
is_snippet_stale,
|
|
10
|
+
record_file_state,
|
|
11
|
+
create_snippet,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FileEditTool(Tool):
|
|
16
|
+
name = "Edit"
|
|
17
|
+
description = (
|
|
18
|
+
"Replaces exact string matches in files.\n\n"
|
|
19
|
+
"Usage:\n"
|
|
20
|
+
"- You must use Read at least once before editing. "
|
|
21
|
+
"This tool will fail if you attempt an edit without having read the file.\n"
|
|
22
|
+
"- The edit will FAIL if old_string is not unique in the file. "
|
|
23
|
+
"Either provide a larger string with more surrounding context or use replace_all.\n"
|
|
24
|
+
"- Use replace_all to replace and rename strings across the entire file."
|
|
25
|
+
)
|
|
26
|
+
input_schema = {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {
|
|
29
|
+
"snippet_id": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": (
|
|
32
|
+
"Required: snippet_id from a previous Read call. "
|
|
33
|
+
"The edit is scoped to the lines covered by that snippet."
|
|
34
|
+
),
|
|
35
|
+
},
|
|
36
|
+
"file_path": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "Optional absolute path guard; must match snippet_id's file.",
|
|
39
|
+
},
|
|
40
|
+
"old_string": {"type": "string", "description": "Exact string to replace"},
|
|
41
|
+
"new_string": {"type": "string", "description": "Replacement string (must differ from old_string)"},
|
|
42
|
+
"replace_all": {"type": "boolean", "description": "Replace all occurrences (default false)", "default": False},
|
|
43
|
+
},
|
|
44
|
+
"required": ["snippet_id", "old_string", "new_string"],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Shared set of files that have been read — populated by FileReadTool
|
|
48
|
+
_read_files: set[str] = set()
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def mark_file_read(cls, file_path: str) -> None:
|
|
52
|
+
cls._read_files.add(file_path)
|
|
53
|
+
|
|
54
|
+
def __init__(self, sandbox_manager=None, session_id: str = ""):
|
|
55
|
+
self._sandbox = sandbox_manager
|
|
56
|
+
self._session_id = session_id
|
|
57
|
+
|
|
58
|
+
def set_session_id(self, session_id: str) -> None:
|
|
59
|
+
"""Phase 3: engine 注入当前会话 ID。"""
|
|
60
|
+
self._session_id = session_id
|
|
61
|
+
|
|
62
|
+
def get_activity_description(self, **kwargs) -> str | None:
|
|
63
|
+
fp = kwargs.get("file_path", "") or kwargs.get("snippet_id", "")
|
|
64
|
+
return f"Editing {fp}" if fp else None
|
|
65
|
+
|
|
66
|
+
def execute(self, snippet_id: str, old_string: str, new_string: str,
|
|
67
|
+
file_path: str = "", replace_all: bool = False, **kwargs) -> ToolResult:
|
|
68
|
+
# ── 1. snippet 校验 ──────────────────────────────────────────────
|
|
69
|
+
if not snippet_id.strip():
|
|
70
|
+
return ToolResult(
|
|
71
|
+
content="Error: snippet_id is required. Use Read first to get a snippet_id.",
|
|
72
|
+
is_error=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
snippet = get_snippet(self._session_id, snippet_id) if self._session_id else None
|
|
76
|
+
if snippet is None:
|
|
77
|
+
return ToolResult(
|
|
78
|
+
content=f"Error: Unknown snippet_id: {snippet_id}. The snippet may have expired "
|
|
79
|
+
f"or you may need to Read the file again to get a fresh snippet_id.",
|
|
80
|
+
is_error=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if is_snippet_stale(self._session_id, snippet):
|
|
84
|
+
return ToolResult(
|
|
85
|
+
content=f"Error: The file {snippet.file_path} has been modified since snippet "
|
|
86
|
+
f"'{snippet_id}' was created. Read the file again to get a fresh snippet_id.",
|
|
87
|
+
is_error=True,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# ── 2. file_path 校验 既允许用户不传路径(使用凭证中的路径),又防止用户用凭证去编辑其他文件(路径必须匹配)
|
|
91
|
+
fp = file_path.strip() if file_path else snippet.file_path
|
|
92
|
+
if not fp:
|
|
93
|
+
return ToolResult(content="Error: file_path is required.", is_error=True)
|
|
94
|
+
if file_path.strip() and Path(file_path.strip()).resolve() != Path(snippet.file_path):
|
|
95
|
+
return ToolResult(
|
|
96
|
+
content=f"Error: snippet_id '{snippet_id}' belongs to {snippet.file_path}, "
|
|
97
|
+
f"not {file_path}.",
|
|
98
|
+
is_error=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# ── 3. 沙箱 & 文件存在性 ──────────────────────────────────────────
|
|
102
|
+
if self._sandbox is not None:
|
|
103
|
+
allowed, reason = self._sandbox.check_path(fp, "write")
|
|
104
|
+
if not allowed:
|
|
105
|
+
return ToolResult(content=f"Error: {reason}", is_error=True)
|
|
106
|
+
|
|
107
|
+
path = Path(fp)
|
|
108
|
+
if not path.exists():
|
|
109
|
+
return ToolResult(content=f"Error: File not found: {fp}", is_error=True)
|
|
110
|
+
if path.is_dir():
|
|
111
|
+
return ToolResult(content=f"Error: {fp} is a directory.", is_error=True)
|
|
112
|
+
|
|
113
|
+
# ── 4. 基本参数校验 ──────────────────────────────────────────────
|
|
114
|
+
if not old_string:
|
|
115
|
+
return ToolResult(
|
|
116
|
+
content="Error: old_string cannot be empty.",
|
|
117
|
+
is_error=True,
|
|
118
|
+
)
|
|
119
|
+
if old_string == new_string:
|
|
120
|
+
return ToolResult(
|
|
121
|
+
content="Error: new_string must differ from old_string.",
|
|
122
|
+
is_error=True,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# ── 5. 读取文件 & 范围搜索 ───────────────────────────────────────
|
|
126
|
+
try:
|
|
127
|
+
content = path.read_text(encoding="utf-8")
|
|
128
|
+
except OSError as e:
|
|
129
|
+
return ToolResult(content=f"Error reading file: {e}", is_error=True)
|
|
130
|
+
|
|
131
|
+
lines = content.splitlines(keepends=True)
|
|
132
|
+
total_lines = len(lines) if lines else 1
|
|
133
|
+
|
|
134
|
+
# 搜索范围:snippet 行范围
|
|
135
|
+
scope_start = snippet.start_line
|
|
136
|
+
scope_end = min(snippet.end_line, total_lines)
|
|
137
|
+
scope_text = "".join(lines[scope_start - 1:scope_end])
|
|
138
|
+
|
|
139
|
+
# 在 scope 内搜索匹配
|
|
140
|
+
match_positions = _find_all(scope_text, old_string)
|
|
141
|
+
|
|
142
|
+
# ── 6. 匹配结果处理 ──────────────────────────────────────────────
|
|
143
|
+
# 先检查文件是否被外部程序修改过(mtime 变化),给出具体原因提示;无论哪种情况,都要求 AI 重新读取文件获取新 snippet。
|
|
144
|
+
if len(match_positions) == 0:
|
|
145
|
+
# 试试:文件外部被修改了?
|
|
146
|
+
try:
|
|
147
|
+
stat = path.stat()
|
|
148
|
+
state = get_file_state(self._session_id, fp)
|
|
149
|
+
if state and stat.st_mtime != state.mtime:
|
|
150
|
+
return ToolResult(
|
|
151
|
+
content=f"Error: old_string not found in {fp}. The file has been modified "
|
|
152
|
+
f"externally since it was read. Read it again to get a fresh snippet_id.\n"
|
|
153
|
+
f" snippet scope: lines {scope_start}-{scope_end}",
|
|
154
|
+
is_error=True,
|
|
155
|
+
metadata={"scope": {"file_path": fp, "start_line": scope_start,
|
|
156
|
+
"end_line": scope_end, "snippet_id": snippet_id}},
|
|
157
|
+
)
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
return ToolResult(
|
|
162
|
+
content=f"Error: old_string not found in {fp} within the snippet scope "
|
|
163
|
+
f"(lines {scope_start}-{scope_end}). "
|
|
164
|
+
f"Read the file again if the content has changed.",
|
|
165
|
+
is_error=True,
|
|
166
|
+
metadata={"scope": {"file_path": fp, "start_line": scope_start,
|
|
167
|
+
"end_line": scope_end, "snippet_id": snippet_id}},
|
|
168
|
+
)
|
|
169
|
+
# 在 snippet 范围内找到多个匹配且未启用 replace_all 时,拒绝执行,防止模糊替换改错地方。
|
|
170
|
+
if not replace_all and len(match_positions) > 1:
|
|
171
|
+
# 非唯一匹配 → 返回候选片段
|
|
172
|
+
candidates = []
|
|
173
|
+
for idx, pos in enumerate(match_positions[:5]): # 最多 5 个
|
|
174
|
+
match_line = _offset_to_line(lines, scope_start, pos)
|
|
175
|
+
preview_start = max(1, match_line - 1)
|
|
176
|
+
preview_end = min(total_lines, match_line + 2)
|
|
177
|
+
preview = "".join(
|
|
178
|
+
f"{ln}\t{lines[ln - 1]}" for ln in range(preview_start, preview_end + 1)
|
|
179
|
+
)
|
|
180
|
+
candidates.append({
|
|
181
|
+
"index": idx + 1,
|
|
182
|
+
"line": match_line,
|
|
183
|
+
"preview": preview,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
return ToolResult(
|
|
187
|
+
content=f"Error: old_string is not unique within snippet scope "
|
|
188
|
+
f"(lines {scope_start}-{scope_end}); found {len(match_positions)} matches. "
|
|
189
|
+
f"Use replace_all=true or provide more surrounding context.",
|
|
190
|
+
is_error=True,
|
|
191
|
+
metadata={
|
|
192
|
+
"match_count": len(match_positions),
|
|
193
|
+
"scope": {"file_path": fp, "start_line": scope_start,
|
|
194
|
+
"end_line": scope_end, "snippet_id": snippet_id},
|
|
195
|
+
"candidates": candidates,
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# ── 7. 执行替换 ──────────────────────────────────────────────────
|
|
200
|
+
# 通过累计行长度计算出替换位置在文件中的全局字节偏移(scope_bytes_before 是范围前的字节数,global_pos 是具体匹配位置的全局偏移
|
|
201
|
+
scope_bytes_before = sum(len(line) for line in lines[:scope_start - 1])
|
|
202
|
+
if replace_all:
|
|
203
|
+
new_scope = scope_text.replace(old_string, new_string)
|
|
204
|
+
scope_bytes = sum(len(line) for line in lines[scope_start - 1:scope_end])
|
|
205
|
+
new_content = content[:scope_bytes_before] + new_scope + content[scope_bytes_before + scope_bytes:]
|
|
206
|
+
replaced = len(match_positions)
|
|
207
|
+
else:
|
|
208
|
+
pos = match_positions[0]
|
|
209
|
+
# 计算全局偏移
|
|
210
|
+
scope_bytes_before = sum(len(line) for line in lines[:scope_start - 1])
|
|
211
|
+
global_pos = scope_bytes_before + pos
|
|
212
|
+
new_content = content[:global_pos] + new_string + content[global_pos + len(old_string):]
|
|
213
|
+
replaced = 1
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
path.write_text(new_content, encoding="utf-8")
|
|
217
|
+
except OSError as e:
|
|
218
|
+
return ToolResult(content=f"Error writing file: {e}", is_error=True)
|
|
219
|
+
|
|
220
|
+
# ── 8. 刷新文件状态 ──────────────────────────────────────────────
|
|
221
|
+
# 编辑后更新文件状态并自增版本号(bump_version=True),使所有旧 snippet 自动失效。基于修改后的完整文件内容重新生成一个 full 类型的 snippet,让 AI 可以继续编辑(同时返回 new_snippet_id)。
|
|
222
|
+
if self._session_id:
|
|
223
|
+
stat = path.stat()
|
|
224
|
+
record_file_state(self._session_id, fp, new_content, stat.st_mtime, bump_version=True)
|
|
225
|
+
# 用替换后的内容计算行数,避免多行替换导致新 snippet 的 end_line 偏小
|
|
226
|
+
new_total_lines = len(new_content.splitlines(keepends=True)) or 1
|
|
227
|
+
new_snippet = create_snippet(self._session_id, fp, 1, new_total_lines, scope_type="full")
|
|
228
|
+
meta = {
|
|
229
|
+
"file_path": fp,
|
|
230
|
+
"replaced_count": replaced,
|
|
231
|
+
"scope": {"file_path": fp, "start_line": scope_start,
|
|
232
|
+
"end_line": scope_end, "snippet_id": snippet_id},
|
|
233
|
+
"new_snippet_id": new_snippet.id,
|
|
234
|
+
}
|
|
235
|
+
else:
|
|
236
|
+
meta = None
|
|
237
|
+
|
|
238
|
+
return ToolResult(
|
|
239
|
+
content=f"Successfully replaced {replaced} occurrence(s) in {fp}.",
|
|
240
|
+
metadata=meta,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# 工具函数
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _find_all(text: str, needle: str) -> list[int]:
|
|
250
|
+
"""返回 needle 在 text 中所有出现位置的偏移量列表。"""
|
|
251
|
+
positions = []
|
|
252
|
+
start = 0
|
|
253
|
+
while True:
|
|
254
|
+
pos = text.find(needle, start)
|
|
255
|
+
if pos == -1:
|
|
256
|
+
break
|
|
257
|
+
positions.append(pos)
|
|
258
|
+
start = pos + len(needle)
|
|
259
|
+
return positions
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _offset_to_line(lines: list[str], scope_start_line: int, scope_offset: int) -> int:
|
|
263
|
+
"""将 scope 内的偏移量转换为文件行号(1-based)。"""
|
|
264
|
+
remaining = scope_offset
|
|
265
|
+
for i in range(scope_start_line - 1, len(lines)):
|
|
266
|
+
remaining -= len(lines[i])
|
|
267
|
+
if remaining < 0:
|
|
268
|
+
return i + 1
|
|
269
|
+
return len(lines)
|
tools/file_read.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Simple file read tool implementation."""
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from core.tool import Tool, ToolResult
|
|
5
|
+
from core.file_state import (
|
|
6
|
+
record_file_state,
|
|
7
|
+
create_snippet,
|
|
8
|
+
clear_session_state as _clear_snippets,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FileReadTool(Tool):
|
|
13
|
+
"""A simple tool to read file contents."""
|
|
14
|
+
|
|
15
|
+
# Phase C:最近读过的文件 → 最后一次 Read 时间戳。压缩流程取 top-N 重新 Read
|
|
16
|
+
# 注入到压缩后对话,让模型不丢失文件内容上下文。
|
|
17
|
+
# 类级别全局变量:worker engine 与主 engine 共享(已知限制,实际工作目录场景下
|
|
18
|
+
# 通常不构成问题;如未来要严格隔离,再改为实例字段)。time.time() 为系统墙钟,
|
|
19
|
+
# 不随调试或 monkey-patch 改变。
|
|
20
|
+
_recent_reads: dict[str, float] = {}
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def mark_recent_read(cls, file_path: str) -> None:
|
|
24
|
+
"""记录一次成功的 Read 调用(用绝对路径作 key,避免相对路径引发的重复)。"""
|
|
25
|
+
if file_path:
|
|
26
|
+
cls._recent_reads[file_path] = time.time()
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def get_recent_reads(cls) -> list[tuple[str, float]]:
|
|
30
|
+
"""返回 (path, ts) 列表,按 ts 倒序(最近的在前)。压缩重注入用。"""
|
|
31
|
+
return sorted(cls._recent_reads.items(), key=lambda kv: kv[1], reverse=True)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def clear_recent_reads(cls) -> None:
|
|
35
|
+
"""清空记录(测试或 /clear 命令使用)。"""
|
|
36
|
+
cls._recent_reads.clear()
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str:
|
|
40
|
+
return "Read"
|
|
41
|
+
|
|
42
|
+
def __init__(self, sandbox_manager=None, session_id: str = ""):
|
|
43
|
+
self._sandbox = sandbox_manager
|
|
44
|
+
self._session_id = session_id
|
|
45
|
+
|
|
46
|
+
def set_session_id(self, session_id: str) -> None:
|
|
47
|
+
"""Phase 1/3: engine 注入当前会话 ID,用于创建 snippet。"""
|
|
48
|
+
self._session_id = session_id
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def description(self) -> str:
|
|
52
|
+
return (
|
|
53
|
+
"Reads a file from the local filesystem. "
|
|
54
|
+
"Usage: Provide an absolute file path to read its contents."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def input_schema(self) -> dict:
|
|
59
|
+
"""
|
|
60
|
+
操作大文件的时候看,第一次可以全读,后续如果要修改文件,那么可以使用offset、limit规定只读取某个片段
|
|
61
|
+
:return:
|
|
62
|
+
"""
|
|
63
|
+
return {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {
|
|
66
|
+
"file_path": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"description": "Absolute path to the file to read"
|
|
69
|
+
},
|
|
70
|
+
"offset": {
|
|
71
|
+
"type": "integer",
|
|
72
|
+
"description": "Line number to start reading from (1-based). Default: 1."
|
|
73
|
+
},
|
|
74
|
+
"limit": {
|
|
75
|
+
"type": "integer",
|
|
76
|
+
"description": "Number of lines to read. Default: read all lines."
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
"required": ["file_path"]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def is_read_only(self) -> bool:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def get_activity_description(self, **kwargs) -> str | None:
|
|
86
|
+
file_path = kwargs.get("file_path", "")
|
|
87
|
+
return f"Reading {file_path}" if file_path else None
|
|
88
|
+
|
|
89
|
+
def execute(self, file_path: str, **kwargs) -> ToolResult:
|
|
90
|
+
"""Execute the file read operation."""
|
|
91
|
+
# 沙箱路径保护:禁止读取受保护目录
|
|
92
|
+
if self._sandbox is not None:
|
|
93
|
+
allowed, reason = self._sandbox.check_path(file_path, "read")
|
|
94
|
+
if not allowed:
|
|
95
|
+
return ToolResult(content=f"Error: {reason}", is_error=True)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
path = Path(file_path)
|
|
99
|
+
|
|
100
|
+
if not path.exists():
|
|
101
|
+
return ToolResult(
|
|
102
|
+
content=f"Error: File not found: {file_path}",
|
|
103
|
+
is_error=True
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if not path.is_file():
|
|
107
|
+
return ToolResult(
|
|
108
|
+
content=f"Error: Not a file: {file_path}",
|
|
109
|
+
is_error=True
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Read file content
|
|
113
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
114
|
+
|
|
115
|
+
# ---- offset / limit 解析 ----
|
|
116
|
+
offset = _parse_positive_int(kwargs.get("offset"), "offset")
|
|
117
|
+
limit = _parse_positive_int(kwargs.get("limit"), "limit")
|
|
118
|
+
if isinstance(offset, str):
|
|
119
|
+
return ToolResult(content=f"Error: {offset}", is_error=True)
|
|
120
|
+
if isinstance(limit, str):
|
|
121
|
+
return ToolResult(content=f"Error: {limit}", is_error=True)
|
|
122
|
+
|
|
123
|
+
# Format with line numbers
|
|
124
|
+
all_lines = content.splitlines(keepends=True)
|
|
125
|
+
total_lines = len(all_lines) if all_lines else 1
|
|
126
|
+
|
|
127
|
+
# 计算实际行范围
|
|
128
|
+
actual_start = offset if offset else 1
|
|
129
|
+
if limit:
|
|
130
|
+
actual_end = min(actual_start + limit - 1, total_lines)
|
|
131
|
+
else:
|
|
132
|
+
actual_end = total_lines
|
|
133
|
+
|
|
134
|
+
# 截取对应行
|
|
135
|
+
selected = all_lines[actual_start - 1:actual_end]
|
|
136
|
+
numbered = "".join(f"{actual_start + i}\t{line}" for i, line in enumerate(selected))
|
|
137
|
+
|
|
138
|
+
from tools.file_edit import FileEditTool
|
|
139
|
+
FileEditTool.mark_file_read(file_path)
|
|
140
|
+
FileEditTool.mark_file_read(str(path.resolve()))
|
|
141
|
+
# Phase C:用 resolve 后的绝对路径作 key,避免同一文件因相对/绝对路径
|
|
142
|
+
# 不同被记成两条。失败路径不调用,避免压缩去 Read 一个本来就读不出来的文件。
|
|
143
|
+
try:
|
|
144
|
+
FileReadTool.mark_recent_read(str(path.resolve()))
|
|
145
|
+
except Exception:
|
|
146
|
+
try:
|
|
147
|
+
FileReadTool.mark_recent_read(file_path)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
# Phase 1: Snippet 系统 — 记录文件状态 + 创建编辑凭证
|
|
152
|
+
snippet_meta = None
|
|
153
|
+
if self._session_id:
|
|
154
|
+
resolved = str(path.resolve())
|
|
155
|
+
stat = path.stat()
|
|
156
|
+
record_file_state(self._session_id, resolved, content, stat.st_mtime)
|
|
157
|
+
# 根据 offset/limit 决定 snippet 的行范围和 scope_type
|
|
158
|
+
is_partial = bool(offset or limit)
|
|
159
|
+
snippet = create_snippet(
|
|
160
|
+
self._session_id, resolved,
|
|
161
|
+
start_line=actual_start,
|
|
162
|
+
end_line=actual_end,
|
|
163
|
+
scope_type="snippet" if is_partial else "full",
|
|
164
|
+
)
|
|
165
|
+
snippet_meta = {
|
|
166
|
+
"snippet_id": snippet.id,
|
|
167
|
+
"file_path": snippet.file_path,
|
|
168
|
+
"start_line": snippet.start_line,
|
|
169
|
+
"end_line": snippet.end_line,
|
|
170
|
+
"scope_type": snippet.scope_type,
|
|
171
|
+
}
|
|
172
|
+
# 将 snippet_id 注入 content 头部,让 LLM 能看到并用于后续 Edit 调用
|
|
173
|
+
header = (
|
|
174
|
+
f"[snippet_id: {snippet_meta['snippet_id']} | "
|
|
175
|
+
f"lines: {snippet_meta['start_line']}-{snippet_meta['end_line']} | "
|
|
176
|
+
f"scope: {snippet_meta['scope_type']}]\n"
|
|
177
|
+
)
|
|
178
|
+
numbered = header + numbered
|
|
179
|
+
|
|
180
|
+
return ToolResult(
|
|
181
|
+
content=numbered,
|
|
182
|
+
metadata=snippet_meta,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
except Exception as e:
|
|
186
|
+
return ToolResult(
|
|
187
|
+
content=f"Error reading file: {e}",
|
|
188
|
+
is_error=True
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_positive_int(value, label: str) -> int | None | str:
|
|
193
|
+
"""解析正整数参数。返回 None=未传入, int=有效值, str=错误信息。"""
|
|
194
|
+
if value is None:
|
|
195
|
+
return None
|
|
196
|
+
if isinstance(value, str):
|
|
197
|
+
value = value.strip()
|
|
198
|
+
if not value:
|
|
199
|
+
return None
|
|
200
|
+
try:
|
|
201
|
+
num = int(value)
|
|
202
|
+
except (ValueError, TypeError):
|
|
203
|
+
return f"{label} must be an integer, got: {value}"
|
|
204
|
+
if num < 1:
|
|
205
|
+
return f"{label} must be >= 1, got: {num}"
|
|
206
|
+
return num
|
tools/file_write.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from core.tool import Tool, ToolResult
|
|
5
|
+
from core.file_state import (
|
|
6
|
+
record_file_state,
|
|
7
|
+
create_snippet,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FileWriteTool(Tool):
|
|
12
|
+
name = "Write"
|
|
13
|
+
description = (
|
|
14
|
+
"Writes a file to the local filesystem.\n\n"
|
|
15
|
+
"Usage:\n"
|
|
16
|
+
"- This tool will overwrite the existing file if one exists at the path.\n"
|
|
17
|
+
"- Prefer the Edit tool for modifying existing files — it only sends the diff. "
|
|
18
|
+
"Only use this tool to create new files or for complete rewrites.\n"
|
|
19
|
+
"- NEVER create documentation files (*.md) or README files unless explicitly requested."
|
|
20
|
+
)
|
|
21
|
+
input_schema = {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"file_path": {"type": "string", "description": "Absolute path to the file to write"},
|
|
25
|
+
"content": {"type": "string", "description": "The full content to write to the file"},
|
|
26
|
+
},
|
|
27
|
+
"required": ["file_path", "content"],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def __init__(self, sandbox_manager=None, session_id: str = ""):
|
|
31
|
+
self._sandbox = sandbox_manager
|
|
32
|
+
self._session_id = session_id
|
|
33
|
+
|
|
34
|
+
def set_session_id(self, session_id: str) -> None:
|
|
35
|
+
"""Phase 3: engine 注入当前会话 ID。"""
|
|
36
|
+
self._session_id = session_id
|
|
37
|
+
|
|
38
|
+
def get_activity_description(self, **kwargs) -> str | None:
|
|
39
|
+
fp = kwargs.get("file_path", "")
|
|
40
|
+
return f"Writing {fp}" if fp else None
|
|
41
|
+
|
|
42
|
+
def execute(self, file_path: str, content: str) -> ToolResult:
|
|
43
|
+
# 沙箱路径保护:禁止写入受保护目录
|
|
44
|
+
if self._sandbox is not None:
|
|
45
|
+
allowed, reason = self._sandbox.check_path(file_path, "write")
|
|
46
|
+
if not allowed:
|
|
47
|
+
return ToolResult(content=f"Error: {reason}", is_error=True)
|
|
48
|
+
|
|
49
|
+
path = Path(file_path)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
path.write_text(content, encoding="utf-8")
|
|
54
|
+
except OSError as e:
|
|
55
|
+
return ToolResult(content=f"Error writing file: {e}", is_error=True)
|
|
56
|
+
|
|
57
|
+
lines = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
|
|
58
|
+
lines = max(lines, 1) # 空文件 → 1 行,避免 snippet end_line=0
|
|
59
|
+
|
|
60
|
+
# Phase 3: 刷新文件状态 + 创建新 snippet
|
|
61
|
+
meta = None
|
|
62
|
+
if self._session_id:
|
|
63
|
+
resolved = str(path.resolve())
|
|
64
|
+
stat = path.stat()
|
|
65
|
+
record_file_state(self._session_id, resolved, content, stat.st_mtime, bump_version=True)
|
|
66
|
+
snippet = create_snippet(self._session_id, resolved, 1, lines, scope_type="full")
|
|
67
|
+
meta = {
|
|
68
|
+
"file_path": resolved,
|
|
69
|
+
"snippet_id": snippet.id,
|
|
70
|
+
"start_line": 1,
|
|
71
|
+
"end_line": lines,
|
|
72
|
+
"scope_type": "full",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return ToolResult(
|
|
76
|
+
content=f"Successfully wrote {lines} lines to {file_path}",
|
|
77
|
+
metadata=meta,
|
|
78
|
+
)
|
tools/glob_tool.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import glob as glob_module
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from core.tool import Tool, ToolResult
|
|
8
|
+
|
|
9
|
+
_MAX_RESULTS = 100
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GlobTool(Tool):
|
|
13
|
+
name = "Glob"
|
|
14
|
+
description = (
|
|
15
|
+
"- Fast file pattern matching tool\n"
|
|
16
|
+
"- Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\"\n"
|
|
17
|
+
"- Returns matching file paths sorted by modification date\n"
|
|
18
|
+
"- Use when you need to find files by name patterns"
|
|
19
|
+
)
|
|
20
|
+
input_schema = {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"properties": {
|
|
23
|
+
"pattern": {"type": "string", "description": "Glob pattern for file matching"},
|
|
24
|
+
"path": {"type": "string", "description": "Directory to search in (default: cwd)"},
|
|
25
|
+
},
|
|
26
|
+
"required": ["pattern"],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def is_read_only(self) -> bool:
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
def get_activity_description(self, **kwargs) -> str | None:
|
|
33
|
+
pattern = kwargs.get("pattern", "")
|
|
34
|
+
return f"Finding {pattern}" if pattern else None
|
|
35
|
+
|
|
36
|
+
def execute(self, pattern: str, path: str = ".") -> ToolResult:
|
|
37
|
+
base = Path(path).resolve()
|
|
38
|
+
if not base.exists():
|
|
39
|
+
return ToolResult(content=f"Error: Directory not found: {path}", is_error=True)
|
|
40
|
+
if not base.is_dir():
|
|
41
|
+
return ToolResult(content=f"Error: Path is not a directory: {path}", is_error=True)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
matches = self._rg_glob(pattern, str(base))
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
matches = self._python_glob(pattern, base)
|
|
47
|
+
|
|
48
|
+
if not matches:
|
|
49
|
+
return ToolResult(content="No files found matching the pattern.")
|
|
50
|
+
|
|
51
|
+
truncated = len(matches) > _MAX_RESULTS
|
|
52
|
+
matches = matches[:_MAX_RESULTS]
|
|
53
|
+
|
|
54
|
+
rel_matches = []
|
|
55
|
+
for m in matches:
|
|
56
|
+
try:
|
|
57
|
+
rel_matches.append(str(Path(m).relative_to(base)))
|
|
58
|
+
except ValueError:
|
|
59
|
+
rel_matches.append(m)
|
|
60
|
+
|
|
61
|
+
result = "\n".join(rel_matches)
|
|
62
|
+
if truncated:
|
|
63
|
+
result += "\n(Results are truncated. Consider using a more specific path or pattern.)"
|
|
64
|
+
return ToolResult(content=result)
|
|
65
|
+
|
|
66
|
+
def _rg_glob(self, pattern: str, search_dir: str) -> list[str]:
|
|
67
|
+
cmd = ["rg", "--files", "--glob", pattern, "--sort=modified",
|
|
68
|
+
"--no-ignore", "--hidden", search_dir]
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
cmd, capture_output=True, text=True,
|
|
71
|
+
encoding="utf-8", errors="replace", timeout=30,
|
|
72
|
+
)
|
|
73
|
+
if result.returncode not in (0, 1):
|
|
74
|
+
raise FileNotFoundError("rg failed")
|
|
75
|
+
output = result.stdout.strip()
|
|
76
|
+
return output.split("\n") if output else []
|
|
77
|
+
|
|
78
|
+
def _python_glob(self, pattern: str, base: Path) -> list[str]:
|
|
79
|
+
matches = glob_module.glob(pattern, root_dir=str(base), recursive=True)
|
|
80
|
+
matches = sorted(matches, key=lambda p: (base / p).stat().st_mtime, reverse=True)
|
|
81
|
+
return [str(base / m) for m in matches]
|