kimi-cli 0.44__py3-none-any.whl → 0.78__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 +349 -40
- kimi_cli/__init__.py +6 -0
- kimi_cli/acp/AGENTS.md +91 -0
- kimi_cli/acp/__init__.py +13 -0
- kimi_cli/acp/convert.py +111 -0
- kimi_cli/acp/kaos.py +270 -0
- kimi_cli/acp/mcp.py +46 -0
- kimi_cli/acp/server.py +335 -0
- kimi_cli/acp/session.py +445 -0
- kimi_cli/acp/tools.py +158 -0
- kimi_cli/acp/types.py +13 -0
- kimi_cli/agents/default/agent.yaml +4 -4
- kimi_cli/agents/default/sub.yaml +2 -1
- kimi_cli/agents/default/system.md +79 -21
- kimi_cli/agents/okabe/agent.yaml +17 -0
- kimi_cli/agentspec.py +53 -25
- kimi_cli/app.py +180 -52
- kimi_cli/cli/__init__.py +595 -0
- kimi_cli/cli/__main__.py +8 -0
- kimi_cli/cli/info.py +63 -0
- kimi_cli/cli/mcp.py +349 -0
- kimi_cli/config.py +153 -17
- kimi_cli/constant.py +3 -0
- kimi_cli/exception.py +23 -2
- kimi_cli/flow/__init__.py +117 -0
- kimi_cli/flow/d2.py +376 -0
- kimi_cli/flow/mermaid.py +218 -0
- kimi_cli/llm.py +129 -23
- kimi_cli/metadata.py +32 -7
- kimi_cli/platforms.py +262 -0
- kimi_cli/prompts/__init__.py +2 -0
- kimi_cli/prompts/compact.md +4 -5
- kimi_cli/session.py +223 -31
- kimi_cli/share.py +2 -0
- kimi_cli/skill.py +145 -0
- kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
- kimi_cli/skills/skill-creator/SKILL.md +351 -0
- kimi_cli/soul/__init__.py +51 -20
- kimi_cli/soul/agent.py +213 -85
- kimi_cli/soul/approval.py +86 -17
- kimi_cli/soul/compaction.py +64 -53
- kimi_cli/soul/context.py +38 -5
- kimi_cli/soul/denwarenji.py +2 -0
- kimi_cli/soul/kimisoul.py +442 -60
- kimi_cli/soul/message.py +54 -54
- kimi_cli/soul/slash.py +72 -0
- kimi_cli/soul/toolset.py +387 -6
- kimi_cli/toad.py +74 -0
- kimi_cli/tools/AGENTS.md +5 -0
- kimi_cli/tools/__init__.py +42 -34
- kimi_cli/tools/display.py +25 -0
- kimi_cli/tools/dmail/__init__.py +10 -10
- kimi_cli/tools/dmail/dmail.md +11 -9
- kimi_cli/tools/file/__init__.py +1 -3
- kimi_cli/tools/file/glob.py +20 -23
- kimi_cli/tools/file/grep.md +1 -1
- kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
- kimi_cli/tools/file/read.md +24 -6
- kimi_cli/tools/file/read.py +134 -50
- kimi_cli/tools/file/replace.md +1 -1
- kimi_cli/tools/file/replace.py +36 -29
- kimi_cli/tools/file/utils.py +282 -0
- kimi_cli/tools/file/write.py +43 -22
- kimi_cli/tools/multiagent/__init__.py +7 -0
- kimi_cli/tools/multiagent/create.md +11 -0
- kimi_cli/tools/multiagent/create.py +50 -0
- kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
- kimi_cli/tools/shell/__init__.py +120 -0
- kimi_cli/tools/{bash → shell}/bash.md +1 -2
- kimi_cli/tools/shell/powershell.md +25 -0
- kimi_cli/tools/test.py +4 -4
- kimi_cli/tools/think/__init__.py +2 -2
- kimi_cli/tools/todo/__init__.py +14 -8
- kimi_cli/tools/utils.py +64 -24
- kimi_cli/tools/web/fetch.py +68 -13
- kimi_cli/tools/web/search.py +10 -12
- kimi_cli/ui/acp/__init__.py +65 -412
- kimi_cli/ui/print/__init__.py +37 -49
- kimi_cli/ui/print/visualize.py +179 -0
- kimi_cli/ui/shell/__init__.py +141 -84
- kimi_cli/ui/shell/console.py +2 -0
- kimi_cli/ui/shell/debug.py +28 -23
- kimi_cli/ui/shell/keyboard.py +5 -1
- kimi_cli/ui/shell/prompt.py +220 -194
- kimi_cli/ui/shell/replay.py +111 -46
- kimi_cli/ui/shell/setup.py +89 -82
- kimi_cli/ui/shell/slash.py +422 -0
- kimi_cli/ui/shell/update.py +4 -2
- kimi_cli/ui/shell/usage.py +271 -0
- kimi_cli/ui/shell/visualize.py +574 -72
- kimi_cli/ui/wire/__init__.py +267 -0
- kimi_cli/ui/wire/jsonrpc.py +142 -0
- kimi_cli/ui/wire/protocol.py +1 -0
- kimi_cli/utils/__init__.py +0 -0
- kimi_cli/utils/aiohttp.py +2 -0
- kimi_cli/utils/aioqueue.py +72 -0
- kimi_cli/utils/broadcast.py +37 -0
- kimi_cli/utils/changelog.py +12 -7
- kimi_cli/utils/clipboard.py +12 -0
- kimi_cli/utils/datetime.py +37 -0
- kimi_cli/utils/environment.py +58 -0
- kimi_cli/utils/envvar.py +12 -0
- kimi_cli/utils/frontmatter.py +44 -0
- kimi_cli/utils/logging.py +7 -6
- kimi_cli/utils/message.py +9 -14
- kimi_cli/utils/path.py +99 -9
- kimi_cli/utils/pyinstaller.py +6 -0
- kimi_cli/utils/rich/__init__.py +33 -0
- kimi_cli/utils/rich/columns.py +99 -0
- kimi_cli/utils/rich/markdown.py +961 -0
- kimi_cli/utils/rich/markdown_sample.md +108 -0
- kimi_cli/utils/rich/markdown_sample_short.md +2 -0
- kimi_cli/utils/signals.py +2 -0
- kimi_cli/utils/slashcmd.py +124 -0
- kimi_cli/utils/string.py +2 -0
- kimi_cli/utils/term.py +168 -0
- kimi_cli/utils/typing.py +20 -0
- kimi_cli/wire/__init__.py +98 -29
- kimi_cli/wire/serde.py +45 -0
- kimi_cli/wire/types.py +299 -0
- kimi_cli-0.78.dist-info/METADATA +200 -0
- kimi_cli-0.78.dist-info/RECORD +135 -0
- kimi_cli-0.78.dist-info/entry_points.txt +4 -0
- kimi_cli/cli.py +0 -250
- kimi_cli/soul/runtime.py +0 -96
- kimi_cli/tools/bash/__init__.py +0 -99
- kimi_cli/tools/file/patch.md +0 -8
- kimi_cli/tools/file/patch.py +0 -143
- kimi_cli/tools/mcp.py +0 -85
- kimi_cli/ui/shell/liveview.py +0 -386
- kimi_cli/ui/shell/metacmd.py +0 -262
- kimi_cli/wire/message.py +0 -91
- kimi_cli-0.44.dist-info/METADATA +0 -188
- kimi_cli-0.44.dist-info/RECORD +0 -89
- kimi_cli-0.44.dist-info/entry_points.txt +0 -3
- /kimi_cli/tools/{task → multiagent}/task.md +0 -0
- {kimi_cli-0.44.dist-info → kimi_cli-0.78.dist-info}/WHEEL +0 -0
kimi_cli/tools/file/read.py
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
from typing import override
|
|
3
4
|
|
|
4
|
-
import
|
|
5
|
-
from kosong.tooling import CallableTool2, ToolError, ToolOk,
|
|
5
|
+
from kaos.path import KaosPath
|
|
6
|
+
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue
|
|
6
7
|
from pydantic import BaseModel, Field
|
|
7
8
|
|
|
8
|
-
from kimi_cli.soul.
|
|
9
|
-
from kimi_cli.tools.utils import
|
|
9
|
+
from kimi_cli.soul.agent import Runtime
|
|
10
|
+
from kimi_cli.tools.file.utils import MEDIA_SNIFF_BYTES, FileType, detect_file_type
|
|
11
|
+
from kimi_cli.tools.utils import load_desc_jinja, truncate_line
|
|
12
|
+
from kimi_cli.utils.path import is_within_directory
|
|
13
|
+
from kimi_cli.wire.types import ImageURLPart, VideoURLPart
|
|
10
14
|
|
|
11
15
|
MAX_LINES = 1000
|
|
12
16
|
MAX_LINE_LENGTH = 2000
|
|
13
17
|
MAX_BYTES = 100 << 10 # 100KB
|
|
18
|
+
MAX_MEDIA_BYTES = 80 << 20 # 80MB
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _to_data_url(mime_type: str, data: bytes) -> str:
|
|
22
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
23
|
+
return f"data:{mime_type};base64,{encoded}"
|
|
14
24
|
|
|
15
25
|
|
|
16
26
|
class Params(BaseModel):
|
|
17
|
-
path: str = Field(
|
|
27
|
+
path: str = Field(
|
|
28
|
+
description=(
|
|
29
|
+
"The path to the file to read. Absolute paths are required when reading files "
|
|
30
|
+
"outside the working directory."
|
|
31
|
+
)
|
|
32
|
+
)
|
|
18
33
|
line_offset: int = Field(
|
|
19
34
|
description=(
|
|
20
35
|
"The line number to start reading from. "
|
|
@@ -37,78 +52,147 @@ class Params(BaseModel):
|
|
|
37
52
|
|
|
38
53
|
class ReadFile(CallableTool2[Params]):
|
|
39
54
|
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
55
|
params: type[Params] = Params
|
|
49
56
|
|
|
50
|
-
def __init__(self,
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
def __init__(self, runtime: Runtime) -> None:
|
|
58
|
+
capabilities = runtime.llm.capabilities if runtime.llm else set[str]()
|
|
59
|
+
description = load_desc_jinja(
|
|
60
|
+
Path(__file__).parent / "read.md",
|
|
61
|
+
{
|
|
62
|
+
"MAX_LINES": MAX_LINES,
|
|
63
|
+
"MAX_LINE_LENGTH": MAX_LINE_LENGTH,
|
|
64
|
+
"MAX_BYTES": MAX_BYTES,
|
|
65
|
+
"MAX_MEDIA_BYTES": MAX_MEDIA_BYTES,
|
|
66
|
+
"capabilities": capabilities,
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
super().__init__(description=description)
|
|
70
|
+
self._work_dir = runtime.builtin_args.KIMI_WORK_DIR
|
|
71
|
+
|
|
72
|
+
async def _validate_path(self, path: KaosPath) -> ToolError | None:
|
|
73
|
+
"""Validate that the path is safe to read."""
|
|
74
|
+
# Check for path traversal attempts
|
|
75
|
+
resolved_path = path.canonical()
|
|
76
|
+
|
|
77
|
+
if not is_within_directory(resolved_path, self._work_dir) and not path.is_absolute():
|
|
78
|
+
# Outside files can only be read with absolute paths
|
|
79
|
+
return ToolError(
|
|
80
|
+
message=(
|
|
81
|
+
f"`{path}` is not an absolute path. "
|
|
82
|
+
"You must provide an absolute path to read a file "
|
|
83
|
+
"outside the working directory."
|
|
84
|
+
),
|
|
85
|
+
brief="Invalid path",
|
|
86
|
+
)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
async def _read_media(self, path: KaosPath, file_type: FileType) -> ToolReturnValue:
|
|
90
|
+
assert file_type.kind in ("image", "video")
|
|
91
|
+
|
|
92
|
+
stat = await path.stat()
|
|
93
|
+
size = stat.st_size
|
|
94
|
+
if size == 0:
|
|
95
|
+
return ToolError(
|
|
96
|
+
message=f"`{path}` is empty.",
|
|
97
|
+
brief="Empty file",
|
|
98
|
+
)
|
|
99
|
+
if size > MAX_MEDIA_BYTES:
|
|
100
|
+
return ToolError(
|
|
101
|
+
message=(
|
|
102
|
+
f"`{path}` is {size} bytes, which exceeds the max "
|
|
103
|
+
f"{MAX_MEDIA_BYTES} bytes for media files."
|
|
104
|
+
),
|
|
105
|
+
brief="File too large",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
data = await path.read_bytes()
|
|
109
|
+
data_url = _to_data_url(file_type.mime_type, data)
|
|
110
|
+
match file_type.kind:
|
|
111
|
+
case "image":
|
|
112
|
+
part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=data_url))
|
|
113
|
+
case "video":
|
|
114
|
+
part = VideoURLPart(video_url=VideoURLPart.VideoURL(url=data_url))
|
|
115
|
+
return ToolOk(
|
|
116
|
+
output=part,
|
|
117
|
+
message=(
|
|
118
|
+
f"Loaded {file_type.kind} file `{path}` ({file_type.mime_type}, {size} bytes)."
|
|
119
|
+
),
|
|
120
|
+
)
|
|
53
121
|
|
|
54
122
|
@override
|
|
55
|
-
async def __call__(self, params: Params) ->
|
|
123
|
+
async def __call__(self, params: Params) -> ToolReturnValue:
|
|
56
124
|
# TODO: checks:
|
|
57
125
|
# - check if the path may contain secrets
|
|
58
|
-
|
|
126
|
+
|
|
127
|
+
if not params.path:
|
|
128
|
+
return ToolError(
|
|
129
|
+
message="File path cannot be empty.",
|
|
130
|
+
brief="Empty file path",
|
|
131
|
+
)
|
|
132
|
+
|
|
59
133
|
try:
|
|
60
|
-
p =
|
|
134
|
+
p = KaosPath(params.path).expanduser()
|
|
61
135
|
|
|
62
|
-
if
|
|
63
|
-
return
|
|
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
|
-
)
|
|
136
|
+
if err := await self._validate_path(p):
|
|
137
|
+
return err
|
|
70
138
|
|
|
71
|
-
if not p.exists():
|
|
139
|
+
if not await p.exists():
|
|
72
140
|
return ToolError(
|
|
73
141
|
message=f"`{params.path}` does not exist.",
|
|
74
142
|
brief="File not found",
|
|
75
143
|
)
|
|
76
|
-
if not p.is_file():
|
|
144
|
+
if not await p.is_file():
|
|
77
145
|
return ToolError(
|
|
78
146
|
message=f"`{params.path}` is not a file.",
|
|
79
147
|
brief="Invalid path",
|
|
80
148
|
)
|
|
81
149
|
|
|
150
|
+
header = await p.read_bytes(MEDIA_SNIFF_BYTES)
|
|
151
|
+
file_type = detect_file_type(str(p), header=header)
|
|
152
|
+
if file_type.kind in ("image", "video"):
|
|
153
|
+
return await self._read_media(p, file_type)
|
|
154
|
+
|
|
155
|
+
if file_type.kind == "unknown":
|
|
156
|
+
return ToolError(
|
|
157
|
+
message=(
|
|
158
|
+
f"`{params.path}` seems not readable. "
|
|
159
|
+
"You may need to read it with proper shell commands, Python tools "
|
|
160
|
+
"or MCP tools if available. "
|
|
161
|
+
"If you read/operate it with Python, you MUST ensure that any "
|
|
162
|
+
"third-party packages are installed in a virtual environment (venv)."
|
|
163
|
+
),
|
|
164
|
+
brief="File not readable",
|
|
165
|
+
)
|
|
166
|
+
|
|
82
167
|
assert params.line_offset >= 1
|
|
83
168
|
assert params.n_lines >= 1
|
|
84
169
|
|
|
85
170
|
lines: list[str] = []
|
|
86
171
|
n_bytes = 0
|
|
87
|
-
truncated_line_numbers = []
|
|
172
|
+
truncated_line_numbers: list[int] = []
|
|
88
173
|
max_lines_reached = False
|
|
89
174
|
max_bytes_reached = False
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
break
|
|
175
|
+
current_line_no = 0
|
|
176
|
+
async for line in p.read_lines(errors="replace"):
|
|
177
|
+
current_line_no += 1
|
|
178
|
+
if current_line_no < params.line_offset:
|
|
179
|
+
continue
|
|
180
|
+
truncated = truncate_line(line, MAX_LINE_LENGTH)
|
|
181
|
+
if truncated != line:
|
|
182
|
+
truncated_line_numbers.append(current_line_no)
|
|
183
|
+
lines.append(truncated)
|
|
184
|
+
n_bytes += len(truncated.encode("utf-8"))
|
|
185
|
+
if len(lines) >= params.n_lines:
|
|
186
|
+
break
|
|
187
|
+
if len(lines) >= MAX_LINES:
|
|
188
|
+
max_lines_reached = True
|
|
189
|
+
break
|
|
190
|
+
if n_bytes >= MAX_BYTES:
|
|
191
|
+
max_bytes_reached = True
|
|
192
|
+
break
|
|
109
193
|
|
|
110
194
|
# Format output with line numbers like `cat -n`
|
|
111
|
-
lines_with_no = []
|
|
195
|
+
lines_with_no: list[str] = []
|
|
112
196
|
for line_num, line in zip(
|
|
113
197
|
range(params.line_offset, params.line_offset + len(lines)), lines, strict=True
|
|
114
198
|
):
|
kimi_cli/tools/file/replace.md
CHANGED
|
@@ -4,4 +4,4 @@ Replace specific strings within a specified file.
|
|
|
4
4
|
- Only use this tool on text files.
|
|
5
5
|
- Multi-line strings are supported.
|
|
6
6
|
- Can specify a single edit or a list of edits in one call.
|
|
7
|
-
- You should prefer this tool over WriteFile tool and
|
|
7
|
+
- You should prefer this tool over WriteFile tool and Shell `sed` command.
|
kimi_cli/tools/file/replace.py
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
from typing import override
|
|
3
3
|
|
|
4
|
-
import
|
|
5
|
-
from kosong.tooling import CallableTool2, ToolError,
|
|
4
|
+
from kaos.path import KaosPath
|
|
5
|
+
from kosong.tooling import CallableTool2, ToolError, ToolReturnValue
|
|
6
6
|
from pydantic import BaseModel, Field
|
|
7
7
|
|
|
8
|
+
from kimi_cli.soul.agent import BuiltinSystemPromptArgs
|
|
8
9
|
from kimi_cli.soul.approval import Approval
|
|
9
|
-
from kimi_cli.
|
|
10
|
+
from kimi_cli.tools.display import DisplayBlock
|
|
10
11
|
from kimi_cli.tools.file import FileActions
|
|
11
|
-
from kimi_cli.tools.utils import
|
|
12
|
+
from kimi_cli.tools.file.utils import build_diff_blocks
|
|
13
|
+
from kimi_cli.tools.utils import ToolRejectedError, load_desc
|
|
14
|
+
from kimi_cli.utils.path import is_within_directory
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
class Edit(BaseModel):
|
|
@@ -29,22 +32,21 @@ class Params(BaseModel):
|
|
|
29
32
|
|
|
30
33
|
class StrReplaceFile(CallableTool2[Params]):
|
|
31
34
|
name: str = "StrReplaceFile"
|
|
32
|
-
description: str = (Path(__file__).parent / "replace.md")
|
|
35
|
+
description: str = load_desc(Path(__file__).parent / "replace.md")
|
|
33
36
|
params: type[Params] = Params
|
|
34
37
|
|
|
35
|
-
def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval
|
|
36
|
-
super().__init__(
|
|
38
|
+
def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval):
|
|
39
|
+
super().__init__()
|
|
37
40
|
self._work_dir = builtin_args.KIMI_WORK_DIR
|
|
38
41
|
self._approval = approval
|
|
39
42
|
|
|
40
|
-
def _validate_path(self, path:
|
|
43
|
+
async def _validate_path(self, path: KaosPath) -> ToolError | None:
|
|
41
44
|
"""Validate that the path is safe to edit."""
|
|
42
45
|
# Check for path traversal attempts
|
|
43
|
-
resolved_path = path.
|
|
44
|
-
resolved_work_dir = self._work_dir.resolve()
|
|
46
|
+
resolved_path = path.canonical()
|
|
45
47
|
|
|
46
48
|
# Ensure the path is within work directory
|
|
47
|
-
if not
|
|
49
|
+
if not is_within_directory(resolved_path, self._work_dir):
|
|
48
50
|
return ToolError(
|
|
49
51
|
message=(
|
|
50
52
|
f"`{path}` is outside the working directory. "
|
|
@@ -62,9 +64,9 @@ class StrReplaceFile(CallableTool2[Params]):
|
|
|
62
64
|
return content.replace(edit.old, edit.new, 1)
|
|
63
65
|
|
|
64
66
|
@override
|
|
65
|
-
async def __call__(self, params: Params) ->
|
|
67
|
+
async def __call__(self, params: Params) -> ToolReturnValue:
|
|
66
68
|
try:
|
|
67
|
-
p =
|
|
69
|
+
p = KaosPath(params.path)
|
|
68
70
|
|
|
69
71
|
if not p.is_absolute():
|
|
70
72
|
return ToolError(
|
|
@@ -76,32 +78,23 @@ class StrReplaceFile(CallableTool2[Params]):
|
|
|
76
78
|
)
|
|
77
79
|
|
|
78
80
|
# Validate path safety
|
|
79
|
-
path_error = self._validate_path(p)
|
|
81
|
+
path_error = await self._validate_path(p)
|
|
80
82
|
if path_error:
|
|
81
83
|
return path_error
|
|
82
84
|
|
|
83
|
-
if not p.exists():
|
|
85
|
+
if not await p.exists():
|
|
84
86
|
return ToolError(
|
|
85
87
|
message=f"`{params.path}` does not exist.",
|
|
86
88
|
brief="File not found",
|
|
87
89
|
)
|
|
88
|
-
if not p.is_file():
|
|
90
|
+
if not await p.is_file():
|
|
89
91
|
return ToolError(
|
|
90
92
|
message=f"`{params.path}` is not a file.",
|
|
91
93
|
brief="Invalid path",
|
|
92
94
|
)
|
|
93
95
|
|
|
94
|
-
# Request approval
|
|
95
|
-
if not await self._approval.request(
|
|
96
|
-
self.name,
|
|
97
|
-
FileActions.EDIT,
|
|
98
|
-
f"Edit file `{params.path}`",
|
|
99
|
-
):
|
|
100
|
-
return ToolRejectedError()
|
|
101
|
-
|
|
102
96
|
# Read the file content
|
|
103
|
-
|
|
104
|
-
content = await f.read()
|
|
97
|
+
content = await p.read_text(errors="replace")
|
|
105
98
|
|
|
106
99
|
original_content = content
|
|
107
100
|
edits = [params.edit] if isinstance(params.edit, Edit) else params.edit
|
|
@@ -117,9 +110,21 @@ class StrReplaceFile(CallableTool2[Params]):
|
|
|
117
110
|
brief="No replacements made",
|
|
118
111
|
)
|
|
119
112
|
|
|
113
|
+
diff_blocks: list[DisplayBlock] = list(
|
|
114
|
+
build_diff_blocks(params.path, original_content, content)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Request approval
|
|
118
|
+
if not await self._approval.request(
|
|
119
|
+
self.name,
|
|
120
|
+
FileActions.EDIT,
|
|
121
|
+
f"Edit file `{params.path}`",
|
|
122
|
+
display=diff_blocks,
|
|
123
|
+
):
|
|
124
|
+
return ToolRejectedError()
|
|
125
|
+
|
|
120
126
|
# Write the modified content back to the file
|
|
121
|
-
|
|
122
|
-
await f.write(content)
|
|
127
|
+
await p.write_text(content, errors="replace")
|
|
123
128
|
|
|
124
129
|
# Count changes for success message
|
|
125
130
|
total_replacements = 0
|
|
@@ -129,12 +134,14 @@ class StrReplaceFile(CallableTool2[Params]):
|
|
|
129
134
|
else:
|
|
130
135
|
total_replacements += 1 if edit.old in original_content else 0
|
|
131
136
|
|
|
132
|
-
return
|
|
137
|
+
return ToolReturnValue(
|
|
138
|
+
is_error=False,
|
|
133
139
|
output="",
|
|
134
140
|
message=(
|
|
135
141
|
f"File successfully edited. "
|
|
136
142
|
f"Applied {len(edits)} edit(s) with {total_replacements} total replacement(s)."
|
|
137
143
|
),
|
|
144
|
+
display=diff_blocks,
|
|
138
145
|
)
|
|
139
146
|
|
|
140
147
|
except Exception as e:
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from difflib import SequenceMatcher
|
|
6
|
+
from pathlib import PurePath
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from kimi_cli.tools.display import DiffDisplayBlock
|
|
10
|
+
|
|
11
|
+
MEDIA_SNIFF_BYTES = 512
|
|
12
|
+
|
|
13
|
+
_EXTRA_MIME_TYPES = {
|
|
14
|
+
".avif": "image/avif",
|
|
15
|
+
".heic": "image/heic",
|
|
16
|
+
".heif": "image/heif",
|
|
17
|
+
".mkv": "video/x-matroska",
|
|
18
|
+
".m4v": "video/x-m4v",
|
|
19
|
+
".3gp": "video/3gpp",
|
|
20
|
+
".3g2": "video/3gpp2",
|
|
21
|
+
# TypeScript files: override mimetypes default (video/mp2t for MPEG Transport Stream)
|
|
22
|
+
".ts": "text/typescript",
|
|
23
|
+
".tsx": "text/typescript",
|
|
24
|
+
".mts": "text/typescript",
|
|
25
|
+
".cts": "text/typescript",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for suffix, mime_type in _EXTRA_MIME_TYPES.items():
|
|
29
|
+
mimetypes.add_type(mime_type, suffix)
|
|
30
|
+
|
|
31
|
+
_IMAGE_MIME_BY_SUFFIX = {
|
|
32
|
+
".png": "image/png",
|
|
33
|
+
".jpg": "image/jpeg",
|
|
34
|
+
".jpeg": "image/jpeg",
|
|
35
|
+
".gif": "image/gif",
|
|
36
|
+
".bmp": "image/bmp",
|
|
37
|
+
".tif": "image/tiff",
|
|
38
|
+
".tiff": "image/tiff",
|
|
39
|
+
".webp": "image/webp",
|
|
40
|
+
".ico": "image/x-icon",
|
|
41
|
+
".heic": "image/heic",
|
|
42
|
+
".heif": "image/heif",
|
|
43
|
+
".avif": "image/avif",
|
|
44
|
+
".svg": "image/svg+xml",
|
|
45
|
+
".svgz": "image/svg+xml",
|
|
46
|
+
}
|
|
47
|
+
_VIDEO_MIME_BY_SUFFIX = {
|
|
48
|
+
".mp4": "video/mp4",
|
|
49
|
+
".mkv": "video/x-matroska",
|
|
50
|
+
".avi": "video/x-msvideo",
|
|
51
|
+
".mov": "video/quicktime",
|
|
52
|
+
".wmv": "video/x-ms-wmv",
|
|
53
|
+
".webm": "video/webm",
|
|
54
|
+
".m4v": "video/x-m4v",
|
|
55
|
+
".flv": "video/x-flv",
|
|
56
|
+
".3gp": "video/3gpp",
|
|
57
|
+
".3g2": "video/3gpp2",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_ASF_HEADER = b"\x30\x26\xb2\x75\x8e\x66\xcf\x11\xa6\xd9\x00\xaa\x00\x62\xce\x6c"
|
|
61
|
+
_FTYP_IMAGE_BRANDS = {
|
|
62
|
+
"avif": "image/avif",
|
|
63
|
+
"avis": "image/avif",
|
|
64
|
+
"heic": "image/heic",
|
|
65
|
+
"heif": "image/heif",
|
|
66
|
+
"heix": "image/heif",
|
|
67
|
+
"hevc": "image/heic",
|
|
68
|
+
"mif1": "image/heif",
|
|
69
|
+
"msf1": "image/heif",
|
|
70
|
+
}
|
|
71
|
+
_FTYP_VIDEO_BRANDS = {
|
|
72
|
+
"isom": "video/mp4",
|
|
73
|
+
"iso2": "video/mp4",
|
|
74
|
+
"mp41": "video/mp4",
|
|
75
|
+
"mp42": "video/mp4",
|
|
76
|
+
"avc1": "video/mp4",
|
|
77
|
+
"mp4v": "video/mp4",
|
|
78
|
+
"m4v": "video/x-m4v",
|
|
79
|
+
"qt": "video/quicktime",
|
|
80
|
+
"3gp4": "video/3gpp",
|
|
81
|
+
"3gp5": "video/3gpp",
|
|
82
|
+
"3gp6": "video/3gpp",
|
|
83
|
+
"3gp7": "video/3gpp",
|
|
84
|
+
"3g2": "video/3gpp2",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_NON_TEXT_SUFFIXES = {
|
|
88
|
+
".icns",
|
|
89
|
+
".psd",
|
|
90
|
+
".ai",
|
|
91
|
+
".eps",
|
|
92
|
+
# Documents / office formats
|
|
93
|
+
".pdf",
|
|
94
|
+
".doc",
|
|
95
|
+
".docx",
|
|
96
|
+
".dot",
|
|
97
|
+
".dotx",
|
|
98
|
+
".rtf",
|
|
99
|
+
".odt",
|
|
100
|
+
".xls",
|
|
101
|
+
".xlsx",
|
|
102
|
+
".xlsm",
|
|
103
|
+
".xlt",
|
|
104
|
+
".xltx",
|
|
105
|
+
".xltm",
|
|
106
|
+
".ods",
|
|
107
|
+
".ppt",
|
|
108
|
+
".pptx",
|
|
109
|
+
".pptm",
|
|
110
|
+
".pps",
|
|
111
|
+
".ppsx",
|
|
112
|
+
".odp",
|
|
113
|
+
".pages",
|
|
114
|
+
".numbers",
|
|
115
|
+
".key",
|
|
116
|
+
# Archives / compressed
|
|
117
|
+
".zip",
|
|
118
|
+
".rar",
|
|
119
|
+
".7z",
|
|
120
|
+
".tar",
|
|
121
|
+
".gz",
|
|
122
|
+
".tgz",
|
|
123
|
+
".bz2",
|
|
124
|
+
".xz",
|
|
125
|
+
".zst",
|
|
126
|
+
".lz",
|
|
127
|
+
".lz4",
|
|
128
|
+
".br",
|
|
129
|
+
".cab",
|
|
130
|
+
".ar",
|
|
131
|
+
".deb",
|
|
132
|
+
".rpm",
|
|
133
|
+
# Audio
|
|
134
|
+
".mp3",
|
|
135
|
+
".wav",
|
|
136
|
+
".flac",
|
|
137
|
+
".ogg",
|
|
138
|
+
".oga",
|
|
139
|
+
".opus",
|
|
140
|
+
".aac",
|
|
141
|
+
".m4a",
|
|
142
|
+
".wma",
|
|
143
|
+
# Fonts
|
|
144
|
+
".ttf",
|
|
145
|
+
".otf",
|
|
146
|
+
".woff",
|
|
147
|
+
".woff2",
|
|
148
|
+
# Binaries / bundles
|
|
149
|
+
".exe",
|
|
150
|
+
".dll",
|
|
151
|
+
".so",
|
|
152
|
+
".dylib",
|
|
153
|
+
".bin",
|
|
154
|
+
".apk",
|
|
155
|
+
".ipa",
|
|
156
|
+
".jar",
|
|
157
|
+
".class",
|
|
158
|
+
".pyc",
|
|
159
|
+
".pyo",
|
|
160
|
+
".wasm",
|
|
161
|
+
# Disk images / databases
|
|
162
|
+
".dmg",
|
|
163
|
+
".iso",
|
|
164
|
+
".img",
|
|
165
|
+
".sqlite",
|
|
166
|
+
".sqlite3",
|
|
167
|
+
".db",
|
|
168
|
+
".db3",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class FileType:
|
|
174
|
+
kind: Literal["text", "image", "video", "unknown"]
|
|
175
|
+
mime_type: str
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _sniff_ftyp_brand(header: bytes) -> str | None:
|
|
179
|
+
if len(header) < 12 or header[4:8] != b"ftyp":
|
|
180
|
+
return None
|
|
181
|
+
brand = header[8:12].decode("ascii", errors="ignore").lower()
|
|
182
|
+
return brand.strip()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def sniff_media_from_magic(data: bytes) -> FileType | None:
|
|
186
|
+
header = data[:MEDIA_SNIFF_BYTES]
|
|
187
|
+
if header.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
188
|
+
return FileType(kind="image", mime_type="image/png")
|
|
189
|
+
if header.startswith(b"\xff\xd8\xff"):
|
|
190
|
+
return FileType(kind="image", mime_type="image/jpeg")
|
|
191
|
+
if header.startswith((b"GIF87a", b"GIF89a")):
|
|
192
|
+
return FileType(kind="image", mime_type="image/gif")
|
|
193
|
+
if header.startswith(b"BM"):
|
|
194
|
+
return FileType(kind="image", mime_type="image/bmp")
|
|
195
|
+
if header.startswith((b"II*\x00", b"MM\x00*")):
|
|
196
|
+
return FileType(kind="image", mime_type="image/tiff")
|
|
197
|
+
if header.startswith(b"\x00\x00\x01\x00"):
|
|
198
|
+
return FileType(kind="image", mime_type="image/x-icon")
|
|
199
|
+
if header.startswith(b"RIFF") and len(header) >= 12:
|
|
200
|
+
chunk = header[8:12]
|
|
201
|
+
if chunk == b"WEBP":
|
|
202
|
+
return FileType(kind="image", mime_type="image/webp")
|
|
203
|
+
if chunk == b"AVI ":
|
|
204
|
+
return FileType(kind="video", mime_type="video/x-msvideo")
|
|
205
|
+
if header.startswith(b"FLV"):
|
|
206
|
+
return FileType(kind="video", mime_type="video/x-flv")
|
|
207
|
+
if header.startswith(_ASF_HEADER):
|
|
208
|
+
return FileType(kind="video", mime_type="video/x-ms-wmv")
|
|
209
|
+
if header.startswith(b"\x1a\x45\xdf\xa3"):
|
|
210
|
+
lowered = header.lower()
|
|
211
|
+
if b"webm" in lowered:
|
|
212
|
+
return FileType(kind="video", mime_type="video/webm")
|
|
213
|
+
if b"matroska" in lowered:
|
|
214
|
+
return FileType(kind="video", mime_type="video/x-matroska")
|
|
215
|
+
if brand := _sniff_ftyp_brand(header):
|
|
216
|
+
if brand in _FTYP_IMAGE_BRANDS:
|
|
217
|
+
return FileType(kind="image", mime_type=_FTYP_IMAGE_BRANDS[brand])
|
|
218
|
+
if brand in _FTYP_VIDEO_BRANDS:
|
|
219
|
+
return FileType(kind="video", mime_type=_FTYP_VIDEO_BRANDS[brand])
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def detect_file_type(path: str | PurePath, header: bytes | None = None) -> FileType:
|
|
224
|
+
suffix = PurePath(str(path)).suffix.lower()
|
|
225
|
+
media_hint: FileType | None = None
|
|
226
|
+
if suffix in _IMAGE_MIME_BY_SUFFIX:
|
|
227
|
+
media_hint = FileType(kind="image", mime_type=_IMAGE_MIME_BY_SUFFIX[suffix])
|
|
228
|
+
elif suffix in _VIDEO_MIME_BY_SUFFIX:
|
|
229
|
+
media_hint = FileType(kind="video", mime_type=_VIDEO_MIME_BY_SUFFIX[suffix])
|
|
230
|
+
else:
|
|
231
|
+
mime_type, _ = mimetypes.guess_type(str(path))
|
|
232
|
+
if mime_type:
|
|
233
|
+
if mime_type.startswith("image/"):
|
|
234
|
+
media_hint = FileType(kind="image", mime_type=mime_type)
|
|
235
|
+
elif mime_type.startswith("video/"):
|
|
236
|
+
media_hint = FileType(kind="video", mime_type=mime_type)
|
|
237
|
+
|
|
238
|
+
if header is not None:
|
|
239
|
+
sniffed = sniff_media_from_magic(header)
|
|
240
|
+
if sniffed:
|
|
241
|
+
if media_hint and sniffed.kind != media_hint.kind:
|
|
242
|
+
return FileType(kind="unknown", mime_type="")
|
|
243
|
+
return sniffed
|
|
244
|
+
# NUL bytes are a strong signal of binary content.
|
|
245
|
+
if b"\x00" in header:
|
|
246
|
+
return FileType(kind="unknown", mime_type="")
|
|
247
|
+
|
|
248
|
+
if media_hint:
|
|
249
|
+
return media_hint
|
|
250
|
+
if suffix in _NON_TEXT_SUFFIXES:
|
|
251
|
+
return FileType(kind="unknown", mime_type="")
|
|
252
|
+
return FileType(kind="text", mime_type="text/plain")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
N_CONTEXT_LINES = 3
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def build_diff_blocks(
|
|
259
|
+
path: str,
|
|
260
|
+
old_text: str,
|
|
261
|
+
new_text: str,
|
|
262
|
+
) -> list[DiffDisplayBlock]:
|
|
263
|
+
"""Build diff display blocks grouped with small context windows."""
|
|
264
|
+
old_lines = old_text.splitlines()
|
|
265
|
+
new_lines = new_text.splitlines()
|
|
266
|
+
matcher = SequenceMatcher(None, old_lines, new_lines, autojunk=False)
|
|
267
|
+
blocks: list[DiffDisplayBlock] = []
|
|
268
|
+
for group in matcher.get_grouped_opcodes(n=N_CONTEXT_LINES):
|
|
269
|
+
if not group:
|
|
270
|
+
continue
|
|
271
|
+
i1 = group[0][1]
|
|
272
|
+
i2 = group[-1][2]
|
|
273
|
+
j1 = group[0][3]
|
|
274
|
+
j2 = group[-1][4]
|
|
275
|
+
blocks.append(
|
|
276
|
+
DiffDisplayBlock(
|
|
277
|
+
path=path,
|
|
278
|
+
old_text="\n".join(old_lines[i1:i2]),
|
|
279
|
+
new_text="\n".join(new_lines[j1:j2]),
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
return blocks
|