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.

Files changed (76) hide show
  1. kimi_cli/CHANGELOG.md +304 -0
  2. kimi_cli/__init__.py +374 -0
  3. kimi_cli/agent.py +261 -0
  4. kimi_cli/agents/koder/README.md +3 -0
  5. kimi_cli/agents/koder/agent.yaml +24 -0
  6. kimi_cli/agents/koder/sub.yaml +11 -0
  7. kimi_cli/agents/koder/system.md +72 -0
  8. kimi_cli/config.py +138 -0
  9. kimi_cli/llm.py +8 -0
  10. kimi_cli/metadata.py +117 -0
  11. kimi_cli/prompts/metacmds/__init__.py +4 -0
  12. kimi_cli/prompts/metacmds/compact.md +74 -0
  13. kimi_cli/prompts/metacmds/init.md +21 -0
  14. kimi_cli/py.typed +0 -0
  15. kimi_cli/share.py +8 -0
  16. kimi_cli/soul/__init__.py +59 -0
  17. kimi_cli/soul/approval.py +69 -0
  18. kimi_cli/soul/context.py +142 -0
  19. kimi_cli/soul/denwarenji.py +37 -0
  20. kimi_cli/soul/kimisoul.py +248 -0
  21. kimi_cli/soul/message.py +76 -0
  22. kimi_cli/soul/toolset.py +25 -0
  23. kimi_cli/soul/wire.py +101 -0
  24. kimi_cli/tools/__init__.py +85 -0
  25. kimi_cli/tools/bash/__init__.py +97 -0
  26. kimi_cli/tools/bash/bash.md +31 -0
  27. kimi_cli/tools/dmail/__init__.py +38 -0
  28. kimi_cli/tools/dmail/dmail.md +15 -0
  29. kimi_cli/tools/file/__init__.py +21 -0
  30. kimi_cli/tools/file/glob.md +17 -0
  31. kimi_cli/tools/file/glob.py +149 -0
  32. kimi_cli/tools/file/grep.md +5 -0
  33. kimi_cli/tools/file/grep.py +285 -0
  34. kimi_cli/tools/file/patch.md +8 -0
  35. kimi_cli/tools/file/patch.py +131 -0
  36. kimi_cli/tools/file/read.md +14 -0
  37. kimi_cli/tools/file/read.py +139 -0
  38. kimi_cli/tools/file/replace.md +7 -0
  39. kimi_cli/tools/file/replace.py +132 -0
  40. kimi_cli/tools/file/write.md +5 -0
  41. kimi_cli/tools/file/write.py +107 -0
  42. kimi_cli/tools/mcp.py +85 -0
  43. kimi_cli/tools/task/__init__.py +156 -0
  44. kimi_cli/tools/task/task.md +26 -0
  45. kimi_cli/tools/test.py +55 -0
  46. kimi_cli/tools/think/__init__.py +21 -0
  47. kimi_cli/tools/think/think.md +1 -0
  48. kimi_cli/tools/todo/__init__.py +27 -0
  49. kimi_cli/tools/todo/set_todo_list.md +15 -0
  50. kimi_cli/tools/utils.py +150 -0
  51. kimi_cli/tools/web/__init__.py +4 -0
  52. kimi_cli/tools/web/fetch.md +1 -0
  53. kimi_cli/tools/web/fetch.py +94 -0
  54. kimi_cli/tools/web/search.md +1 -0
  55. kimi_cli/tools/web/search.py +126 -0
  56. kimi_cli/ui/__init__.py +68 -0
  57. kimi_cli/ui/acp/__init__.py +441 -0
  58. kimi_cli/ui/print/__init__.py +176 -0
  59. kimi_cli/ui/shell/__init__.py +326 -0
  60. kimi_cli/ui/shell/console.py +3 -0
  61. kimi_cli/ui/shell/liveview.py +158 -0
  62. kimi_cli/ui/shell/metacmd.py +309 -0
  63. kimi_cli/ui/shell/prompt.py +574 -0
  64. kimi_cli/ui/shell/setup.py +192 -0
  65. kimi_cli/ui/shell/update.py +204 -0
  66. kimi_cli/utils/changelog.py +101 -0
  67. kimi_cli/utils/logging.py +18 -0
  68. kimi_cli/utils/message.py +8 -0
  69. kimi_cli/utils/path.py +23 -0
  70. kimi_cli/utils/provider.py +64 -0
  71. kimi_cli/utils/pyinstaller.py +24 -0
  72. kimi_cli/utils/string.py +12 -0
  73. kimi_cli-0.35.dist-info/METADATA +24 -0
  74. kimi_cli-0.35.dist-info/RECORD +76 -0
  75. kimi_cli-0.35.dist-info/WHEEL +4 -0
  76. 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.