kimi-cli 0.35__py3-none-any.whl → 0.52__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.
- kimi_cli/CHANGELOG.md +165 -0
- kimi_cli/__init__.py +0 -374
- kimi_cli/agents/{koder → default}/agent.yaml +1 -1
- kimi_cli/agents/{koder → default}/system.md +1 -1
- kimi_cli/agentspec.py +115 -0
- kimi_cli/app.py +208 -0
- kimi_cli/cli.py +321 -0
- kimi_cli/config.py +33 -16
- kimi_cli/constant.py +4 -0
- kimi_cli/exception.py +16 -0
- kimi_cli/llm.py +144 -3
- kimi_cli/metadata.py +6 -69
- kimi_cli/prompts/__init__.py +4 -0
- kimi_cli/session.py +103 -0
- kimi_cli/soul/__init__.py +130 -9
- kimi_cli/soul/agent.py +159 -0
- kimi_cli/soul/approval.py +5 -6
- kimi_cli/soul/compaction.py +106 -0
- kimi_cli/soul/context.py +1 -1
- kimi_cli/soul/kimisoul.py +180 -80
- kimi_cli/soul/message.py +6 -6
- kimi_cli/soul/runtime.py +96 -0
- kimi_cli/soul/toolset.py +3 -2
- kimi_cli/tools/__init__.py +35 -31
- kimi_cli/tools/bash/__init__.py +25 -9
- kimi_cli/tools/bash/cmd.md +31 -0
- kimi_cli/tools/dmail/__init__.py +5 -4
- kimi_cli/tools/file/__init__.py +8 -0
- kimi_cli/tools/file/glob.md +1 -1
- kimi_cli/tools/file/glob.py +4 -4
- kimi_cli/tools/file/grep.py +36 -19
- kimi_cli/tools/file/patch.py +52 -10
- kimi_cli/tools/file/read.py +6 -5
- kimi_cli/tools/file/replace.py +16 -4
- kimi_cli/tools/file/write.py +16 -4
- kimi_cli/tools/mcp.py +7 -4
- kimi_cli/tools/task/__init__.py +60 -41
- kimi_cli/tools/task/task.md +1 -1
- kimi_cli/tools/todo/__init__.py +4 -2
- kimi_cli/tools/utils.py +1 -1
- kimi_cli/tools/web/fetch.py +2 -1
- kimi_cli/tools/web/search.py +13 -12
- kimi_cli/ui/__init__.py +0 -68
- kimi_cli/ui/acp/__init__.py +67 -38
- kimi_cli/ui/print/__init__.py +46 -69
- kimi_cli/ui/shell/__init__.py +145 -154
- kimi_cli/ui/shell/console.py +27 -1
- kimi_cli/ui/shell/debug.py +187 -0
- kimi_cli/ui/shell/keyboard.py +183 -0
- kimi_cli/ui/shell/metacmd.py +34 -81
- kimi_cli/ui/shell/prompt.py +245 -28
- kimi_cli/ui/shell/replay.py +104 -0
- kimi_cli/ui/shell/setup.py +19 -19
- kimi_cli/ui/shell/update.py +11 -5
- kimi_cli/ui/shell/visualize.py +576 -0
- kimi_cli/ui/wire/README.md +109 -0
- kimi_cli/ui/wire/__init__.py +340 -0
- kimi_cli/ui/wire/jsonrpc.py +48 -0
- kimi_cli/utils/__init__.py +0 -0
- kimi_cli/utils/aiohttp.py +10 -0
- kimi_cli/utils/changelog.py +6 -2
- kimi_cli/utils/clipboard.py +10 -0
- kimi_cli/utils/message.py +15 -1
- kimi_cli/utils/rich/__init__.py +33 -0
- kimi_cli/utils/rich/markdown.py +959 -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 +41 -0
- kimi_cli/utils/string.py +8 -0
- kimi_cli/utils/term.py +114 -0
- kimi_cli/wire/__init__.py +73 -0
- kimi_cli/wire/message.py +191 -0
- kimi_cli-0.52.dist-info/METADATA +186 -0
- kimi_cli-0.52.dist-info/RECORD +99 -0
- kimi_cli-0.52.dist-info/entry_points.txt +3 -0
- kimi_cli/agent.py +0 -261
- kimi_cli/agents/koder/README.md +0 -3
- kimi_cli/prompts/metacmds/__init__.py +0 -4
- kimi_cli/soul/wire.py +0 -101
- kimi_cli/ui/shell/liveview.py +0 -158
- kimi_cli/utils/provider.py +0 -64
- kimi_cli-0.35.dist-info/METADATA +0 -24
- kimi_cli-0.35.dist-info/RECORD +0 -76
- kimi_cli-0.35.dist-info/entry_points.txt +0 -3
- /kimi_cli/agents/{koder → default}/sub.yaml +0 -0
- /kimi_cli/prompts/{metacmds/compact.md → compact.md} +0 -0
- /kimi_cli/prompts/{metacmds/init.md → init.md} +0 -0
- {kimi_cli-0.35.dist-info → kimi_cli-0.52.dist-info}/WHEEL +0 -0
kimi_cli/soul/runtime.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
from kimi_cli.config import Config
|
|
9
|
+
from kimi_cli.llm import LLM
|
|
10
|
+
from kimi_cli.session import Session
|
|
11
|
+
from kimi_cli.soul.approval import Approval
|
|
12
|
+
from kimi_cli.soul.denwarenji import DenwaRenji
|
|
13
|
+
from kimi_cli.utils.logging import logger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BuiltinSystemPromptArgs(NamedTuple):
|
|
17
|
+
"""Builtin system prompt arguments."""
|
|
18
|
+
|
|
19
|
+
KIMI_NOW: str
|
|
20
|
+
"""The current datetime."""
|
|
21
|
+
KIMI_WORK_DIR: Path
|
|
22
|
+
"""The current working directory."""
|
|
23
|
+
KIMI_WORK_DIR_LS: str
|
|
24
|
+
"""The directory listing of current working directory."""
|
|
25
|
+
KIMI_AGENTS_MD: str # TODO: move to first message from system prompt
|
|
26
|
+
"""The content of AGENTS.md."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_agents_md(work_dir: Path) -> str | None:
|
|
30
|
+
paths = [
|
|
31
|
+
work_dir / "AGENTS.md",
|
|
32
|
+
work_dir / "agents.md",
|
|
33
|
+
]
|
|
34
|
+
for path in paths:
|
|
35
|
+
if path.is_file():
|
|
36
|
+
logger.info("Loaded agents.md: {path}", path=path)
|
|
37
|
+
return path.read_text(encoding="utf-8").strip()
|
|
38
|
+
logger.info("No AGENTS.md found in {work_dir}", work_dir=work_dir)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _list_work_dir(work_dir: Path) -> str:
|
|
43
|
+
if sys.platform == "win32":
|
|
44
|
+
ls = subprocess.run(
|
|
45
|
+
["cmd", "/c", "dir", work_dir],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
errors="replace",
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
ls = subprocess.run(
|
|
53
|
+
["ls", "-la", work_dir],
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
encoding="utf-8",
|
|
57
|
+
errors="replace",
|
|
58
|
+
)
|
|
59
|
+
return ls.stdout.strip()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Runtime(NamedTuple):
|
|
63
|
+
"""Agent runtime."""
|
|
64
|
+
|
|
65
|
+
config: Config
|
|
66
|
+
llm: LLM | None
|
|
67
|
+
session: Session
|
|
68
|
+
builtin_args: BuiltinSystemPromptArgs
|
|
69
|
+
denwa_renji: DenwaRenji
|
|
70
|
+
approval: Approval
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
async def create(
|
|
74
|
+
config: Config,
|
|
75
|
+
llm: LLM | None,
|
|
76
|
+
session: Session,
|
|
77
|
+
yolo: bool,
|
|
78
|
+
) -> "Runtime":
|
|
79
|
+
ls_output, agents_md = await asyncio.gather(
|
|
80
|
+
asyncio.to_thread(_list_work_dir, session.work_dir),
|
|
81
|
+
asyncio.to_thread(load_agents_md, session.work_dir),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return Runtime(
|
|
85
|
+
config=config,
|
|
86
|
+
llm=llm,
|
|
87
|
+
session=session,
|
|
88
|
+
builtin_args=BuiltinSystemPromptArgs(
|
|
89
|
+
KIMI_NOW=datetime.now().astimezone().isoformat(),
|
|
90
|
+
KIMI_WORK_DIR=session.work_dir,
|
|
91
|
+
KIMI_WORK_DIR_LS=ls_output,
|
|
92
|
+
KIMI_AGENTS_MD=agents_md or "",
|
|
93
|
+
),
|
|
94
|
+
denwa_renji=DenwaRenji(),
|
|
95
|
+
approval=Approval(yolo=yolo),
|
|
96
|
+
)
|
kimi_cli/soul/toolset.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from contextvars import ContextVar
|
|
2
2
|
from typing import override
|
|
3
3
|
|
|
4
|
-
from kosong.
|
|
5
|
-
from kosong.tooling import HandleResult
|
|
4
|
+
from kosong.message import ToolCall
|
|
5
|
+
from kosong.tooling import HandleResult
|
|
6
|
+
from kosong.tooling.simple import SimpleToolset
|
|
6
7
|
|
|
7
8
|
current_tool_call = ContextVar[ToolCall | None]("current_tool_call", default=None)
|
|
8
9
|
|
kimi_cli/tools/__init__.py
CHANGED
|
@@ -1,81 +1,85 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from pathlib import Path
|
|
3
|
+
from typing import cast
|
|
3
4
|
|
|
4
|
-
import streamingjson
|
|
5
|
+
import streamingjson # pyright: ignore[reportMissingTypeStubs]
|
|
5
6
|
from kosong.utils.typing import JsonType
|
|
6
7
|
|
|
7
8
|
from kimi_cli.utils.string import shorten_middle
|
|
8
9
|
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
class SkipThisTool(Exception):
|
|
12
|
+
"""Raised when a tool decides to skip itself from the loading process."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_key_argument(json_content: str | streamingjson.Lexer, tool_name: str) -> str | None:
|
|
18
|
+
if isinstance(json_content, streamingjson.Lexer):
|
|
19
|
+
json_str = json_content.complete_json()
|
|
20
|
+
else:
|
|
21
|
+
json_str = json_content
|
|
11
22
|
try:
|
|
12
|
-
curr_args: JsonType = json.loads(
|
|
23
|
+
curr_args: JsonType = json.loads(json_str)
|
|
13
24
|
except json.JSONDecodeError:
|
|
14
25
|
return None
|
|
15
26
|
if not curr_args:
|
|
16
27
|
return None
|
|
17
|
-
|
|
28
|
+
key_argument: str = ""
|
|
18
29
|
match tool_name:
|
|
19
30
|
case "Task":
|
|
20
31
|
if not isinstance(curr_args, dict) or not curr_args.get("description"):
|
|
21
32
|
return None
|
|
22
|
-
|
|
33
|
+
key_argument = str(curr_args["description"])
|
|
23
34
|
case "SendDMail":
|
|
24
35
|
return "El Psy Kongroo"
|
|
25
36
|
case "Think":
|
|
26
37
|
if not isinstance(curr_args, dict) or not curr_args.get("thought"):
|
|
27
38
|
return None
|
|
28
|
-
|
|
39
|
+
key_argument = str(curr_args["thought"])
|
|
29
40
|
case "SetTodoList":
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if not isinstance(curr_args["todos"], list):
|
|
33
|
-
return None
|
|
34
|
-
for todo in curr_args["todos"]:
|
|
35
|
-
if not isinstance(todo, dict) or not todo.get("title"):
|
|
36
|
-
continue
|
|
37
|
-
subtitle += f"• {todo['title']}"
|
|
38
|
-
if todo.get("status"):
|
|
39
|
-
subtitle += f" [{todo['status']}]"
|
|
40
|
-
subtitle += "\n"
|
|
41
|
-
return "\n" + subtitle.strip()
|
|
42
|
-
case "Bash":
|
|
41
|
+
return None
|
|
42
|
+
case "Bash" | "CMD":
|
|
43
43
|
if not isinstance(curr_args, dict) or not curr_args.get("command"):
|
|
44
44
|
return None
|
|
45
|
-
|
|
45
|
+
key_argument = str(curr_args["command"])
|
|
46
46
|
case "ReadFile":
|
|
47
47
|
if not isinstance(curr_args, dict) or not curr_args.get("path"):
|
|
48
48
|
return None
|
|
49
|
-
|
|
49
|
+
key_argument = _normalize_path(str(curr_args["path"]))
|
|
50
50
|
case "Glob":
|
|
51
51
|
if not isinstance(curr_args, dict) or not curr_args.get("pattern"):
|
|
52
52
|
return None
|
|
53
|
-
|
|
53
|
+
key_argument = str(curr_args["pattern"])
|
|
54
54
|
case "Grep":
|
|
55
55
|
if not isinstance(curr_args, dict) or not curr_args.get("pattern"):
|
|
56
56
|
return None
|
|
57
|
-
|
|
57
|
+
key_argument = str(curr_args["pattern"])
|
|
58
58
|
case "WriteFile":
|
|
59
59
|
if not isinstance(curr_args, dict) or not curr_args.get("path"):
|
|
60
60
|
return None
|
|
61
|
-
|
|
61
|
+
key_argument = _normalize_path(str(curr_args["path"]))
|
|
62
62
|
case "StrReplaceFile":
|
|
63
63
|
if not isinstance(curr_args, dict) or not curr_args.get("path"):
|
|
64
64
|
return None
|
|
65
|
-
|
|
65
|
+
key_argument = _normalize_path(str(curr_args["path"]))
|
|
66
66
|
case "SearchWeb":
|
|
67
67
|
if not isinstance(curr_args, dict) or not curr_args.get("query"):
|
|
68
68
|
return None
|
|
69
|
-
|
|
69
|
+
key_argument = str(curr_args["query"])
|
|
70
70
|
case "FetchURL":
|
|
71
71
|
if not isinstance(curr_args, dict) or not curr_args.get("url"):
|
|
72
72
|
return None
|
|
73
|
-
|
|
73
|
+
key_argument = str(curr_args["url"])
|
|
74
74
|
case _:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
if isinstance(json_content, streamingjson.Lexer):
|
|
76
|
+
# lexer.json_content is list[str] based on streamingjson source code
|
|
77
|
+
content: list[str] = cast(list[str], json_content.json_content) # pyright: ignore[reportUnknownMemberType]
|
|
78
|
+
key_argument = "".join(content)
|
|
79
|
+
else:
|
|
80
|
+
key_argument = json_content
|
|
81
|
+
key_argument = shorten_middle(key_argument, width=50)
|
|
82
|
+
return key_argument
|
|
79
83
|
|
|
80
84
|
|
|
81
85
|
def _normalize_path(path: str) -> str:
|
kimi_cli/tools/bash/__init__.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import platform
|
|
3
|
+
from collections.abc import Callable
|
|
2
4
|
from pathlib import Path
|
|
3
|
-
from typing import override
|
|
5
|
+
from typing import Any, override
|
|
4
6
|
|
|
5
7
|
from kosong.tooling import CallableTool2, ToolReturnType
|
|
6
8
|
from pydantic import BaseModel, Field
|
|
@@ -24,12 +26,16 @@ class Params(BaseModel):
|
|
|
24
26
|
)
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
_NAME = "CMD" if platform.system() == "Windows" else "Bash"
|
|
30
|
+
_DESC_FILE = "cmd.md" if platform.system() == "Windows" else "bash.md"
|
|
31
|
+
|
|
32
|
+
|
|
27
33
|
class Bash(CallableTool2[Params]):
|
|
28
|
-
name: str =
|
|
29
|
-
description: str = load_desc(Path(__file__).parent /
|
|
34
|
+
name: str = _NAME
|
|
35
|
+
description: str = load_desc(Path(__file__).parent / _DESC_FILE, {})
|
|
30
36
|
params: type[Params] = Params
|
|
31
37
|
|
|
32
|
-
def __init__(self, approval: Approval, **kwargs):
|
|
38
|
+
def __init__(self, approval: Approval, **kwargs: Any):
|
|
33
39
|
super().__init__(**kwargs)
|
|
34
40
|
self._approval = approval
|
|
35
41
|
|
|
@@ -38,16 +44,18 @@ class Bash(CallableTool2[Params]):
|
|
|
38
44
|
builder = ToolResultBuilder()
|
|
39
45
|
|
|
40
46
|
if not await self._approval.request(
|
|
41
|
-
|
|
47
|
+
self.name,
|
|
48
|
+
"run shell command",
|
|
49
|
+
f"Run command `{params.command}`",
|
|
42
50
|
):
|
|
43
51
|
return ToolRejectedError()
|
|
44
52
|
|
|
45
53
|
def stdout_cb(line: bytes):
|
|
46
|
-
line_str = line.decode()
|
|
54
|
+
line_str = line.decode(encoding="utf-8", errors="replace")
|
|
47
55
|
builder.write(line_str)
|
|
48
56
|
|
|
49
57
|
def stderr_cb(line: bytes):
|
|
50
|
-
line_str = line.decode()
|
|
58
|
+
line_str = line.decode(encoding="utf-8", errors="replace")
|
|
51
59
|
builder.write(line_str)
|
|
52
60
|
|
|
53
61
|
try:
|
|
@@ -69,8 +77,13 @@ class Bash(CallableTool2[Params]):
|
|
|
69
77
|
)
|
|
70
78
|
|
|
71
79
|
|
|
72
|
-
async def _stream_subprocess(
|
|
73
|
-
|
|
80
|
+
async def _stream_subprocess(
|
|
81
|
+
command: str,
|
|
82
|
+
stdout_cb: Callable[[bytes], None],
|
|
83
|
+
stderr_cb: Callable[[bytes], None],
|
|
84
|
+
timeout: int,
|
|
85
|
+
) -> int:
|
|
86
|
+
async def _read_stream(stream: asyncio.StreamReader, cb: Callable[[bytes], None]):
|
|
74
87
|
while True:
|
|
75
88
|
line = await stream.readline()
|
|
76
89
|
if line:
|
|
@@ -83,6 +96,9 @@ async def _stream_subprocess(command: str, stdout_cb, stderr_cb, timeout: int) -
|
|
|
83
96
|
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
84
97
|
)
|
|
85
98
|
|
|
99
|
+
assert process.stdout is not None, "stdout is None"
|
|
100
|
+
assert process.stderr is not None, "stderr is None"
|
|
101
|
+
|
|
86
102
|
try:
|
|
87
103
|
await asyncio.wait_for(
|
|
88
104
|
asyncio.gather(
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Execute a Windows Command Prompt (`cmd.exe`) command. Use this tool to explore the filesystem, inspect or edit files, run Windows scripts, collect system information, etc., whenever the agent is running on Windows.
|
|
2
|
+
|
|
3
|
+
Note that you are running on Windows, so make sure to use Windows commands, paths, and conventions.
|
|
4
|
+
|
|
5
|
+
**Output:**
|
|
6
|
+
The stdout and stderr streams are combined and returned as a single string. Extremely long output may be truncated. When a command fails, the exit code is provided in a system tag.
|
|
7
|
+
|
|
8
|
+
**Guidelines for safety and security:**
|
|
9
|
+
- Every tool call starts a fresh `cmd.exe` session. Environment variables, `cd` changes, and command history do not persist between calls.
|
|
10
|
+
- Do not launch interactive programs or anything that is expected to block indefinitely; ensure each command finishes promptly. Provide a `timeout` argument for potentially long runs.
|
|
11
|
+
- Avoid using `..` to leave the working directory, and never touch files outside that directory unless explicitly instructed.
|
|
12
|
+
- Never attempt commands that require elevated (Administrator) privileges unless explicitly authorized.
|
|
13
|
+
|
|
14
|
+
**Windows-specific tips:**
|
|
15
|
+
- Use `cd /d "<path>"` when you must switch drives and directories in one command.
|
|
16
|
+
- Quote any path containing spaces with double quotes. Escape special characters such as `&`, `|`, `>`, and `<` with `^` when needed.
|
|
17
|
+
- Prefer non-interactive file editing techniques such as `type`, `more`, `copy`, `powershell -Command "Get-Content"`, or `python - <<'PY' ... PY`.
|
|
18
|
+
- Convert forward slashes to backslashes only when a command explicitly requires it; most tooling on Windows accepts `/` as well.
|
|
19
|
+
|
|
20
|
+
**Guidelines for efficiency:**
|
|
21
|
+
- Chain related commands with `&&` (stop on failure) or `&` (always continue); use `||` to run a fallback after a failure.
|
|
22
|
+
- Redirect or pipe output with `>`, `>>`, `|`, and leverage `for /f`, `if`, and `set` to build richer one-liners instead of multiple tool calls.
|
|
23
|
+
- Reuse built-in utilities (e.g., `findstr`, `where`, `powershell`) to filter, transform, or locate data in a single invocation.
|
|
24
|
+
|
|
25
|
+
**Commands available:**
|
|
26
|
+
- Shell environment: `cd`, `dir`, `set`, `setlocal`, `echo`, `call`, `where`
|
|
27
|
+
- File operations: `type`, `copy`, `move`, `del`, `erase`, `mkdir`, `rmdir`, `attrib`, `mklink`
|
|
28
|
+
- Text/search: `find`, `findstr`, `more`, `sort`, `powershell -Command "Get-Content"`
|
|
29
|
+
- System info: `ver`, `systeminfo`, `tasklist`, `wmic`, `hostname`
|
|
30
|
+
- Archives/scripts: `tar`, `powershell -Command "Compress-Archive"`, `powershell`, `python`, `node`
|
|
31
|
+
- Other: Any other binaries available on the system PATH; run `where <command>` first if unsure.
|
kimi_cli/tools/dmail/__init__.py
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
-
from typing import override
|
|
2
|
+
from typing import Any, override
|
|
3
3
|
|
|
4
4
|
from kosong.tooling import CallableTool2, ToolError, ToolReturnType
|
|
5
5
|
|
|
6
6
|
from kimi_cli.soul.denwarenji import DenwaRenji, DenwaRenjiError, DMail
|
|
7
|
+
from kimi_cli.tools.utils import load_desc
|
|
7
8
|
|
|
8
9
|
NAME = "SendDMail"
|
|
9
10
|
|
|
10
11
|
|
|
11
|
-
class SendDMail(CallableTool2):
|
|
12
|
+
class SendDMail(CallableTool2[DMail]):
|
|
12
13
|
name: str = NAME
|
|
13
|
-
description: str = (Path(__file__).parent / "dmail.md")
|
|
14
|
+
description: str = load_desc(Path(__file__).parent / "dmail.md")
|
|
14
15
|
params: type[DMail] = DMail
|
|
15
16
|
|
|
16
|
-
def __init__(self, denwa_renji: DenwaRenji, **kwargs):
|
|
17
|
+
def __init__(self, denwa_renji: DenwaRenji, **kwargs: Any) -> None:
|
|
17
18
|
super().__init__(**kwargs)
|
|
18
19
|
self._denwa_renji = denwa_renji
|
|
19
20
|
|
kimi_cli/tools/file/__init__.py
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
1
4
|
class FileOpsWindow:
|
|
2
5
|
"""Maintains a window of file operations."""
|
|
3
6
|
|
|
4
7
|
pass
|
|
5
8
|
|
|
6
9
|
|
|
10
|
+
class FileActions(str, Enum):
|
|
11
|
+
READ = "read file"
|
|
12
|
+
EDIT = "edit file"
|
|
13
|
+
|
|
14
|
+
|
|
7
15
|
from .glob import Glob # noqa: E402
|
|
8
16
|
from .grep import Grep # noqa: E402
|
|
9
17
|
from .patch import PatchFile # noqa: E402
|
kimi_cli/tools/file/glob.md
CHANGED
|
@@ -14,4 +14,4 @@ Find files and directories using glob patterns. This tool supports standard glob
|
|
|
14
14
|
|
|
15
15
|
**Bad example patterns:**
|
|
16
16
|
- `**`, `**/*.py` - Any pattern starting with '**' will be rejected. Because it would recursively search all directories and subdirectories, which is very likely to yield large result that exceeds your context size. Always use more specific patterns like `src/**/*.py` instead.
|
|
17
|
-
- `node_modules/**/*.js` - Although this does not start with '**', it would still highly possible to yield large result because `node_modules` is well-known to contain too many directories and files. Avoid
|
|
17
|
+
- `node_modules/**/*.js` - Although this does not start with '**', it would still highly possible to yield large result because `node_modules` is well-known to contain too many directories and files. Avoid recursively searching in such directories, other examples include `venv`, `.venv`, `__pycache__`, `target`. If you really need to search in a dependency, use more specific patterns like `node_modules/react/src/*` instead.
|
kimi_cli/tools/file/glob.py
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import override
|
|
5
|
+
from typing import Any, override
|
|
6
6
|
|
|
7
7
|
import aiofiles.os
|
|
8
8
|
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
|
|
9
9
|
from pydantic import BaseModel, Field
|
|
10
10
|
|
|
11
|
-
from kimi_cli.
|
|
11
|
+
from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
|
|
12
12
|
from kimi_cli.tools.utils import load_desc
|
|
13
13
|
|
|
14
14
|
MAX_MATCHES = 1000
|
|
@@ -38,7 +38,7 @@ class Glob(CallableTool2[Params]):
|
|
|
38
38
|
)
|
|
39
39
|
params: type[Params] = Params
|
|
40
40
|
|
|
41
|
-
def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
|
|
41
|
+
def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs: Any) -> None:
|
|
42
42
|
super().__init__(**kwargs)
|
|
43
43
|
self._work_dir = builtin_args.KIMI_WORK_DIR
|
|
44
44
|
|
|
@@ -128,7 +128,7 @@ class Glob(CallableTool2[Params]):
|
|
|
128
128
|
message = (
|
|
129
129
|
f"Found {len(matches)} matches for pattern `{params.pattern}`."
|
|
130
130
|
if len(matches) > 0
|
|
131
|
-
else "No matches found for pattern `{params.pattern}`."
|
|
131
|
+
else f"No matches found for pattern `{params.pattern}`."
|
|
132
132
|
)
|
|
133
133
|
if len(matches) > MAX_MATCHES:
|
|
134
134
|
matches = matches[:MAX_MATCHES]
|
kimi_cli/tools/file/grep.py
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import os
|
|
3
2
|
import platform
|
|
4
3
|
import shutil
|
|
5
4
|
import stat
|
|
6
5
|
import tarfile
|
|
7
6
|
import tempfile
|
|
7
|
+
import zipfile
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import override
|
|
10
10
|
|
|
11
11
|
import aiohttp
|
|
12
|
-
import ripgrepy
|
|
12
|
+
import ripgrepy # pyright: ignore[reportMissingTypeStubs]
|
|
13
13
|
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
|
|
14
14
|
from pydantic import BaseModel, Field
|
|
15
15
|
|
|
16
16
|
import kimi_cli
|
|
17
17
|
from kimi_cli.share import get_share_dir
|
|
18
|
+
from kimi_cli.tools.utils import load_desc
|
|
19
|
+
from kimi_cli.utils.aiohttp import new_client_session
|
|
18
20
|
from kimi_cli.utils.logging import logger
|
|
19
21
|
|
|
20
22
|
|
|
@@ -112,7 +114,7 @@ _RG_DOWNLOAD_LOCK = asyncio.Lock()
|
|
|
112
114
|
|
|
113
115
|
|
|
114
116
|
def _rg_binary_name() -> str:
|
|
115
|
-
return "rg.exe" if
|
|
117
|
+
return "rg.exe" if platform.system() == "Windows" else "rg"
|
|
116
118
|
|
|
117
119
|
|
|
118
120
|
def _find_existing_rg(bin_name: str) -> Path | None:
|
|
@@ -147,6 +149,8 @@ def _detect_target() -> str | None:
|
|
|
147
149
|
os_name = "apple-darwin"
|
|
148
150
|
elif sys_name == "Linux":
|
|
149
151
|
os_name = "unknown-linux-musl" if arch == "x86_64" else "unknown-linux-gnu"
|
|
152
|
+
elif sys_name == "Windows":
|
|
153
|
+
os_name = "pc-windows-msvc"
|
|
150
154
|
else:
|
|
151
155
|
logger.error("Unsupported operating system for ripgrep: {sys_name}", sys_name=sys_name)
|
|
152
156
|
return None
|
|
@@ -159,7 +163,9 @@ async def _download_and_install_rg(bin_name: str) -> Path:
|
|
|
159
163
|
if not target:
|
|
160
164
|
raise RuntimeError("Unsupported platform for ripgrep download")
|
|
161
165
|
|
|
162
|
-
|
|
166
|
+
is_windows = "windows" in target
|
|
167
|
+
archive_ext = "zip" if is_windows else "tar.gz"
|
|
168
|
+
filename = f"ripgrep-{RG_VERSION}-{target}.{archive_ext}"
|
|
163
169
|
url = f"{RG_BASE_URL}/{filename}"
|
|
164
170
|
logger.info("Downloading ripgrep from {url}", url=url)
|
|
165
171
|
|
|
@@ -167,7 +173,7 @@ async def _download_and_install_rg(bin_name: str) -> Path:
|
|
|
167
173
|
share_bin_dir.mkdir(parents=True, exist_ok=True)
|
|
168
174
|
destination = share_bin_dir / bin_name
|
|
169
175
|
|
|
170
|
-
async with
|
|
176
|
+
async with new_client_session() as session:
|
|
171
177
|
with tempfile.TemporaryDirectory(prefix="kimi-rg-") as tmpdir:
|
|
172
178
|
tar_path = Path(tmpdir) / filename
|
|
173
179
|
|
|
@@ -182,19 +188,30 @@ async def _download_and_install_rg(bin_name: str) -> Path:
|
|
|
182
188
|
raise RuntimeError("Failed to download ripgrep binary") from exc
|
|
183
189
|
|
|
184
190
|
try:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
191
|
+
if is_windows:
|
|
192
|
+
with zipfile.ZipFile(tar_path, "r") as zf:
|
|
193
|
+
member_name = next(
|
|
194
|
+
(name for name in zf.namelist() if Path(name).name == bin_name),
|
|
195
|
+
None,
|
|
196
|
+
)
|
|
197
|
+
if not member_name:
|
|
198
|
+
raise RuntimeError("Ripgrep binary not found in archive")
|
|
199
|
+
with zf.open(member_name) as source, open(destination, "wb") as dest_fh:
|
|
200
|
+
shutil.copyfileobj(source, dest_fh)
|
|
201
|
+
else:
|
|
202
|
+
with tarfile.open(tar_path, "r:gz") as tar:
|
|
203
|
+
member = next(
|
|
204
|
+
(m for m in tar.getmembers() if Path(m.name).name == bin_name),
|
|
205
|
+
None,
|
|
206
|
+
)
|
|
207
|
+
if not member:
|
|
208
|
+
raise RuntimeError("Ripgrep binary not found in archive")
|
|
209
|
+
extracted = tar.extractfile(member)
|
|
210
|
+
if not extracted:
|
|
211
|
+
raise RuntimeError("Failed to extract ripgrep binary")
|
|
212
|
+
with open(destination, "wb") as dest_fh:
|
|
213
|
+
shutil.copyfileobj(extracted, dest_fh)
|
|
214
|
+
except (zipfile.BadZipFile, tarfile.TarError, OSError) as exc:
|
|
198
215
|
raise RuntimeError("Failed to extract ripgrep archive") from exc
|
|
199
216
|
|
|
200
217
|
destination.chmod(destination.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
@@ -219,7 +236,7 @@ async def _ensure_rg_path() -> str:
|
|
|
219
236
|
|
|
220
237
|
class Grep(CallableTool2[Params]):
|
|
221
238
|
name: str = "Grep"
|
|
222
|
-
description: str = (Path(__file__).parent / "grep.md")
|
|
239
|
+
description: str = load_desc(Path(__file__).parent / "grep.md")
|
|
223
240
|
params: type[Params] = Params
|
|
224
241
|
|
|
225
242
|
@override
|
kimi_cli/tools/file/patch.py
CHANGED
|
@@ -1,12 +1,45 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
-
from typing import override
|
|
2
|
+
from typing import Any, Literal, override
|
|
3
3
|
|
|
4
4
|
import aiofiles
|
|
5
|
-
import patch_ng
|
|
5
|
+
import patch_ng # pyright: ignore[reportMissingTypeStubs]
|
|
6
6
|
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
|
|
7
7
|
from pydantic import BaseModel, Field
|
|
8
8
|
|
|
9
|
-
from kimi_cli.
|
|
9
|
+
from kimi_cli.soul.approval import Approval
|
|
10
|
+
from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
|
|
11
|
+
from kimi_cli.tools.file import FileActions
|
|
12
|
+
from kimi_cli.tools.utils import ToolRejectedError, load_desc
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_patch(diff_bytes: bytes) -> patch_ng.PatchSet | None:
|
|
16
|
+
"""Parse patch from bytes, returning PatchSet or None on error.
|
|
17
|
+
|
|
18
|
+
This wrapper provides type hints for the untyped patch_ng.fromstring function.
|
|
19
|
+
"""
|
|
20
|
+
result: patch_ng.PatchSet | Literal[False] = patch_ng.fromstring(diff_bytes) # pyright: ignore[reportUnknownMemberType]
|
|
21
|
+
return result if result is not False else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _count_hunks(patch_set: patch_ng.PatchSet) -> int:
|
|
25
|
+
"""Count total hunks across all items in a PatchSet.
|
|
26
|
+
|
|
27
|
+
This wrapper provides type hints for the untyped patch_ng library.
|
|
28
|
+
From source code inspection: PatchSet.items is list[Patch], Patch.hunks is list[Hunk].
|
|
29
|
+
Type ignore needed because patch_ng lacks type annotations.
|
|
30
|
+
"""
|
|
31
|
+
items: list[patch_ng.Patch] = patch_set.items # pyright: ignore[reportUnknownMemberType]
|
|
32
|
+
# Each Patch has a hunks attribute (list[Hunk])
|
|
33
|
+
return sum(len(item.hunks) for item in items) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _apply_patch(patch_set: patch_ng.PatchSet, root: str) -> bool:
|
|
37
|
+
"""Apply a patch to files under the given root directory.
|
|
38
|
+
|
|
39
|
+
This wrapper provides type hints for the untyped patch_ng.apply method.
|
|
40
|
+
"""
|
|
41
|
+
success: Any = patch_set.apply(root=root) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
|
42
|
+
return bool(success) # pyright: ignore[reportUnknownArgumentType]
|
|
10
43
|
|
|
11
44
|
|
|
12
45
|
class Params(BaseModel):
|
|
@@ -16,12 +49,13 @@ class Params(BaseModel):
|
|
|
16
49
|
|
|
17
50
|
class PatchFile(CallableTool2[Params]):
|
|
18
51
|
name: str = "PatchFile"
|
|
19
|
-
description: str = (Path(__file__).parent / "patch.md")
|
|
52
|
+
description: str = load_desc(Path(__file__).parent / "patch.md")
|
|
20
53
|
params: type[Params] = Params
|
|
21
54
|
|
|
22
|
-
def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
|
|
55
|
+
def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs: Any):
|
|
23
56
|
super().__init__(**kwargs)
|
|
24
57
|
self._work_dir = builtin_args.KIMI_WORK_DIR
|
|
58
|
+
self._approval = approval
|
|
25
59
|
|
|
26
60
|
def _validate_path(self, path: Path) -> ToolError | None:
|
|
27
61
|
"""Validate that the path is safe to patch."""
|
|
@@ -70,15 +104,23 @@ class PatchFile(CallableTool2[Params]):
|
|
|
70
104
|
brief="Invalid path",
|
|
71
105
|
)
|
|
72
106
|
|
|
107
|
+
# Request approval
|
|
108
|
+
if not await self._approval.request(
|
|
109
|
+
self.name,
|
|
110
|
+
FileActions.EDIT,
|
|
111
|
+
f"Patch file `{params.path}`",
|
|
112
|
+
):
|
|
113
|
+
return ToolRejectedError()
|
|
114
|
+
|
|
73
115
|
# Read the file content
|
|
74
116
|
async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
|
|
75
117
|
original_content = await f.read()
|
|
76
118
|
|
|
77
119
|
# Create patch object directly from string (no temporary file needed!)
|
|
78
|
-
patch_set =
|
|
120
|
+
patch_set = _parse_patch(params.diff.encode("utf-8"))
|
|
79
121
|
|
|
80
|
-
# Handle case where
|
|
81
|
-
if
|
|
122
|
+
# Handle case where parsing failed
|
|
123
|
+
if patch_set is None:
|
|
82
124
|
return ToolError(
|
|
83
125
|
message=(
|
|
84
126
|
"Failed to parse diff content: invalid patch format or no valid hunks found"
|
|
@@ -87,7 +129,7 @@ class PatchFile(CallableTool2[Params]):
|
|
|
87
129
|
)
|
|
88
130
|
|
|
89
131
|
# Count total hunks across all items
|
|
90
|
-
total_hunks =
|
|
132
|
+
total_hunks = _count_hunks(patch_set)
|
|
91
133
|
|
|
92
134
|
if total_hunks == 0:
|
|
93
135
|
return ToolError(
|
|
@@ -96,7 +138,7 @@ class PatchFile(CallableTool2[Params]):
|
|
|
96
138
|
)
|
|
97
139
|
|
|
98
140
|
# Apply the patch
|
|
99
|
-
success = patch_set
|
|
141
|
+
success = _apply_patch(patch_set, str(p.parent))
|
|
100
142
|
|
|
101
143
|
if not success:
|
|
102
144
|
return ToolError(
|