mosscode 0.1.0__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.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/tools/files.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from .base import Params, Tool, ToolFailure, file_io
|
|
8
|
+
from .diffing import unified_diff
|
|
9
|
+
|
|
10
|
+
READ_LIMIT = 2000
|
|
11
|
+
LINE_LIMIT = 2000 # Characters per displayed line.
|
|
12
|
+
READ_BUDGET = 56 * 1024 # Bytes per page, below the registry's 64 KiB result cap.
|
|
13
|
+
PREVIEW_LINES = 16 # Diff lines shown in the approval panel.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReadParams(Params):
|
|
17
|
+
path: str
|
|
18
|
+
offset: int = Field(default=1, ge=1)
|
|
19
|
+
limit: int = Field(default=READ_LIMIT, ge=1, le=100000)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WriteParams(Params):
|
|
23
|
+
path: str
|
|
24
|
+
content: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EditParams(Params):
|
|
28
|
+
path: str
|
|
29
|
+
old_text: str = Field(min_length=1)
|
|
30
|
+
new_text: str
|
|
31
|
+
replace_all: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FileState:
|
|
35
|
+
"""What the model last saw of each file: (mtime_ns, size) at read or own write."""
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self._seen: dict[str, tuple[int, int]] = {}
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def stamp(path: Path) -> tuple[int, int]:
|
|
42
|
+
info = path.stat()
|
|
43
|
+
return info.st_mtime_ns, info.st_size
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _key(path: Path) -> str:
|
|
47
|
+
return os.path.normcase(str(path))
|
|
48
|
+
|
|
49
|
+
def clear(self) -> None:
|
|
50
|
+
self._seen.clear()
|
|
51
|
+
|
|
52
|
+
def copy(self) -> "FileState":
|
|
53
|
+
"""A fork starts from what its parent has read."""
|
|
54
|
+
state = FileState()
|
|
55
|
+
state._seen = dict(self._seen)
|
|
56
|
+
return state
|
|
57
|
+
|
|
58
|
+
def record(self, path: Path, stamp: tuple[int, int] | None = None) -> None:
|
|
59
|
+
self._seen[self._key(path)] = stamp if stamp is not None else self.stamp(path)
|
|
60
|
+
|
|
61
|
+
def check(self, path: Path) -> None:
|
|
62
|
+
seen = self._seen.get(self._key(path))
|
|
63
|
+
if seen is None:
|
|
64
|
+
raise ToolFailure("not_read", "修改已有文件前需要先用 read_file 读取它;文件未修改。")
|
|
65
|
+
if self.stamp(path) != seen:
|
|
66
|
+
raise ToolFailure(
|
|
67
|
+
"stale_read", "文件在上次读取后被修改(可能来自用户、命令或其他程序);请重新读取后再修改。文件未修改。"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read_text(path: Path) -> str:
|
|
72
|
+
with path.open("r", encoding="utf-8", newline="") as stream:
|
|
73
|
+
text = stream.read()
|
|
74
|
+
if "\0" in text:
|
|
75
|
+
raise ToolFailure("binary_file", "目标包含二进制内容,不能作为文本文件操作。")
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def atomic_write(path: Path, text: str) -> None:
|
|
80
|
+
if path.is_dir():
|
|
81
|
+
raise IsADirectoryError()
|
|
82
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
previous_mode = path.stat().st_mode if path.exists() else None
|
|
84
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".mosscode-", dir=path.parent)
|
|
85
|
+
try:
|
|
86
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as stream:
|
|
87
|
+
stream.write(text)
|
|
88
|
+
if previous_mode is not None:
|
|
89
|
+
os.chmod(temporary, previous_mode)
|
|
90
|
+
os.replace(temporary, path)
|
|
91
|
+
finally:
|
|
92
|
+
if os.path.exists(temporary):
|
|
93
|
+
os.unlink(temporary)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def line_count(text: str) -> int:
|
|
97
|
+
return len(text.splitlines())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class FileTool(Tool):
|
|
101
|
+
def __init__(self, root: Path, state: FileState | None = None):
|
|
102
|
+
super().__init__(root)
|
|
103
|
+
self.state = state if state is not None else FileState()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ReadFile(FileTool):
|
|
107
|
+
read_only = True
|
|
108
|
+
name = "read_file"
|
|
109
|
+
description = (
|
|
110
|
+
f"读取 UTF-8 文本文件。每行格式为“行号 + Tab + 原文”;offset 从 1 开始,limit 默认 {READ_LIMIT} 行。"
|
|
111
|
+
f"超过 {LINE_LIMIT} 字符的行会截断,单页还有字节上限并会提示下一页的 offset。项目目录之外的路径需要用户确认。"
|
|
112
|
+
)
|
|
113
|
+
prompt_summary = "读取文本文件,可按行分段查看"
|
|
114
|
+
prompt_guidelines = (
|
|
115
|
+
"使用 read_file 查看文本原文,优先于 shell 读取;修改或覆盖已有文件前必须先用 read_file 读取,文件被外部改动后需重新读取。",
|
|
116
|
+
"read_file 输出中 Tab 之前的行号不是文件正文,不要把行号加入编辑参数。遇到截断时根据返回提示继续读取相关范围。",
|
|
117
|
+
)
|
|
118
|
+
params_model = ReadParams
|
|
119
|
+
|
|
120
|
+
async def execute(self, params: ReadParams):
|
|
121
|
+
def read():
|
|
122
|
+
path = self.read_path(params.path)
|
|
123
|
+
# Stamp before reading: a change during the read makes the record stale.
|
|
124
|
+
stamp = FileState.stamp(path)
|
|
125
|
+
selected, more, size, long_lines, total = [], False, 0, 0, 0
|
|
126
|
+
with path.open("r", encoding="utf-8", newline="") as stream:
|
|
127
|
+
for number, line in enumerate(stream, 1):
|
|
128
|
+
total = number
|
|
129
|
+
if "\0" in line:
|
|
130
|
+
raise ToolFailure("binary_file", "目标包含二进制内容。")
|
|
131
|
+
if number < params.offset:
|
|
132
|
+
continue
|
|
133
|
+
if more:
|
|
134
|
+
continue # Count remaining lines only.
|
|
135
|
+
if len(selected) >= params.limit:
|
|
136
|
+
more = True
|
|
137
|
+
continue
|
|
138
|
+
text = line.rstrip("\n").rstrip("\r")
|
|
139
|
+
if len(text) > LINE_LIMIT:
|
|
140
|
+
text = text[:LINE_LIMIT] + "…(本行过长,已截断)"
|
|
141
|
+
long_lines += 1
|
|
142
|
+
displayed = f"{number:>6}\t{text}"
|
|
143
|
+
encoded = len(displayed.encode("utf-8")) + 1
|
|
144
|
+
if size + encoded > READ_BUDGET:
|
|
145
|
+
more = True
|
|
146
|
+
continue
|
|
147
|
+
selected.append(displayed)
|
|
148
|
+
size += encoded
|
|
149
|
+
self.state.record(path, stamp)
|
|
150
|
+
next_offset = params.offset + len(selected)
|
|
151
|
+
if not selected:
|
|
152
|
+
output = "(文件为空)" if total == 0 else f"(offset={params.offset} 超出文件末尾,文件共 {total} 行)"
|
|
153
|
+
else:
|
|
154
|
+
output = "\n".join(selected)
|
|
155
|
+
if more:
|
|
156
|
+
output += f"\n(共 {total} 行,还有内容,请从 offset={next_offset} 继续读取)"
|
|
157
|
+
return output, {
|
|
158
|
+
"path": str(path),
|
|
159
|
+
"lines": len(selected),
|
|
160
|
+
"total_lines": total,
|
|
161
|
+
"truncated": more,
|
|
162
|
+
"long_lines": long_lines,
|
|
163
|
+
"next_offset": next_offset if more else None,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return await file_io(read)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class WriteFile(FileTool):
|
|
170
|
+
name = "write_file"
|
|
171
|
+
description = "创建或覆盖 UTF-8 文本文件,自动创建父目录。content 是要写入的完整文件内容。覆盖已有文件前必须先用 read_file 读取。"
|
|
172
|
+
prompt_summary = "创建文件或整体重写文件"
|
|
173
|
+
prompt_guidelines = (
|
|
174
|
+
"write_file 用于新建文件或确有必要的整体重写,优先于 shell 写入;局部修改优先使用可用的精确编辑工具。",
|
|
175
|
+
"write_file 会覆盖已有内容;覆盖前先读取文件,保留需要的原有内容和用户无关改动。",
|
|
176
|
+
)
|
|
177
|
+
params_model = WriteParams
|
|
178
|
+
|
|
179
|
+
def approval_preview(self, params: WriteParams) -> str:
|
|
180
|
+
path = self.path(params.path)
|
|
181
|
+
name = self.display(path)
|
|
182
|
+
if path.exists():
|
|
183
|
+
return unified_diff(read_text(path), params.content, name, limit=PREVIEW_LINES) or "内容与现有文件相同。"
|
|
184
|
+
lines = params.content.splitlines()
|
|
185
|
+
shown = [f"+{line}" for line in lines[:PREVIEW_LINES]]
|
|
186
|
+
if len(lines) > PREVIEW_LINES:
|
|
187
|
+
shown.append(f"…(共 {len(lines)} 行,仅显示前 {PREVIEW_LINES} 行)")
|
|
188
|
+
return "\n".join([f"新建文件 {name}({line_count(params.content)} 行)", *shown])
|
|
189
|
+
|
|
190
|
+
async def execute(self, params: WriteParams):
|
|
191
|
+
def write():
|
|
192
|
+
path = self.path(params.path)
|
|
193
|
+
existed, before = path.is_file(), None
|
|
194
|
+
if existed:
|
|
195
|
+
self.state.check(path)
|
|
196
|
+
try:
|
|
197
|
+
before = read_text(path)
|
|
198
|
+
except (UnicodeError, ToolFailure):
|
|
199
|
+
pass # Overwriting a non-text file: no diff.
|
|
200
|
+
atomic_write(path, params.content)
|
|
201
|
+
self.state.record(path)
|
|
202
|
+
name = self.display(path)
|
|
203
|
+
details = {"path": str(path), "bytes": len(params.content.encode("utf-8")), "created": not existed}
|
|
204
|
+
if not existed:
|
|
205
|
+
return f"已创建 {name}({line_count(params.content)} 行)。", details
|
|
206
|
+
if before is None:
|
|
207
|
+
return f"已覆盖 {name}(原文件不是 UTF-8 文本)。", details
|
|
208
|
+
details["diff"] = unified_diff(before, params.content, name)
|
|
209
|
+
return f"已覆盖 {name}:{line_count(before)} 行 → {line_count(params.content)} 行。", details
|
|
210
|
+
|
|
211
|
+
return await file_io(write)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class EditFile(FileTool):
|
|
215
|
+
name = "edit_file"
|
|
216
|
+
description = (
|
|
217
|
+
"将文件中的 old_text 精确替换为 new_text,空格和换行也参与匹配。默认要求恰好匹配一处;"
|
|
218
|
+
"replace_all=true 时替换全部匹配。零次或不唯一的匹配不修改文件。编辑前必须先用 read_file 读取。"
|
|
219
|
+
)
|
|
220
|
+
prompt_summary = "以唯一原文匹配精确替换一个片段"
|
|
221
|
+
prompt_guidelines = (
|
|
222
|
+
"局部修改优先 edit_file;编辑前读取相关原文,old_text 不包含读取输出的行号。",
|
|
223
|
+
"edit_file 的 old_text 使用足以唯一定位的最小原文上下文,避免携带大段无关内容;new_text 保留周边缩进和代码风格。重命名等需要替换全部出现位置时使用 replace_all。",
|
|
224
|
+
"edit_file 匹配为零时重新读取当前文件;匹配多次时补充邻近上下文。依据返回的错误调整参数,不猜测原文或原样重复失败调用。",
|
|
225
|
+
)
|
|
226
|
+
params_model = EditParams
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _replace(text: str, params: EditParams) -> tuple[str, int, int, int]:
|
|
230
|
+
"""New text, match count and the changed line range; nothing is written."""
|
|
231
|
+
if params.old_text == params.new_text:
|
|
232
|
+
raise ToolFailure("no_change", "old_text 与 new_text 相同,无需修改;文件未修改。")
|
|
233
|
+
# The model reads CRLF files as LF lines; match in LF and write back CRLF.
|
|
234
|
+
crlf = "\r\n" in text and text.count("\r\n") == text.count("\n")
|
|
235
|
+
work, old, new = text, params.old_text, params.new_text
|
|
236
|
+
if crlf:
|
|
237
|
+
work, old, new = (value.replace("\r\n", "\n") for value in (text, old, new))
|
|
238
|
+
positions, start = [], 0
|
|
239
|
+
step = len(old) if params.replace_all else 1 # Overlaps count when one match is required.
|
|
240
|
+
while (position := work.find(old, start)) >= 0:
|
|
241
|
+
positions.append(position)
|
|
242
|
+
start = position + step
|
|
243
|
+
count = len(positions)
|
|
244
|
+
if count == 0:
|
|
245
|
+
raise ToolFailure(
|
|
246
|
+
"match_count",
|
|
247
|
+
"原文匹配 0 次;文件未修改。请重新读取文件确认当前内容,old_text 不要包含行号。",
|
|
248
|
+
matches=0,
|
|
249
|
+
)
|
|
250
|
+
if count > 1 and not params.replace_all:
|
|
251
|
+
raise ToolFailure(
|
|
252
|
+
"match_count",
|
|
253
|
+
f"原文匹配 {count} 次,需要恰好 1 次;文件未修改。"
|
|
254
|
+
"补充邻近上下文使其唯一,或设置 replace_all=true 替换全部。",
|
|
255
|
+
matches=count,
|
|
256
|
+
)
|
|
257
|
+
result = work.replace(old, new) if params.replace_all else work.replace(old, new, 1)
|
|
258
|
+
# Line range of the change in the new text; the prefix before the first match is unchanged.
|
|
259
|
+
first = work[: positions[0]].count("\n") + 1
|
|
260
|
+
last_start = positions[-1] + (len(new) - len(old)) * (count - 1)
|
|
261
|
+
last = result[:last_start].count("\n") + 1 + new.count("\n")
|
|
262
|
+
if crlf:
|
|
263
|
+
result = result.replace("\n", "\r\n")
|
|
264
|
+
return result, count, first, last
|
|
265
|
+
|
|
266
|
+
def approval_preview(self, params: EditParams) -> str:
|
|
267
|
+
path = self.path(params.path)
|
|
268
|
+
text = read_text(path)
|
|
269
|
+
result = self._replace(text, params)[0]
|
|
270
|
+
return unified_diff(text, result, self.display(path), limit=PREVIEW_LINES)
|
|
271
|
+
|
|
272
|
+
async def execute(self, params: EditParams):
|
|
273
|
+
def edit():
|
|
274
|
+
path = self.path(params.path)
|
|
275
|
+
self.state.check(path)
|
|
276
|
+
text = read_text(path)
|
|
277
|
+
result, count, first, last = self._replace(text, params)
|
|
278
|
+
atomic_write(path, result)
|
|
279
|
+
self.state.record(path)
|
|
280
|
+
name = self.display(path)
|
|
281
|
+
where = f"第 {first} 行" if last <= first else f"第 {first}–{last} 行"
|
|
282
|
+
return (
|
|
283
|
+
f"已修改 {name}:替换 {count} 处,{where}。",
|
|
284
|
+
{"path": str(path), "matches": count, "diff": unified_diff(text, result, name)},
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return await file_io(edit)
|
mosscode/tools/memory.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The memory tool: save or delete one note; never takes a path."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from ..memory import SCOPE_LABELS, MemoryFailure, MemoryStore
|
|
8
|
+
from .base import Params, Tool, ToolFailure, file_io
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MemoryParams(Params):
|
|
12
|
+
action: Literal["save", "delete"]
|
|
13
|
+
scope: Literal["user", "project"]
|
|
14
|
+
name: str = Field(min_length=1, max_length=200)
|
|
15
|
+
type: Literal["user", "feedback", "project", "reference"] | None = None
|
|
16
|
+
description: str = Field(default="", max_length=2000)
|
|
17
|
+
body: str = Field(default="", max_length=40000)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Memory(Tool):
|
|
21
|
+
name = "memory"
|
|
22
|
+
permission_kind = "memory" # No approval: it writes only the two note directories.
|
|
23
|
+
plan_allowed = True # Offered in plan mode too; not read-only, so it never runs concurrently.
|
|
24
|
+
description = (
|
|
25
|
+
"保存或删除跨会话的记忆笔记。save 写入或覆盖同名笔记(更新日期),delete 删除笔记;"
|
|
26
|
+
"成功后返回文件路径,索引由程序重新生成,下次会话开始时出现在系统提示中。"
|
|
27
|
+
"只写笔记目录,不需要审批,计划模式中也可以直接调用。"
|
|
28
|
+
"scope:user 为用户级(所有项目通用),project 为项目级(只用于当前项目)。"
|
|
29
|
+
"type:user(用户本人的偏好、习惯)、feedback(用户纠正或确认过的做法)、"
|
|
30
|
+
"project(代码和 git 历史看不出的项目知识,只能写到 project)、reference(外部资源的位置)。"
|
|
31
|
+
)
|
|
32
|
+
prompt_summary = "保存或删除跨会话的记忆笔记"
|
|
33
|
+
prompt_guidelines = (
|
|
34
|
+
"用户明确要求“记住……”“以后……”“不要再……”时立即调用 memory 保存;发现已有笔记有误时更新或删除。"
|
|
35
|
+
"计划模式中同样直接调用,不写进计划等待批准。",
|
|
36
|
+
"先看系统提示中的笔记索引,有同名或同义的笔记就用相同的 scope 和 name 覆盖更新,而不是新建。",
|
|
37
|
+
"不记录代码结构、git 历史中已有的内容和只与本次对话有关的细节;不记录密钥、令牌、密码。",
|
|
38
|
+
"name 只用小写字母、数字和连字符;description 一行,最多 150 字符,写清什么时候有用;body 最多 4 KB。"
|
|
39
|
+
"feedback 和 project 类型的正文写出事实后,另起两行“原因:……”和“适用方式:……”。",
|
|
40
|
+
)
|
|
41
|
+
params_model = MemoryParams
|
|
42
|
+
|
|
43
|
+
def __init__(self, root, store: MemoryStore):
|
|
44
|
+
super().__init__(root)
|
|
45
|
+
self.store = store
|
|
46
|
+
|
|
47
|
+
async def execute(self, params: MemoryParams):
|
|
48
|
+
label = SCOPE_LABELS[params.scope]
|
|
49
|
+
try:
|
|
50
|
+
if params.action == "delete":
|
|
51
|
+
path = await file_io(self.store.delete, params.scope, params.name)
|
|
52
|
+
return (
|
|
53
|
+
f"已删除{label}笔记 {params.name}:{path}",
|
|
54
|
+
{"action": "delete", "scope": params.scope, "name": params.name, "path": str(path)},
|
|
55
|
+
)
|
|
56
|
+
note, created = await file_io(
|
|
57
|
+
self.store.save, params.scope, params.name, params.type, params.description, params.body
|
|
58
|
+
)
|
|
59
|
+
except MemoryFailure as exc:
|
|
60
|
+
raise ToolFailure(exc.code, str(exc)) from None
|
|
61
|
+
verb = "已保存" if created else "已更新"
|
|
62
|
+
return (
|
|
63
|
+
f"{verb}{label}笔记 {note.name}:{note.path}\n索引已重新生成;本会话的系统提示不会变化,下次会话开始时生效。",
|
|
64
|
+
{"action": "save", "scope": params.scope, "name": note.name, "path": str(note.path), "created": created},
|
|
65
|
+
)
|
mosscode/tools/plan.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The plan-mode exit: the permission policy asks the user and switches modes."""
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from .base import Params, Tool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PlanParams(Params):
|
|
9
|
+
plan: str = Field(min_length=1, max_length=20000)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExitPlanMode(Tool):
|
|
13
|
+
name = "exit_plan_mode"
|
|
14
|
+
plan_only = True # Not read-only: runs alone, after any reads in the same response.
|
|
15
|
+
description = (
|
|
16
|
+
"计划模式下调查完成后调用,提交 Markdown 格式的实施计划请用户审阅。用户批准后立即退出计划模式,"
|
|
17
|
+
"可以继续使用写入、编辑和命令工具执行计划;用户选择继续规划时仍处于计划模式。"
|
|
18
|
+
)
|
|
19
|
+
prompt_summary = "提交计划,请用户批准后开始执行"
|
|
20
|
+
prompt_guidelines = (
|
|
21
|
+
"计划模式下完成调查后用 exit_plan_mode 提交计划,不要用文字询问用户是否开始执行;计划写明要改的文件、步骤和验证方法,简洁具体。",
|
|
22
|
+
"只为需要动手实施的任务提交计划;纯调查或问答直接回答。用户选择继续规划时,根据反馈调整后再提交。",
|
|
23
|
+
)
|
|
24
|
+
params_model = PlanParams
|
|
25
|
+
|
|
26
|
+
async def execute(self, params: PlanParams):
|
|
27
|
+
return (
|
|
28
|
+
"用户已批准计划,已退出计划模式。现在按计划执行;写入、编辑和命令按当前权限档位处理。",
|
|
29
|
+
{"plan": params.plan},
|
|
30
|
+
)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Bounded asynchronous subprocess I/O, including process-tree cleanup."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from .base import Params, Tool, ToolFailure
|
|
12
|
+
from .shell import Shell, resolve_shell
|
|
13
|
+
|
|
14
|
+
OUTPUT_LIMIT = 32768
|
|
15
|
+
# Command output keeps both ends: failure summaries are usually at the end.
|
|
16
|
+
COMMAND_HEAD, COMMAND_TAIL = 1024 * 1024, 1024 * 1024 # Memory guard; layer 1 stores large output.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def terminate_tree(process: asyncio.subprocess.Process) -> None:
|
|
20
|
+
if os.name == "nt":
|
|
21
|
+
# A Job Object owns only this invocation and its descendants. Closing it
|
|
22
|
+
# terminates descendants even if the original shell has already exited.
|
|
23
|
+
job = getattr(process, "_mosscode_job", None)
|
|
24
|
+
if job is not None:
|
|
25
|
+
job.close()
|
|
26
|
+
elif process.returncode is None:
|
|
27
|
+
killer = await asyncio.create_subprocess_exec(
|
|
28
|
+
"taskkill",
|
|
29
|
+
"/PID",
|
|
30
|
+
str(process.pid),
|
|
31
|
+
"/T",
|
|
32
|
+
"/F",
|
|
33
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
34
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
35
|
+
creationflags=0x08000000,
|
|
36
|
+
)
|
|
37
|
+
await killer.wait()
|
|
38
|
+
else:
|
|
39
|
+
try:
|
|
40
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
41
|
+
except ProcessLookupError:
|
|
42
|
+
pass
|
|
43
|
+
if process.returncode is None:
|
|
44
|
+
try:
|
|
45
|
+
process.kill()
|
|
46
|
+
except ProcessLookupError:
|
|
47
|
+
pass
|
|
48
|
+
await process.wait()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _decode_cut(data: bytes, *, start: bool) -> str:
|
|
52
|
+
"""Decode one side of a cut without a broken character at the cut edge."""
|
|
53
|
+
if start:
|
|
54
|
+
skip = 0
|
|
55
|
+
while skip < min(3, len(data)) and 0x80 <= data[skip] < 0xC0:
|
|
56
|
+
skip += 1
|
|
57
|
+
return data[skip:].decode("utf-8", errors="replace")
|
|
58
|
+
for trim in range(min(3, len(data)) + 1):
|
|
59
|
+
try:
|
|
60
|
+
return data[: len(data) - trim].decode("utf-8")
|
|
61
|
+
except UnicodeDecodeError:
|
|
62
|
+
continue
|
|
63
|
+
return data.decode("utf-8", errors="replace")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def _feed(stream, data: bytes) -> None:
|
|
67
|
+
"""Write stdin and close it; a child that exits without reading is not an error."""
|
|
68
|
+
try:
|
|
69
|
+
if data:
|
|
70
|
+
stream.write(data)
|
|
71
|
+
await stream.drain()
|
|
72
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
73
|
+
pass
|
|
74
|
+
finally:
|
|
75
|
+
stream.close()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def run_process(
|
|
79
|
+
args: list[str],
|
|
80
|
+
cwd: Path,
|
|
81
|
+
*,
|
|
82
|
+
check: bool = True,
|
|
83
|
+
limit: int = OUTPUT_LIMIT,
|
|
84
|
+
tail: int = 0,
|
|
85
|
+
env: dict[str, str] | None = None,
|
|
86
|
+
input: bytes | None = None,
|
|
87
|
+
):
|
|
88
|
+
"""input, when given, is written to the child's stdin, which is then closed; otherwise stdin is empty."""
|
|
89
|
+
options = {"creationflags": 0x08000000} if os.name == "nt" else {"start_new_session": True}
|
|
90
|
+
# Windows commands wait on a one-byte gate until Job assignment completes.
|
|
91
|
+
# This closes the race in which a fast shell could spawn before assignment.
|
|
92
|
+
# os.read takes exactly the gate byte, so the child inherits the rest of the pipe as its stdin.
|
|
93
|
+
inherit = "" if input is not None else ",stdin=subprocess.DEVNULL"
|
|
94
|
+
gate = f"import os,subprocess,sys; os.read(0,1); sys.exit(subprocess.call(sys.argv[1:]{inherit}))"
|
|
95
|
+
command = [sys.executable, "-c", gate, *args] if os.name == "nt" else args
|
|
96
|
+
piped = os.name == "nt" or input is not None
|
|
97
|
+
spawn = asyncio.create_task(
|
|
98
|
+
asyncio.create_subprocess_exec(
|
|
99
|
+
*command,
|
|
100
|
+
cwd=cwd,
|
|
101
|
+
stdin=asyncio.subprocess.PIPE if piped else asyncio.subprocess.DEVNULL,
|
|
102
|
+
stdout=asyncio.subprocess.PIPE,
|
|
103
|
+
stderr=asyncio.subprocess.PIPE,
|
|
104
|
+
env=env,
|
|
105
|
+
**options,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
process = await asyncio.shield(spawn)
|
|
110
|
+
except asyncio.CancelledError:
|
|
111
|
+
process = await spawn
|
|
112
|
+
await terminate_tree(process)
|
|
113
|
+
raise
|
|
114
|
+
if os.name == "nt":
|
|
115
|
+
from .windows_job import WindowsJob
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
process._mosscode_job = WindowsJob(process.pid)
|
|
119
|
+
except OSError:
|
|
120
|
+
await terminate_tree(process)
|
|
121
|
+
raise ToolFailure("process_cleanup_unavailable", "无法建立命令进程的生命周期边界,命令已终止。") from None
|
|
122
|
+
feeder = None
|
|
123
|
+
if piped:
|
|
124
|
+
feeder = asyncio.create_task(_feed(process.stdin, (b"\n" if os.name == "nt" else b"") + (input or b"")))
|
|
125
|
+
|
|
126
|
+
async def drain(stream):
|
|
127
|
+
# Keep the first `limit` bytes and, with `tail`, a rolling window of the
|
|
128
|
+
# bytes after them; head + end is the whole stream when nothing was dropped.
|
|
129
|
+
head, end = bytearray(), bytearray()
|
|
130
|
+
total = 0
|
|
131
|
+
while chunk := await stream.read(8192):
|
|
132
|
+
total += len(chunk)
|
|
133
|
+
room = max(0, limit - len(head))
|
|
134
|
+
head.extend(chunk[:room])
|
|
135
|
+
if tail:
|
|
136
|
+
end.extend(chunk[room:])
|
|
137
|
+
del end[: max(0, len(end) - tail)]
|
|
138
|
+
omitted = total - len(head) - len(end)
|
|
139
|
+
if omitted <= 0:
|
|
140
|
+
return (bytes(head) + bytes(end)).decode("utf-8", errors="replace"), False
|
|
141
|
+
if not tail:
|
|
142
|
+
return head.decode("utf-8", errors="replace"), True
|
|
143
|
+
marker = f"\n…(中间省略 {omitted} 字节)…\n"
|
|
144
|
+
return _decode_cut(bytes(head), start=False) + marker + _decode_cut(bytes(end), start=True), True
|
|
145
|
+
|
|
146
|
+
stdout_task = asyncio.create_task(drain(process.stdout))
|
|
147
|
+
stderr_task = asyncio.create_task(drain(process.stderr))
|
|
148
|
+
try:
|
|
149
|
+
await process.wait()
|
|
150
|
+
stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
|
|
151
|
+
details = {
|
|
152
|
+
"stdout": stdout[0],
|
|
153
|
+
"stderr": stderr[0],
|
|
154
|
+
"exit_code": process.returncode,
|
|
155
|
+
"truncated": stdout[1] or stderr[1],
|
|
156
|
+
}
|
|
157
|
+
if check and process.returncode != 0:
|
|
158
|
+
raise ToolFailure("nonzero_exit", f"命令退出码为 {process.returncode}。", **details)
|
|
159
|
+
return details
|
|
160
|
+
finally:
|
|
161
|
+
await terminate_tree(process)
|
|
162
|
+
await asyncio.gather(stdout_task, stderr_task, *(task for task in (feeder,) if task), return_exceptions=True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class CommandParams(Params):
|
|
166
|
+
command: str = Field(min_length=1)
|
|
167
|
+
cwd: str | None = None
|
|
168
|
+
timeout: float = Field(default=120.0, gt=0, le=600, allow_inf_nan=False)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
SHELL_GUIDELINES = {
|
|
172
|
+
"Git Bash": "当前 shell 是 Windows 上的 Git Bash:使用 bash 语法和正斜杠路径(如 C:/Users 或 /c/Users),空设备写 /dev/null 而不是 NUL;Windows 原生程序仍可直接调用。",
|
|
173
|
+
"PowerShell": "当前 shell 是 PowerShell:使用 PowerShell 语法;Windows PowerShell 5 不支持 && 和 ||,用 ; 分隔并检查 $LASTEXITCODE。",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class ExecuteCommand(Tool):
|
|
178
|
+
name = "execute_command"
|
|
179
|
+
prompt_summary = "执行测试、构建等 shell 命令"
|
|
180
|
+
params_model = CommandParams
|
|
181
|
+
plan_commands = True
|
|
182
|
+
|
|
183
|
+
def __init__(self, root: Path, shell: Shell | str | None = None):
|
|
184
|
+
super().__init__(root)
|
|
185
|
+
self.shell = shell if isinstance(shell, Shell) else resolve_shell(shell or "auto")
|
|
186
|
+
self.description = (
|
|
187
|
+
f"在 {self.shell.label} 中执行命令,返回 stdout、stderr 和退出码。cwd 可指定工作目录;"
|
|
188
|
+
"timeout 以秒计,默认 120、最大 600。输出过长时保留开头和结尾。"
|
|
189
|
+
)
|
|
190
|
+
self.prompt_guidelines = (
|
|
191
|
+
"execute_command 用于测试、构建和专用工具未覆盖的任务;已有专用工具时优先用于读取、搜索、写入和编辑。",
|
|
192
|
+
"execute_command 的命令遵循当前 shell 的语法,正确引用含空格的路径。依据退出码和输出判断结果,不能仅因有输出就宣布成功。",
|
|
193
|
+
*((SHELL_GUIDELINES[self.shell.label],) if self.shell.label in SHELL_GUIDELINES else ()),
|
|
194
|
+
"git status/diff/log/show、ls、cat、grep 等只读命令(参数限于项目内,不含管道和重定向)无需审批,计划模式中也可使用;其他命令按权限档位审批。",
|
|
195
|
+
"execute_command 超时或取消可能留下部分效果;重复有副作用的命令前先检查状态,不通过增大超时掩盖持续失败。",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async def execute(self, params: CommandParams):
|
|
199
|
+
cwd = self.path(params.cwd) if params.cwd is not None else self.root
|
|
200
|
+
details = await run_process(
|
|
201
|
+
self.shell.args(params.command),
|
|
202
|
+
cwd,
|
|
203
|
+
check=False,
|
|
204
|
+
limit=COMMAND_HEAD,
|
|
205
|
+
tail=COMMAND_TAIL,
|
|
206
|
+
env=self.shell.env(params.command),
|
|
207
|
+
)
|
|
208
|
+
output = details["stdout"]
|
|
209
|
+
if details["stderr"]:
|
|
210
|
+
output += ("\n" if output else "") + "stderr:\n" + details["stderr"]
|
|
211
|
+
if details["exit_code"] != 0:
|
|
212
|
+
# The model sees only output, so the failing command's streams must be in it.
|
|
213
|
+
raise ToolFailure(
|
|
214
|
+
"nonzero_exit", f"命令退出码为 {details['exit_code']}。" + (f"\n{output}" if output else ""), **details
|
|
215
|
+
)
|
|
216
|
+
return output or "命令完成,无输出。", details
|