kimi-cli 0.35__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.
Potentially problematic release.
This version of kimi-cli might be problematic. Click here for more details.
- kimi_cli/CHANGELOG.md +304 -0
- kimi_cli/__init__.py +374 -0
- kimi_cli/agent.py +261 -0
- kimi_cli/agents/koder/README.md +3 -0
- kimi_cli/agents/koder/agent.yaml +24 -0
- kimi_cli/agents/koder/sub.yaml +11 -0
- kimi_cli/agents/koder/system.md +72 -0
- kimi_cli/config.py +138 -0
- kimi_cli/llm.py +8 -0
- kimi_cli/metadata.py +117 -0
- kimi_cli/prompts/metacmds/__init__.py +4 -0
- kimi_cli/prompts/metacmds/compact.md +74 -0
- kimi_cli/prompts/metacmds/init.md +21 -0
- kimi_cli/py.typed +0 -0
- kimi_cli/share.py +8 -0
- kimi_cli/soul/__init__.py +59 -0
- kimi_cli/soul/approval.py +69 -0
- kimi_cli/soul/context.py +142 -0
- kimi_cli/soul/denwarenji.py +37 -0
- kimi_cli/soul/kimisoul.py +248 -0
- kimi_cli/soul/message.py +76 -0
- kimi_cli/soul/toolset.py +25 -0
- kimi_cli/soul/wire.py +101 -0
- kimi_cli/tools/__init__.py +85 -0
- kimi_cli/tools/bash/__init__.py +97 -0
- kimi_cli/tools/bash/bash.md +31 -0
- kimi_cli/tools/dmail/__init__.py +38 -0
- kimi_cli/tools/dmail/dmail.md +15 -0
- kimi_cli/tools/file/__init__.py +21 -0
- kimi_cli/tools/file/glob.md +17 -0
- kimi_cli/tools/file/glob.py +149 -0
- kimi_cli/tools/file/grep.md +5 -0
- kimi_cli/tools/file/grep.py +285 -0
- kimi_cli/tools/file/patch.md +8 -0
- kimi_cli/tools/file/patch.py +131 -0
- kimi_cli/tools/file/read.md +14 -0
- kimi_cli/tools/file/read.py +139 -0
- kimi_cli/tools/file/replace.md +7 -0
- kimi_cli/tools/file/replace.py +132 -0
- kimi_cli/tools/file/write.md +5 -0
- kimi_cli/tools/file/write.py +107 -0
- kimi_cli/tools/mcp.py +85 -0
- kimi_cli/tools/task/__init__.py +156 -0
- kimi_cli/tools/task/task.md +26 -0
- kimi_cli/tools/test.py +55 -0
- kimi_cli/tools/think/__init__.py +21 -0
- kimi_cli/tools/think/think.md +1 -0
- kimi_cli/tools/todo/__init__.py +27 -0
- kimi_cli/tools/todo/set_todo_list.md +15 -0
- kimi_cli/tools/utils.py +150 -0
- kimi_cli/tools/web/__init__.py +4 -0
- kimi_cli/tools/web/fetch.md +1 -0
- kimi_cli/tools/web/fetch.py +94 -0
- kimi_cli/tools/web/search.md +1 -0
- kimi_cli/tools/web/search.py +126 -0
- kimi_cli/ui/__init__.py +68 -0
- kimi_cli/ui/acp/__init__.py +441 -0
- kimi_cli/ui/print/__init__.py +176 -0
- kimi_cli/ui/shell/__init__.py +326 -0
- kimi_cli/ui/shell/console.py +3 -0
- kimi_cli/ui/shell/liveview.py +158 -0
- kimi_cli/ui/shell/metacmd.py +309 -0
- kimi_cli/ui/shell/prompt.py +574 -0
- kimi_cli/ui/shell/setup.py +192 -0
- kimi_cli/ui/shell/update.py +204 -0
- kimi_cli/utils/changelog.py +101 -0
- kimi_cli/utils/logging.py +18 -0
- kimi_cli/utils/message.py +8 -0
- kimi_cli/utils/path.py +23 -0
- kimi_cli/utils/provider.py +64 -0
- kimi_cli/utils/pyinstaller.py +24 -0
- kimi_cli/utils/string.py +12 -0
- kimi_cli-0.35.dist-info/METADATA +24 -0
- kimi_cli-0.35.dist-info/RECORD +76 -0
- kimi_cli-0.35.dist-info/WHEEL +4 -0
- kimi_cli-0.35.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import stat
|
|
6
|
+
import tarfile
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import override
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
import ripgrepy
|
|
13
|
+
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
import kimi_cli
|
|
17
|
+
from kimi_cli.share import get_share_dir
|
|
18
|
+
from kimi_cli.utils.logging import logger
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Params(BaseModel):
|
|
22
|
+
pattern: str = Field(
|
|
23
|
+
description="The regular expression pattern to search for in file contents"
|
|
24
|
+
)
|
|
25
|
+
path: str = Field(
|
|
26
|
+
description=(
|
|
27
|
+
"File or directory to search in. Defaults to current working directory. "
|
|
28
|
+
"If specified, it must be an absolute path."
|
|
29
|
+
),
|
|
30
|
+
default=".",
|
|
31
|
+
)
|
|
32
|
+
glob: str | None = Field(
|
|
33
|
+
description=(
|
|
34
|
+
"Glob pattern to filter files (e.g. `*.js`, `*.{ts,tsx}`). No filter by default."
|
|
35
|
+
),
|
|
36
|
+
default=None,
|
|
37
|
+
)
|
|
38
|
+
output_mode: str = Field(
|
|
39
|
+
description=(
|
|
40
|
+
"`content`: Show matching lines (supports `-B`, `-A`, `-C`, `-n`, `head_limit`); "
|
|
41
|
+
"`files_with_matches`: Show file paths (supports `head_limit`); "
|
|
42
|
+
"`count_matches`: Show total number of matches. "
|
|
43
|
+
"Defaults to `files_with_matches`."
|
|
44
|
+
),
|
|
45
|
+
default="files_with_matches",
|
|
46
|
+
)
|
|
47
|
+
before_context: int | None = Field(
|
|
48
|
+
alias="-B",
|
|
49
|
+
description=(
|
|
50
|
+
"Number of lines to show before each match (the `-B` option). "
|
|
51
|
+
"Requires `output_mode` to be `content`."
|
|
52
|
+
),
|
|
53
|
+
default=None,
|
|
54
|
+
)
|
|
55
|
+
after_context: int | None = Field(
|
|
56
|
+
alias="-A",
|
|
57
|
+
description=(
|
|
58
|
+
"Number of lines to show after each match (the `-A` option). "
|
|
59
|
+
"Requires `output_mode` to be `content`."
|
|
60
|
+
),
|
|
61
|
+
default=None,
|
|
62
|
+
)
|
|
63
|
+
context: int | None = Field(
|
|
64
|
+
alias="-C",
|
|
65
|
+
description=(
|
|
66
|
+
"Number of lines to show before and after each match (the `-C` option). "
|
|
67
|
+
"Requires `output_mode` to be `content`."
|
|
68
|
+
),
|
|
69
|
+
default=None,
|
|
70
|
+
)
|
|
71
|
+
line_number: bool = Field(
|
|
72
|
+
alias="-n",
|
|
73
|
+
description=(
|
|
74
|
+
"Show line numbers in output (the `-n` option). Requires `output_mode` to be `content`."
|
|
75
|
+
),
|
|
76
|
+
default=False,
|
|
77
|
+
)
|
|
78
|
+
ignore_case: bool = Field(
|
|
79
|
+
alias="-i",
|
|
80
|
+
description="Case insensitive search (the `-i` option).",
|
|
81
|
+
default=False,
|
|
82
|
+
)
|
|
83
|
+
type: str | None = Field(
|
|
84
|
+
description=(
|
|
85
|
+
"File type to search. Examples: py, rust, js, ts, go, java, etc. "
|
|
86
|
+
"More efficient than `glob` for standard file types."
|
|
87
|
+
),
|
|
88
|
+
default=None,
|
|
89
|
+
)
|
|
90
|
+
head_limit: int | None = Field(
|
|
91
|
+
description=(
|
|
92
|
+
"Limit output to first N lines, equivalent to `| head -N`. "
|
|
93
|
+
"Works across all output modes: content (limits output lines), "
|
|
94
|
+
"files_with_matches (limits file paths), count_matches (limits count entries). "
|
|
95
|
+
"By default, no limit is applied."
|
|
96
|
+
),
|
|
97
|
+
default=None,
|
|
98
|
+
)
|
|
99
|
+
multiline: bool = Field(
|
|
100
|
+
description=(
|
|
101
|
+
"Enable multiline mode where `.` matches newlines and patterns can span "
|
|
102
|
+
"lines (the `-U` and `--multiline-dotall` options). "
|
|
103
|
+
"By default, multiline mode is disabled."
|
|
104
|
+
),
|
|
105
|
+
default=False,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
RG_VERSION = "15.0.0"
|
|
110
|
+
RG_BASE_URL = "http://cdn.kimi.com/binaries/kimi-cli/rg"
|
|
111
|
+
_RG_DOWNLOAD_LOCK = asyncio.Lock()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _rg_binary_name() -> str:
|
|
115
|
+
return "rg.exe" if os.name == "nt" else "rg"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _find_existing_rg(bin_name: str) -> Path | None:
|
|
119
|
+
share_bin = get_share_dir() / "bin" / bin_name
|
|
120
|
+
if share_bin.is_file():
|
|
121
|
+
return share_bin
|
|
122
|
+
|
|
123
|
+
local_dep = Path(kimi_cli.__file__).parent / "deps" / "bin" / bin_name
|
|
124
|
+
if local_dep.is_file():
|
|
125
|
+
return local_dep
|
|
126
|
+
|
|
127
|
+
system_rg = shutil.which("rg")
|
|
128
|
+
if system_rg:
|
|
129
|
+
return Path(system_rg)
|
|
130
|
+
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _detect_target() -> str | None:
|
|
135
|
+
sys_name = platform.system()
|
|
136
|
+
mach = platform.machine().lower()
|
|
137
|
+
|
|
138
|
+
if mach in ("x86_64", "amd64"):
|
|
139
|
+
arch = "x86_64"
|
|
140
|
+
elif mach in ("arm64", "aarch64"):
|
|
141
|
+
arch = "aarch64"
|
|
142
|
+
else:
|
|
143
|
+
logger.error("Unsupported architecture for ripgrep: {mach}", mach=mach)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
if sys_name == "Darwin":
|
|
147
|
+
os_name = "apple-darwin"
|
|
148
|
+
elif sys_name == "Linux":
|
|
149
|
+
os_name = "unknown-linux-musl" if arch == "x86_64" else "unknown-linux-gnu"
|
|
150
|
+
else:
|
|
151
|
+
logger.error("Unsupported operating system for ripgrep: {sys_name}", sys_name=sys_name)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
return f"{arch}-{os_name}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _download_and_install_rg(bin_name: str) -> Path:
|
|
158
|
+
target = _detect_target()
|
|
159
|
+
if not target:
|
|
160
|
+
raise RuntimeError("Unsupported platform for ripgrep download")
|
|
161
|
+
|
|
162
|
+
filename = f"ripgrep-{RG_VERSION}-{target}.tar.gz"
|
|
163
|
+
url = f"{RG_BASE_URL}/{filename}"
|
|
164
|
+
logger.info("Downloading ripgrep from {url}", url=url)
|
|
165
|
+
|
|
166
|
+
share_bin_dir = get_share_dir() / "bin"
|
|
167
|
+
share_bin_dir.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
destination = share_bin_dir / bin_name
|
|
169
|
+
|
|
170
|
+
async with aiohttp.ClientSession() as session:
|
|
171
|
+
with tempfile.TemporaryDirectory(prefix="kimi-rg-") as tmpdir:
|
|
172
|
+
tar_path = Path(tmpdir) / filename
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
async with session.get(url) as resp:
|
|
176
|
+
resp.raise_for_status()
|
|
177
|
+
with open(tar_path, "wb") as fh:
|
|
178
|
+
async for chunk in resp.content.iter_chunked(1024 * 64):
|
|
179
|
+
if chunk:
|
|
180
|
+
fh.write(chunk)
|
|
181
|
+
except (aiohttp.ClientError, TimeoutError) as exc:
|
|
182
|
+
raise RuntimeError("Failed to download ripgrep binary") from exc
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
with tarfile.open(tar_path, "r:gz") as tar:
|
|
186
|
+
member = next(
|
|
187
|
+
(m for m in tar.getmembers() if Path(m.name).name == bin_name),
|
|
188
|
+
None,
|
|
189
|
+
)
|
|
190
|
+
if not member:
|
|
191
|
+
raise RuntimeError("Ripgrep binary not found in archive")
|
|
192
|
+
extracted = tar.extractfile(member)
|
|
193
|
+
if not extracted:
|
|
194
|
+
raise RuntimeError("Failed to extract ripgrep binary")
|
|
195
|
+
with open(destination, "wb") as dest_fh:
|
|
196
|
+
shutil.copyfileobj(extracted, dest_fh)
|
|
197
|
+
except (tarfile.TarError, OSError) as exc:
|
|
198
|
+
raise RuntimeError("Failed to extract ripgrep archive") from exc
|
|
199
|
+
|
|
200
|
+
destination.chmod(destination.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
201
|
+
logger.info("Installed ripgrep to {destination}", destination=destination)
|
|
202
|
+
return destination
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _ensure_rg_path() -> str:
|
|
206
|
+
bin_name = _rg_binary_name()
|
|
207
|
+
existing = _find_existing_rg(bin_name)
|
|
208
|
+
if existing:
|
|
209
|
+
return str(existing)
|
|
210
|
+
|
|
211
|
+
async with _RG_DOWNLOAD_LOCK:
|
|
212
|
+
existing = _find_existing_rg(bin_name)
|
|
213
|
+
if existing:
|
|
214
|
+
return str(existing)
|
|
215
|
+
|
|
216
|
+
downloaded = await _download_and_install_rg(bin_name)
|
|
217
|
+
return str(downloaded)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Grep(CallableTool2[Params]):
|
|
221
|
+
name: str = "Grep"
|
|
222
|
+
description: str = (Path(__file__).parent / "grep.md").read_text()
|
|
223
|
+
params: type[Params] = Params
|
|
224
|
+
|
|
225
|
+
@override
|
|
226
|
+
async def __call__(self, params: Params) -> ToolReturnType:
|
|
227
|
+
try:
|
|
228
|
+
# Initialize ripgrep with pattern and path
|
|
229
|
+
rg_path = await _ensure_rg_path()
|
|
230
|
+
logger.debug("Using ripgrep binary: {rg_bin}", rg_bin=rg_path)
|
|
231
|
+
rg = ripgrepy.Ripgrepy(params.pattern, params.path, rg_path=rg_path)
|
|
232
|
+
|
|
233
|
+
# Apply search options
|
|
234
|
+
if params.ignore_case:
|
|
235
|
+
rg = rg.ignore_case()
|
|
236
|
+
if params.multiline:
|
|
237
|
+
rg = rg.multiline().multiline_dotall()
|
|
238
|
+
|
|
239
|
+
# Content display options (only for content mode)
|
|
240
|
+
if params.output_mode == "content":
|
|
241
|
+
if params.before_context is not None:
|
|
242
|
+
rg = rg.before_context(params.before_context)
|
|
243
|
+
if params.after_context is not None:
|
|
244
|
+
rg = rg.after_context(params.after_context)
|
|
245
|
+
if params.context is not None:
|
|
246
|
+
rg = rg.context(params.context)
|
|
247
|
+
if params.line_number:
|
|
248
|
+
rg = rg.line_number()
|
|
249
|
+
|
|
250
|
+
# File filtering options
|
|
251
|
+
if params.glob:
|
|
252
|
+
rg = rg.glob(params.glob)
|
|
253
|
+
if params.type:
|
|
254
|
+
rg = rg.type_(params.type)
|
|
255
|
+
|
|
256
|
+
# Set output mode
|
|
257
|
+
if params.output_mode == "files_with_matches":
|
|
258
|
+
rg = rg.files_with_matches()
|
|
259
|
+
elif params.output_mode == "count_matches":
|
|
260
|
+
rg = rg.count_matches()
|
|
261
|
+
|
|
262
|
+
# Execute search
|
|
263
|
+
result = rg.run()
|
|
264
|
+
|
|
265
|
+
# Get results
|
|
266
|
+
output = result.as_string
|
|
267
|
+
|
|
268
|
+
# Apply head limit if specified
|
|
269
|
+
if params.head_limit is not None:
|
|
270
|
+
lines = output.split("\n")
|
|
271
|
+
if len(lines) > params.head_limit:
|
|
272
|
+
lines = lines[: params.head_limit]
|
|
273
|
+
output = "\n".join(lines)
|
|
274
|
+
if params.output_mode in ["content", "files_with_matches", "count_matches"]:
|
|
275
|
+
output += f"\n... (results truncated to {params.head_limit} lines)"
|
|
276
|
+
|
|
277
|
+
if not output:
|
|
278
|
+
return ToolOk(output="", message="No matches found")
|
|
279
|
+
return ToolOk(output=output)
|
|
280
|
+
|
|
281
|
+
except Exception as e:
|
|
282
|
+
return ToolError(
|
|
283
|
+
message=f"Failed to grep. Error: {str(e)}",
|
|
284
|
+
brief="Failed to grep",
|
|
285
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Apply a unified diff patch to a file.
|
|
2
|
+
|
|
3
|
+
**Tips:**
|
|
4
|
+
- The patch must be in unified diff format, the format used by `diff -u` and `git diff`.
|
|
5
|
+
- Only use this tool on text files.
|
|
6
|
+
- The tool will fail with error returned if the patch doesn't apply cleanly.
|
|
7
|
+
- The file must exist before applying the patch.
|
|
8
|
+
- You should prefer this tool over WriteFile tool and Bash `sed` command when editing an existing file.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import override
|
|
3
|
+
|
|
4
|
+
import aiofiles
|
|
5
|
+
import patch_ng
|
|
6
|
+
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from kimi_cli.agent import BuiltinSystemPromptArgs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Params(BaseModel):
|
|
13
|
+
path: str = Field(description="The absolute path to the file to apply the patch to.")
|
|
14
|
+
diff: str = Field(description="The diff content in unified format to apply.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PatchFile(CallableTool2[Params]):
|
|
18
|
+
name: str = "PatchFile"
|
|
19
|
+
description: str = (Path(__file__).parent / "patch.md").read_text()
|
|
20
|
+
params: type[Params] = Params
|
|
21
|
+
|
|
22
|
+
def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
|
|
23
|
+
super().__init__(**kwargs)
|
|
24
|
+
self._work_dir = builtin_args.KIMI_WORK_DIR
|
|
25
|
+
|
|
26
|
+
def _validate_path(self, path: Path) -> ToolError | None:
|
|
27
|
+
"""Validate that the path is safe to patch."""
|
|
28
|
+
# Check for path traversal attempts
|
|
29
|
+
resolved_path = path.resolve()
|
|
30
|
+
resolved_work_dir = Path(self._work_dir).resolve()
|
|
31
|
+
|
|
32
|
+
# Ensure the path is within work directory
|
|
33
|
+
if not str(resolved_path).startswith(str(resolved_work_dir)):
|
|
34
|
+
return ToolError(
|
|
35
|
+
message=(
|
|
36
|
+
f"`{path}` is outside the working directory. "
|
|
37
|
+
"You can only patch files within the working directory."
|
|
38
|
+
),
|
|
39
|
+
brief="Path outside working directory",
|
|
40
|
+
)
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
async def __call__(self, params: Params) -> ToolReturnType:
|
|
45
|
+
try:
|
|
46
|
+
p = Path(params.path)
|
|
47
|
+
|
|
48
|
+
if not p.is_absolute():
|
|
49
|
+
return ToolError(
|
|
50
|
+
message=(
|
|
51
|
+
f"`{params.path}` is not an absolute path. "
|
|
52
|
+
"You must provide an absolute path to patch a file."
|
|
53
|
+
),
|
|
54
|
+
brief="Invalid path",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Validate path safety
|
|
58
|
+
path_error = self._validate_path(p)
|
|
59
|
+
if path_error:
|
|
60
|
+
return path_error
|
|
61
|
+
|
|
62
|
+
if not p.exists():
|
|
63
|
+
return ToolError(
|
|
64
|
+
message=f"`{params.path}` does not exist.",
|
|
65
|
+
brief="File not found",
|
|
66
|
+
)
|
|
67
|
+
if not p.is_file():
|
|
68
|
+
return ToolError(
|
|
69
|
+
message=f"`{params.path}` is not a file.",
|
|
70
|
+
brief="Invalid path",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Read the file content
|
|
74
|
+
async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
|
|
75
|
+
original_content = await f.read()
|
|
76
|
+
|
|
77
|
+
# Create patch object directly from string (no temporary file needed!)
|
|
78
|
+
patch_set = patch_ng.fromstring(params.diff.encode("utf-8"))
|
|
79
|
+
|
|
80
|
+
# Handle case where patch_ng.fromstring returns False on parse errors
|
|
81
|
+
if not patch_set or patch_set is True:
|
|
82
|
+
return ToolError(
|
|
83
|
+
message=(
|
|
84
|
+
"Failed to parse diff content: invalid patch format or no valid hunks found"
|
|
85
|
+
),
|
|
86
|
+
brief="Invalid diff format",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Count total hunks across all items
|
|
90
|
+
total_hunks = sum(len(item.hunks) for item in patch_set.items)
|
|
91
|
+
|
|
92
|
+
if total_hunks == 0:
|
|
93
|
+
return ToolError(
|
|
94
|
+
message="No valid hunks found in the diff content",
|
|
95
|
+
brief="No hunks found",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Apply the patch
|
|
99
|
+
success = patch_set.apply(root=str(p.parent))
|
|
100
|
+
|
|
101
|
+
if not success:
|
|
102
|
+
return ToolError(
|
|
103
|
+
message=(
|
|
104
|
+
"Failed to apply patch - patch may not be compatible with the file content"
|
|
105
|
+
),
|
|
106
|
+
brief="Patch application failed",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Read the modified content to check if changes were made
|
|
110
|
+
async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
|
|
111
|
+
modified_content = await f.read()
|
|
112
|
+
|
|
113
|
+
# Check if any changes were made
|
|
114
|
+
if modified_content == original_content:
|
|
115
|
+
return ToolError(
|
|
116
|
+
message="No changes were made. The patch does not apply to the file.",
|
|
117
|
+
brief="No changes made",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return ToolOk(
|
|
121
|
+
output="",
|
|
122
|
+
message=(
|
|
123
|
+
f"File successfully patched. Applied {total_hunks} hunk(s) to {params.path}."
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
except Exception as e:
|
|
128
|
+
return ToolError(
|
|
129
|
+
message=f"Failed to patch file. Error: {e}",
|
|
130
|
+
brief="Failed to patch file",
|
|
131
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Read content from a file.
|
|
2
|
+
|
|
3
|
+
**Tips:**
|
|
4
|
+
- Make sure you follow the description of each tool parameter.
|
|
5
|
+
- A `<system>` tag will be given before the read file content.
|
|
6
|
+
- Content will be returned with a line number before each line like `cat -n` format.
|
|
7
|
+
- Use `line_offset` and `n_lines` parameters when you only need to read a part of the file.
|
|
8
|
+
- The maximum number of lines that can be read at once is ${MAX_LINES}.
|
|
9
|
+
- Any lines longer than ${MAX_LINE_LENGTH} characters will be truncated, ending with "...".
|
|
10
|
+
- The system will notify you when there is any limitation hit when reading the file.
|
|
11
|
+
- This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible.
|
|
12
|
+
- This tool can only read text files. To list directories, you must use the Glob tool or `ls` command via the Bash tool. To read other file types, use appropriate commands via the Bash tool.
|
|
13
|
+
- If the file doesn't exist or path is invalid, an error will be returned.
|
|
14
|
+
- If you want to search for a certain content/pattern, prefer Grep tool over ReadFile.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import override
|
|
3
|
+
|
|
4
|
+
import aiofiles
|
|
5
|
+
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from kimi_cli.agent import BuiltinSystemPromptArgs
|
|
9
|
+
from kimi_cli.tools.utils import load_desc, truncate_line
|
|
10
|
+
|
|
11
|
+
MAX_LINES = 1000
|
|
12
|
+
MAX_LINE_LENGTH = 2000
|
|
13
|
+
MAX_BYTES = 100 << 10 # 100KB
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Params(BaseModel):
|
|
17
|
+
path: str = Field(description="The absolute path to the file to read")
|
|
18
|
+
line_offset: int = Field(
|
|
19
|
+
description=(
|
|
20
|
+
"The line number to start reading from. "
|
|
21
|
+
"By default read from the beginning of the file. "
|
|
22
|
+
"Set this when the file is too large to read at once."
|
|
23
|
+
),
|
|
24
|
+
default=1,
|
|
25
|
+
ge=1,
|
|
26
|
+
)
|
|
27
|
+
n_lines: int = Field(
|
|
28
|
+
description=(
|
|
29
|
+
"The number of lines to read. "
|
|
30
|
+
f"By default read up to {MAX_LINES} lines, which is the max allowed value. "
|
|
31
|
+
"Set this value when the file is too large to read at once."
|
|
32
|
+
),
|
|
33
|
+
default=MAX_LINES,
|
|
34
|
+
ge=1,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ReadFile(CallableTool2[Params]):
|
|
39
|
+
name: str = "ReadFile"
|
|
40
|
+
description: str = load_desc(
|
|
41
|
+
Path(__file__).parent / "read.md",
|
|
42
|
+
{
|
|
43
|
+
"MAX_LINES": str(MAX_LINES),
|
|
44
|
+
"MAX_LINE_LENGTH": str(MAX_LINE_LENGTH),
|
|
45
|
+
"MAX_BYTES": str(MAX_BYTES),
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
params: type[Params] = Params
|
|
49
|
+
|
|
50
|
+
def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
|
|
51
|
+
super().__init__(**kwargs)
|
|
52
|
+
self._work_dir = builtin_args.KIMI_WORK_DIR
|
|
53
|
+
|
|
54
|
+
@override
|
|
55
|
+
async def __call__(self, params: Params) -> ToolReturnType:
|
|
56
|
+
# TODO: checks:
|
|
57
|
+
# - check if the path may contain secrets
|
|
58
|
+
# - check if the file format is readable
|
|
59
|
+
try:
|
|
60
|
+
p = Path(params.path)
|
|
61
|
+
|
|
62
|
+
if not p.is_absolute():
|
|
63
|
+
return ToolError(
|
|
64
|
+
message=(
|
|
65
|
+
f"`{params.path}` is not an absolute path. "
|
|
66
|
+
"You must provide an absolute path to read a file."
|
|
67
|
+
),
|
|
68
|
+
brief="Invalid path",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if not p.exists():
|
|
72
|
+
return ToolError(
|
|
73
|
+
message=f"`{params.path}` does not exist.",
|
|
74
|
+
brief="File not found",
|
|
75
|
+
)
|
|
76
|
+
if not p.is_file():
|
|
77
|
+
return ToolError(
|
|
78
|
+
message=f"`{params.path}` is not a file.",
|
|
79
|
+
brief="Invalid path",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
assert params.line_offset >= 1
|
|
83
|
+
assert params.n_lines >= 1
|
|
84
|
+
|
|
85
|
+
lines: list[str] = []
|
|
86
|
+
n_bytes = 0
|
|
87
|
+
truncated_line_numbers = []
|
|
88
|
+
max_lines_reached = False
|
|
89
|
+
max_bytes_reached = False
|
|
90
|
+
async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
|
|
91
|
+
current_line_no = 0
|
|
92
|
+
async for line in f:
|
|
93
|
+
current_line_no += 1
|
|
94
|
+
if current_line_no < params.line_offset:
|
|
95
|
+
continue
|
|
96
|
+
truncated = truncate_line(line, MAX_LINE_LENGTH)
|
|
97
|
+
if truncated != line:
|
|
98
|
+
truncated_line_numbers.append(current_line_no)
|
|
99
|
+
lines.append(truncated)
|
|
100
|
+
n_bytes += len(truncated.encode("utf-8"))
|
|
101
|
+
if len(lines) >= params.n_lines:
|
|
102
|
+
break
|
|
103
|
+
if len(lines) >= MAX_LINES:
|
|
104
|
+
max_lines_reached = True
|
|
105
|
+
break
|
|
106
|
+
if n_bytes >= MAX_BYTES:
|
|
107
|
+
max_bytes_reached = True
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
# Format output with line numbers like `cat -n`
|
|
111
|
+
lines_with_no = []
|
|
112
|
+
for line_num, line in zip(
|
|
113
|
+
range(params.line_offset, params.line_offset + len(lines)), lines, strict=True
|
|
114
|
+
):
|
|
115
|
+
# Use 6-digit line number width, right-aligned, with tab separator
|
|
116
|
+
lines_with_no.append(f"{line_num:6d}\t{line}")
|
|
117
|
+
|
|
118
|
+
message = (
|
|
119
|
+
f"{len(lines)} lines read from file starting from line {params.line_offset}."
|
|
120
|
+
if len(lines) > 0
|
|
121
|
+
else "No lines read from file."
|
|
122
|
+
)
|
|
123
|
+
if max_lines_reached:
|
|
124
|
+
message += f" Max {MAX_LINES} lines reached."
|
|
125
|
+
elif max_bytes_reached:
|
|
126
|
+
message += f" Max {MAX_BYTES} bytes reached."
|
|
127
|
+
elif len(lines) < params.n_lines:
|
|
128
|
+
message += " End of file reached."
|
|
129
|
+
if truncated_line_numbers:
|
|
130
|
+
message += f" Lines {truncated_line_numbers} were truncated."
|
|
131
|
+
return ToolOk(
|
|
132
|
+
output="".join(lines_with_no), # lines already contain \n, just join them
|
|
133
|
+
message=message,
|
|
134
|
+
)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
return ToolError(
|
|
137
|
+
message=f"Failed to read {params.path}. Error: {e}",
|
|
138
|
+
brief="Failed to read file",
|
|
139
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Replace specific strings within a specified file.
|
|
2
|
+
|
|
3
|
+
**Tips:**
|
|
4
|
+
- Only use this tool on text files.
|
|
5
|
+
- Multi-line strings are supported.
|
|
6
|
+
- Can specify a single edit or a list of edits in one call.
|
|
7
|
+
- You should prefer this tool over WriteFile tool and Bash `sed` command.
|