claxcode 0.1.1__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.
- claxcode/__init__.py +3 -0
- claxcode/api/__init__.py +1 -0
- claxcode/api/chat.py +51 -0
- claxcode/api/session.py +66 -0
- claxcode/api/subagents.py +82 -0
- claxcode/api/tools.py +38 -0
- claxcode/cli.py +1214 -0
- claxcode/cli_completion.py +174 -0
- claxcode/config.py +54 -0
- claxcode/core/__init__.py +1 -0
- claxcode/core/agent.py +327 -0
- claxcode/core/checkpoint.py +100 -0
- claxcode/core/context.py +114 -0
- claxcode/core/models.py +446 -0
- claxcode/extensions/__init__.py +1 -0
- claxcode/extensions/hooks.py +131 -0
- claxcode/extensions/mcp.py +13 -0
- claxcode/extensions/plugins.py +10 -0
- claxcode/extensions/skills.py +205 -0
- claxcode/main.py +55 -0
- claxcode/memory/__init__.py +23 -0
- claxcode/memory/auto_memory.py +68 -0
- claxcode/memory/long_term.py +196 -0
- claxcode/memory/project_context.py +20 -0
- claxcode/memory/project_memory.py +194 -0
- claxcode/memory/session_store.py +206 -0
- claxcode/models/__init__.py +1 -0
- claxcode/models/session.py +25 -0
- claxcode/models/subagent.py +25 -0
- claxcode/models/tool.py +24 -0
- claxcode/security/__init__.py +1 -0
- claxcode/security/audit.py +54 -0
- claxcode/security/permissions.py +29 -0
- claxcode/skills/bug-fix/SKILL.md +33 -0
- claxcode/skills/code-review/SKILL.md +35 -0
- claxcode/skills/python-refactor/SKILL.md +22 -0
- claxcode/skills/task-workflow/SKILL.md +38 -0
- claxcode/subagents/__init__.py +1 -0
- claxcode/subagents/base.py +108 -0
- claxcode/subagents/executor.py +16 -0
- claxcode/subagents/explore.py +25 -0
- claxcode/subagents/planner.py +25 -0
- claxcode/tools/__init__.py +14 -0
- claxcode/tools/admin.py +170 -0
- claxcode/tools/base.py +19 -0
- claxcode/tools/browser.py +499 -0
- claxcode/tools/code_intelligence.py +68 -0
- claxcode/tools/custom_example.py +76 -0
- claxcode/tools/execution.py +114 -0
- claxcode/tools/file_operations.py +287 -0
- claxcode/tools/memory_tool.py +29 -0
- claxcode/tools/registry.py +35 -0
- claxcode/tools/search.py +157 -0
- claxcode/tools/ssh.py +534 -0
- claxcode/tools/web.py +83 -0
- claxcode-0.1.1.dist-info/METADATA +359 -0
- claxcode-0.1.1.dist-info/RECORD +60 -0
- claxcode-0.1.1.dist-info/WHEEL +4 -0
- claxcode-0.1.1.dist-info/entry_points.txt +3 -0
- claxcode-0.1.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Execution tools."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from claxcode.tools.base import Tool
|
|
7
|
+
from claxcode.tools.registry import tool
|
|
8
|
+
|
|
9
|
+
# Global flag for tool cancellation
|
|
10
|
+
_tool_cancelled = False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def set_tool_cancelled() -> None:
|
|
14
|
+
"""Set the tool cancellation flag."""
|
|
15
|
+
global _tool_cancelled
|
|
16
|
+
_tool_cancelled = True
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def clear_tool_cancelled() -> None:
|
|
20
|
+
"""Reset the cancellation flag (called before each user message)."""
|
|
21
|
+
global _tool_cancelled
|
|
22
|
+
_tool_cancelled = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_tool_cancelled() -> bool:
|
|
26
|
+
"""Return True if the user requested cancellation (Ctrl+C)."""
|
|
27
|
+
return _tool_cancelled
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@tool
|
|
31
|
+
class RunShell(Tool):
|
|
32
|
+
name = "run_shell"
|
|
33
|
+
description = "Run a shell command and return stdout/stderr."
|
|
34
|
+
input_schema = {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {
|
|
37
|
+
"command": {"type": "string", "description": "Shell command to execute"},
|
|
38
|
+
"cwd": {"type": "string", "description": "Working directory", "default": "."},
|
|
39
|
+
"timeout": {"type": "integer", "default": 60},
|
|
40
|
+
},
|
|
41
|
+
"required": ["command"],
|
|
42
|
+
}
|
|
43
|
+
category = "execution"
|
|
44
|
+
required_permissions = ["execute"]
|
|
45
|
+
|
|
46
|
+
async def execute(self, command: str, cwd: str = ".", timeout: int = 60, **kwargs: Any) -> str:
|
|
47
|
+
proc: asyncio.subprocess.Process | None = None
|
|
48
|
+
try:
|
|
49
|
+
proc = await asyncio.create_subprocess_shell(
|
|
50
|
+
command,
|
|
51
|
+
stdout=asyncio.subprocess.PIPE,
|
|
52
|
+
stderr=asyncio.subprocess.PIPE,
|
|
53
|
+
cwd=cwd,
|
|
54
|
+
)
|
|
55
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
56
|
+
output = stdout.decode("utf-8", errors="replace")
|
|
57
|
+
if stderr:
|
|
58
|
+
output += "\n[stderr]\n" + stderr.decode("utf-8", errors="replace")
|
|
59
|
+
if proc.returncode != 0:
|
|
60
|
+
output += f"\n[exit code: {proc.returncode}]"
|
|
61
|
+
return output
|
|
62
|
+
except TimeoutError:
|
|
63
|
+
if proc is not None and proc.returncode is None:
|
|
64
|
+
proc.kill()
|
|
65
|
+
await proc.wait()
|
|
66
|
+
return f"Error: Command timed out after {timeout}s"
|
|
67
|
+
except KeyboardInterrupt:
|
|
68
|
+
if proc is not None and proc.returncode is None:
|
|
69
|
+
proc.kill()
|
|
70
|
+
await proc.wait()
|
|
71
|
+
raise
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
return f"Error: {exc}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@tool
|
|
77
|
+
class RunTests(Tool):
|
|
78
|
+
name = "run_tests"
|
|
79
|
+
description = "Run the project's test suite (pytest)."
|
|
80
|
+
input_schema = {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"properties": {
|
|
83
|
+
"target": {"type": "string", "default": ".", "description": "Directory or file to test"},
|
|
84
|
+
"verbose": {"type": "boolean", "default": True},
|
|
85
|
+
},
|
|
86
|
+
"required": [],
|
|
87
|
+
}
|
|
88
|
+
category = "execution"
|
|
89
|
+
required_permissions = ["execute"]
|
|
90
|
+
|
|
91
|
+
async def execute(self, target: str = ".", verbose: bool = True, **kwargs: Any) -> str:
|
|
92
|
+
cmd = f"pytest {target}"
|
|
93
|
+
if verbose:
|
|
94
|
+
cmd += " -v"
|
|
95
|
+
return await RunShell().execute(command=cmd, cwd=".")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@tool
|
|
99
|
+
class GitCommand(Tool):
|
|
100
|
+
name = "git_command"
|
|
101
|
+
description = "Run a git command."
|
|
102
|
+
input_schema = {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"properties": {
|
|
105
|
+
"args": {"type": "string", "description": "Git arguments, e.g., 'status' or 'diff HEAD'"},
|
|
106
|
+
"cwd": {"type": "string", "default": "."},
|
|
107
|
+
},
|
|
108
|
+
"required": ["args"],
|
|
109
|
+
}
|
|
110
|
+
category = "execution"
|
|
111
|
+
required_permissions = ["execute"]
|
|
112
|
+
|
|
113
|
+
async def execute(self, args: str, cwd: str = ".", **kwargs: Any) -> str:
|
|
114
|
+
return await RunShell().execute(command=f"git {args}", cwd=cwd)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""File operation tools."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from claxcode.tools.base import Tool
|
|
7
|
+
from claxcode.tools.registry import tool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@tool
|
|
11
|
+
class ReadFile(Tool):
|
|
12
|
+
name = "read_file"
|
|
13
|
+
description = "读取文本文件的内容。"
|
|
14
|
+
input_schema = {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {
|
|
17
|
+
"path": {"type": "string", "description": "Absolute or relative file path"},
|
|
18
|
+
},
|
|
19
|
+
"required": ["path"],
|
|
20
|
+
}
|
|
21
|
+
category = "file_operations"
|
|
22
|
+
required_permissions = ["read"]
|
|
23
|
+
|
|
24
|
+
async def execute(self, path: str, **kwargs: Any) -> str:
|
|
25
|
+
target = Path(path)
|
|
26
|
+
if not target.exists():
|
|
27
|
+
return f"错误:文件不存在: {path}"
|
|
28
|
+
try:
|
|
29
|
+
return target.read_text(encoding="utf-8")
|
|
30
|
+
except UnicodeDecodeError:
|
|
31
|
+
return f"错误:文件可能是二进制文件: {path}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@tool
|
|
35
|
+
class WriteFile(Tool):
|
|
36
|
+
name = "write_file"
|
|
37
|
+
description = "将内容写入文件(覆盖或创建)。"
|
|
38
|
+
input_schema = {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"path": {"type": "string"},
|
|
42
|
+
"content": {"type": "string"},
|
|
43
|
+
},
|
|
44
|
+
"required": ["path", "content"],
|
|
45
|
+
}
|
|
46
|
+
category = "file_operations"
|
|
47
|
+
required_permissions = ["write"]
|
|
48
|
+
|
|
49
|
+
async def execute(self, path: str, content: str, **kwargs: Any) -> str:
|
|
50
|
+
target = Path(path)
|
|
51
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
target.write_text(content, encoding="utf-8")
|
|
53
|
+
return f"文件已写入: {path}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@tool
|
|
57
|
+
class StrReplaceFile(Tool):
|
|
58
|
+
name = "str_replace_file"
|
|
59
|
+
description = "替换文件中的指定字符串。"
|
|
60
|
+
input_schema = {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"path": {"type": "string"},
|
|
64
|
+
"old": {"type": "string"},
|
|
65
|
+
"new": {"type": "string"},
|
|
66
|
+
"replace_all": {"type": "boolean", "default": False},
|
|
67
|
+
},
|
|
68
|
+
"required": ["path", "old", "new"],
|
|
69
|
+
}
|
|
70
|
+
category = "file_operations"
|
|
71
|
+
required_permissions = ["write"]
|
|
72
|
+
|
|
73
|
+
async def execute(self, path: str, old: str, new: str, replace_all: bool = False, **kwargs: Any) -> str:
|
|
74
|
+
target = Path(path)
|
|
75
|
+
if not target.exists():
|
|
76
|
+
return f"错误:文件不存在: {path}"
|
|
77
|
+
text = target.read_text(encoding="utf-8")
|
|
78
|
+
if old not in text:
|
|
79
|
+
return f"错误:在 {path} 中未找到要替换的字符串"
|
|
80
|
+
if replace_all:
|
|
81
|
+
count = text.count(old)
|
|
82
|
+
text = text.replace(old, new)
|
|
83
|
+
else:
|
|
84
|
+
text = text.replace(old, new, 1)
|
|
85
|
+
count = 1
|
|
86
|
+
target.write_text(text, encoding="utf-8")
|
|
87
|
+
return f"已在 {path} 中替换 {count} 处"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@tool
|
|
91
|
+
class ViewFile(Tool):
|
|
92
|
+
name = "view_file"
|
|
93
|
+
description = "查看文件内容,带行号,支持分页。"
|
|
94
|
+
input_schema = {
|
|
95
|
+
"type": "object",
|
|
96
|
+
"properties": {
|
|
97
|
+
"path": {"type": "string", "description": "文件路径"},
|
|
98
|
+
"view_range": {
|
|
99
|
+
"type": "array",
|
|
100
|
+
"items": {"type": "integer"},
|
|
101
|
+
"minItems": 2,
|
|
102
|
+
"maxItems": 2,
|
|
103
|
+
"description": "查看范围 [起始行, 结束行],1-based",
|
|
104
|
+
},
|
|
105
|
+
"max_lines": {"type": "integer", "default": 50, "description": "默认最大显示行数"},
|
|
106
|
+
},
|
|
107
|
+
"required": ["path"],
|
|
108
|
+
}
|
|
109
|
+
category = "file_operations"
|
|
110
|
+
required_permissions = ["read"]
|
|
111
|
+
|
|
112
|
+
async def execute(self, path: str, view_range: list[int] | None = None, max_lines: int = 50, **kwargs: Any) -> str:
|
|
113
|
+
target = Path(path)
|
|
114
|
+
if not target.exists():
|
|
115
|
+
return f"错误:文件不存在: {path}"
|
|
116
|
+
try:
|
|
117
|
+
text = target.read_text(encoding="utf-8")
|
|
118
|
+
except UnicodeDecodeError:
|
|
119
|
+
return f"错误:文件可能是二进制文件: {path}"
|
|
120
|
+
|
|
121
|
+
lines = text.splitlines()
|
|
122
|
+
total = len(lines)
|
|
123
|
+
|
|
124
|
+
start = 0
|
|
125
|
+
end = total
|
|
126
|
+
|
|
127
|
+
if view_range is not None:
|
|
128
|
+
start = max(0, view_range[0] - 1)
|
|
129
|
+
end = min(total, view_range[1])
|
|
130
|
+
elif total > max_lines:
|
|
131
|
+
end = max_lines
|
|
132
|
+
|
|
133
|
+
width = len(str(total))
|
|
134
|
+
output_lines = []
|
|
135
|
+
for i in range(start, end):
|
|
136
|
+
output_lines.append(f"{i + 1:>{width}} | {lines[i]}")
|
|
137
|
+
|
|
138
|
+
result = "\n".join(output_lines)
|
|
139
|
+
|
|
140
|
+
if end < total:
|
|
141
|
+
result += f"\n\n... (共 {total} 行,显示 {start + 1}-{end} 行。使用 view_range 查看更多)"
|
|
142
|
+
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@tool
|
|
147
|
+
class EditFile(Tool):
|
|
148
|
+
name = "edit_file"
|
|
149
|
+
description = "精确编辑文件:使用 old_string / new_string 块进行多行替换,自动验证匹配唯一性。"
|
|
150
|
+
input_schema = {
|
|
151
|
+
"type": "object",
|
|
152
|
+
"properties": {
|
|
153
|
+
"path": {"type": "string", "description": "文件路径"},
|
|
154
|
+
"edits": {
|
|
155
|
+
"type": "array",
|
|
156
|
+
"description": "编辑块列表,每个块包含 old_string 和 new_string",
|
|
157
|
+
"items": {
|
|
158
|
+
"type": "object",
|
|
159
|
+
"properties": {
|
|
160
|
+
"old_string": {"type": "string", "description": "要替换的精确内容(多行)"},
|
|
161
|
+
"new_string": {"type": "string", "description": "替换后的内容"},
|
|
162
|
+
},
|
|
163
|
+
"required": ["old_string", "new_string"],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
"required": ["path", "edits"],
|
|
168
|
+
}
|
|
169
|
+
category = "file_operations"
|
|
170
|
+
required_permissions = ["write"]
|
|
171
|
+
|
|
172
|
+
async def execute(self, path: str, edits: list[dict], **kwargs: Any) -> str:
|
|
173
|
+
target = Path(path)
|
|
174
|
+
if not target.exists():
|
|
175
|
+
return f"错误:文件不存在: {path}"
|
|
176
|
+
text = target.read_text(encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
# 先验证所有编辑块,收集位置信息
|
|
179
|
+
edit_infos = []
|
|
180
|
+
for idx, edit in enumerate(edits):
|
|
181
|
+
old = edit["old_string"]
|
|
182
|
+
new = edit["new_string"]
|
|
183
|
+
count = text.count(old)
|
|
184
|
+
|
|
185
|
+
if count == 0:
|
|
186
|
+
# 返回文件前 50 行上下文帮助定位
|
|
187
|
+
lines = text.splitlines()
|
|
188
|
+
context_lines = lines[:50]
|
|
189
|
+
width = len(str(len(lines))) if lines else 1
|
|
190
|
+
ctx = "\n".join(f"{i + 1:>{width}} | {line}" for i, line in enumerate(context_lines))
|
|
191
|
+
return f"错误:第 {idx + 1} 个编辑块的 old_string 在文件中未找到。\n\n文件前 {len(context_lines)} 行上下文:\n{ctx}\n\n提示:请核对 old_string 是否完全匹配(包括缩进和换行)。"
|
|
192
|
+
|
|
193
|
+
if count > 1:
|
|
194
|
+
# 收集所有匹配位置
|
|
195
|
+
matches = []
|
|
196
|
+
start = 0
|
|
197
|
+
while True:
|
|
198
|
+
pos = text.find(old, start)
|
|
199
|
+
if pos == -1:
|
|
200
|
+
break
|
|
201
|
+
# 计算行号
|
|
202
|
+
line_start = text[:pos].count("\n") + 1
|
|
203
|
+
line_end = line_start + old.count("\n")
|
|
204
|
+
matches.append((line_start, line_end))
|
|
205
|
+
start = pos + 1
|
|
206
|
+
match_lines = "\n".join(f" - 第 {s}-{e} 行" for s, e in matches)
|
|
207
|
+
return f"错误:第 {idx + 1} 个编辑块的 old_string 在文件中匹配到 {count} 处:\n{match_lines}\n提示:请增加更多上下文使匹配唯一。"
|
|
208
|
+
|
|
209
|
+
# 记录位置(用于从后往前排序)
|
|
210
|
+
pos = text.find(old)
|
|
211
|
+
edit_infos.append({"idx": idx, "old": old, "new": new, "pos": pos})
|
|
212
|
+
|
|
213
|
+
# 从后往前执行替换(避免位置偏移)
|
|
214
|
+
edit_infos.sort(key=lambda x: x["pos"], reverse=True)
|
|
215
|
+
applied = []
|
|
216
|
+
for info in edit_infos:
|
|
217
|
+
old = info["old"]
|
|
218
|
+
new = info["new"]
|
|
219
|
+
pos = text.find(old)
|
|
220
|
+
if pos == -1:
|
|
221
|
+
return (
|
|
222
|
+
f"错误:第 {info['idx'] + 1} 个编辑块与前序编辑块冲突"
|
|
223
|
+
"(old_string 已被前面的替换覆盖)。文件未修改,请拆分后重试。"
|
|
224
|
+
)
|
|
225
|
+
line_start = text[:pos].count("\n") + 1
|
|
226
|
+
line_end = line_start + old.count("\n")
|
|
227
|
+
new_line_end = line_start + new.count("\n")
|
|
228
|
+
text = text[:pos] + new + text[pos + len(old):]
|
|
229
|
+
applied.append((info["idx"] + 1, line_start, line_end, new_line_end))
|
|
230
|
+
|
|
231
|
+
target.write_text(text, encoding="utf-8")
|
|
232
|
+
|
|
233
|
+
# 按原始顺序输出结果
|
|
234
|
+
applied.sort(key=lambda x: x[0])
|
|
235
|
+
lines = [f"✅ 已编辑: {path}"]
|
|
236
|
+
for block_num, s, e, ne in applied:
|
|
237
|
+
if s == e and s == ne:
|
|
238
|
+
lines.append(f" 块 {block_num}: 第 {s} 行")
|
|
239
|
+
elif s == e:
|
|
240
|
+
lines.append(f" 块 {block_num}: 第 {s} 行 → 第 {s}-{ne} 行")
|
|
241
|
+
else:
|
|
242
|
+
lines.append(f" 块 {block_num}: 第 {s}-{e} 行 → 第 {s}-{ne} 行")
|
|
243
|
+
return "\n".join(lines)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@tool
|
|
247
|
+
class CreateDirectory(Tool):
|
|
248
|
+
name = "create_directory"
|
|
249
|
+
description = "创建目录及必要的父目录。"
|
|
250
|
+
input_schema = {
|
|
251
|
+
"type": "object",
|
|
252
|
+
"properties": {
|
|
253
|
+
"path": {"type": "string"},
|
|
254
|
+
},
|
|
255
|
+
"required": ["path"],
|
|
256
|
+
}
|
|
257
|
+
category = "file_operations"
|
|
258
|
+
required_permissions = ["write"]
|
|
259
|
+
|
|
260
|
+
async def execute(self, path: str, **kwargs: Any) -> str:
|
|
261
|
+
target = Path(path)
|
|
262
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
return f"目录已创建: {path}"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@tool
|
|
267
|
+
class RenameFile(Tool):
|
|
268
|
+
name = "rename_file"
|
|
269
|
+
description = "重命名或移动文件/目录。"
|
|
270
|
+
input_schema = {
|
|
271
|
+
"type": "object",
|
|
272
|
+
"properties": {
|
|
273
|
+
"old_path": {"type": "string"},
|
|
274
|
+
"new_path": {"type": "string"},
|
|
275
|
+
},
|
|
276
|
+
"required": ["old_path", "new_path"],
|
|
277
|
+
}
|
|
278
|
+
category = "file_operations"
|
|
279
|
+
required_permissions = ["write"]
|
|
280
|
+
|
|
281
|
+
async def execute(self, old_path: str, new_path: str, **kwargs: Any) -> str:
|
|
282
|
+
source = Path(old_path)
|
|
283
|
+
dest = Path(new_path)
|
|
284
|
+
if not source.exists():
|
|
285
|
+
return f"错误:源文件不存在: {old_path}"
|
|
286
|
+
source.rename(dest)
|
|
287
|
+
return f"已重命名 {old_path} -> {new_path}"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Memory tool — lets the agent record durable facts and preferences."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from claxcode.memory.auto_memory import auto_memory
|
|
6
|
+
from claxcode.tools.base import Tool
|
|
7
|
+
from claxcode.tools.registry import tool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@tool
|
|
11
|
+
class RememberFactTool(Tool):
|
|
12
|
+
name = "remember_fact"
|
|
13
|
+
description = (
|
|
14
|
+
"记录一条稳定的全局用户偏好或事实到自动记忆(键值对),"
|
|
15
|
+
"以后的会话会自动加载。例如用户偏好的语言和通用回答风格。"
|
|
16
|
+
)
|
|
17
|
+
input_schema = {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {
|
|
20
|
+
"key": {"type": "string", "description": "事实名称,如 '回复语言'"},
|
|
21
|
+
"value": {"type": "string", "description": "事实内容,如 '始终使用中文'"},
|
|
22
|
+
},
|
|
23
|
+
"required": ["key", "value"],
|
|
24
|
+
}
|
|
25
|
+
category = "memory"
|
|
26
|
+
required_permissions = ["write"]
|
|
27
|
+
|
|
28
|
+
async def execute(self, key: str, value: str, **kwargs: Any) -> str:
|
|
29
|
+
return auto_memory.remember(key, value)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tool registry with decorator-based registration."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from claxcode.tools.base import Tool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ToolRegistry:
|
|
8
|
+
"""Central registry for tools."""
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self._tools: dict[str, Tool] = {}
|
|
12
|
+
|
|
13
|
+
def register(self, tool: Tool) -> Tool:
|
|
14
|
+
self._tools[tool.name] = tool
|
|
15
|
+
return tool
|
|
16
|
+
|
|
17
|
+
def get(self, name: str) -> Tool | None:
|
|
18
|
+
return self._tools.get(name)
|
|
19
|
+
|
|
20
|
+
def list_tools(self) -> list[Tool]:
|
|
21
|
+
return list(self._tools.values())
|
|
22
|
+
|
|
23
|
+
def unregister(self, name: str) -> bool:
|
|
24
|
+
"""Remove a tool from the registry. Returns True if removed."""
|
|
25
|
+
return self._tools.pop(name, None) is not None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
registry = ToolRegistry()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def tool(cls: type[Tool]) -> type[Tool]:
|
|
32
|
+
"""Decorator to auto-register a Tool subclass."""
|
|
33
|
+
instance = cls()
|
|
34
|
+
registry.register(instance)
|
|
35
|
+
return cls
|
claxcode/tools/search.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Search tools."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from claxcode.tools.base import Tool
|
|
10
|
+
from claxcode.tools.registry import tool
|
|
11
|
+
|
|
12
|
+
# 依赖/缓存目录:搜索时跳过,避免扫入海量无关文件
|
|
13
|
+
IGNORED_DIRS = {
|
|
14
|
+
".git", ".hg", ".svn",
|
|
15
|
+
".venv", "venv", "node_modules", "__pycache__",
|
|
16
|
+
".pytest_cache", ".ruff_cache", ".mypy_cache", ".tox", ".eggs",
|
|
17
|
+
".claxcode", ".idea", "dist", "build",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _iter_files(root: Path) -> Iterator[Path]:
|
|
22
|
+
"""Recursively yield files under root, pruning dependency/cache dirs."""
|
|
23
|
+
stack = [root]
|
|
24
|
+
while stack:
|
|
25
|
+
current = stack.pop()
|
|
26
|
+
try:
|
|
27
|
+
entries = sorted(current.iterdir())
|
|
28
|
+
except OSError:
|
|
29
|
+
continue
|
|
30
|
+
for p in entries:
|
|
31
|
+
if p.is_dir():
|
|
32
|
+
if p.name not in IGNORED_DIRS:
|
|
33
|
+
stack.append(p)
|
|
34
|
+
elif p.is_file():
|
|
35
|
+
yield p
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@tool
|
|
39
|
+
class GlobSearch(Tool):
|
|
40
|
+
name = "glob_search"
|
|
41
|
+
description = "递归查找匹配 glob 模式的文件(自动跳过 .git/.venv/node_modules 等目录)。"
|
|
42
|
+
input_schema = {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {
|
|
45
|
+
"pattern": {"type": "string", "description": "Glob pattern, e.g., src/**/*.py"},
|
|
46
|
+
"directory": {"type": "string", "default": "."},
|
|
47
|
+
},
|
|
48
|
+
"required": ["pattern"],
|
|
49
|
+
}
|
|
50
|
+
category = "search"
|
|
51
|
+
required_permissions = ["read"]
|
|
52
|
+
|
|
53
|
+
async def execute(self, pattern: str, directory: str = ".", **kwargs: Any) -> str:
|
|
54
|
+
root = Path(directory)
|
|
55
|
+
matches = []
|
|
56
|
+
for p in _iter_files(root):
|
|
57
|
+
rel = str(p.relative_to(root))
|
|
58
|
+
if fnmatch.fnmatch(rel, pattern):
|
|
59
|
+
matches.append(rel)
|
|
60
|
+
if not matches:
|
|
61
|
+
return "未找到文件。"
|
|
62
|
+
note = f"\n... [已截断,共 {len(matches)} 个]" if len(matches) > 200 else ""
|
|
63
|
+
return "\n".join(matches[:200]) + note
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@tool
|
|
67
|
+
class GrepSearch(Tool):
|
|
68
|
+
name = "grep_search"
|
|
69
|
+
description = "使用正则表达式搜索文件内容(自动跳过 .git/.venv/node_modules 等目录)。"
|
|
70
|
+
input_schema = {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"pattern": {"type": "string", "description": "Regex pattern to search for"},
|
|
74
|
+
"directory": {"type": "string", "default": "."},
|
|
75
|
+
"glob": {"type": "string", "default": "*"},
|
|
76
|
+
"output_mode": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"enum": ["content", "files_with_matches"],
|
|
79
|
+
"default": "content",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
"required": ["pattern"],
|
|
83
|
+
}
|
|
84
|
+
category = "search"
|
|
85
|
+
required_permissions = ["read"]
|
|
86
|
+
|
|
87
|
+
async def execute(
|
|
88
|
+
self,
|
|
89
|
+
pattern: str,
|
|
90
|
+
directory: str = ".",
|
|
91
|
+
glob: str = "*",
|
|
92
|
+
output_mode: str = "content",
|
|
93
|
+
**kwargs: Any,
|
|
94
|
+
) -> str:
|
|
95
|
+
root = Path(directory)
|
|
96
|
+
regex = re.compile(pattern)
|
|
97
|
+
results = []
|
|
98
|
+
for p in _iter_files(root):
|
|
99
|
+
if not fnmatch.fnmatch(p.name, glob):
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
text = p.read_text(encoding="utf-8")
|
|
103
|
+
except Exception:
|
|
104
|
+
continue
|
|
105
|
+
if not regex.search(text):
|
|
106
|
+
continue
|
|
107
|
+
if output_mode == "files_with_matches":
|
|
108
|
+
results.append(str(p.relative_to(root)))
|
|
109
|
+
else:
|
|
110
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
111
|
+
if regex.search(line):
|
|
112
|
+
results.append(f"{p.relative_to(root)}:{i}:{line}")
|
|
113
|
+
if not results:
|
|
114
|
+
return "未找到匹配项。"
|
|
115
|
+
note = f"\n... [已截断,共 {len(results)} 条]" if len(results) > 250 else ""
|
|
116
|
+
return "\n".join(results[:250]) + note
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@tool
|
|
120
|
+
class ExploreCodebase(Tool):
|
|
121
|
+
name = "explore_codebase"
|
|
122
|
+
description = "高级探索:列出顶层文件并总结结构。"
|
|
123
|
+
input_schema = {
|
|
124
|
+
"type": "object",
|
|
125
|
+
"properties": {
|
|
126
|
+
"directory": {"type": "string", "default": "."},
|
|
127
|
+
"depth": {"type": "integer", "default": 2},
|
|
128
|
+
},
|
|
129
|
+
"required": [],
|
|
130
|
+
}
|
|
131
|
+
category = "search"
|
|
132
|
+
required_permissions = ["read"]
|
|
133
|
+
|
|
134
|
+
async def execute(self, directory: str = ".", depth: int = 2, **kwargs: Any) -> str:
|
|
135
|
+
root = Path(directory)
|
|
136
|
+
lines = []
|
|
137
|
+
|
|
138
|
+
def walk(current: Path, current_depth: int):
|
|
139
|
+
if current_depth > depth:
|
|
140
|
+
return
|
|
141
|
+
prefix = " " * current_depth
|
|
142
|
+
try:
|
|
143
|
+
entries = sorted(current.iterdir())
|
|
144
|
+
except OSError:
|
|
145
|
+
return
|
|
146
|
+
for p in entries:
|
|
147
|
+
if p.name in IGNORED_DIRS:
|
|
148
|
+
continue
|
|
149
|
+
if p.name.startswith(".") and p.name not in {".gitignore", ".env"}:
|
|
150
|
+
continue
|
|
151
|
+
lines.append(f"{prefix}{p.name}{'/' if p.is_dir() else ''}")
|
|
152
|
+
if p.is_dir():
|
|
153
|
+
walk(p, current_depth + 1)
|
|
154
|
+
|
|
155
|
+
if root.exists():
|
|
156
|
+
walk(root, 0)
|
|
157
|
+
return "\n".join(lines) if lines else "空目录。"
|