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,150 @@
1
+ import re
2
+ import string
3
+ from pathlib import Path
4
+
5
+ from kosong.tooling import ToolError, ToolOk
6
+
7
+
8
+ def load_desc(path: Path, substitutions: dict[str, str] | None = None) -> str:
9
+ """Load a tool description from a file, with optional substitutions."""
10
+ description = path.read_text()
11
+ if substitutions:
12
+ description = string.Template(description).substitute(substitutions)
13
+ return description
14
+
15
+
16
+ def truncate_line(line: str, max_length: int, marker: str = "...") -> str:
17
+ """
18
+ Truncate a line if it exceeds `max_length`, preserving the beginning and the line break.
19
+ The output may be longer than `max_length` if it is too short to fit the marker.
20
+ """
21
+ if len(line) <= max_length:
22
+ return line
23
+
24
+ # Find line breaks at the end of the line
25
+ m = re.search(r"[\r\n]+$", line)
26
+ linebreak = m.group(0) if m else ""
27
+ end = marker + linebreak
28
+ max_length = max(max_length, len(end))
29
+ return line[: max_length - len(end)] + end
30
+
31
+
32
+ # Default output limits
33
+ DEFAULT_MAX_CHARS = 50_000
34
+ DEFAULT_MAX_LINE_LENGTH = 2000
35
+
36
+
37
+ class ToolResultBuilder:
38
+ """
39
+ Builder for tool results with character and line limits.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ max_chars: int = DEFAULT_MAX_CHARS,
45
+ max_line_length: int | None = DEFAULT_MAX_LINE_LENGTH,
46
+ ):
47
+ self.max_chars = max_chars
48
+ self.max_line_length = max_line_length
49
+ self._marker = "[...truncated]"
50
+ if max_line_length is not None:
51
+ assert max_line_length > len(self._marker)
52
+ self._buffer: list[str] = []
53
+ self._n_chars = 0
54
+ self._n_lines = 0
55
+ self._truncation_happened = False
56
+
57
+ def write(self, text: str) -> int:
58
+ """
59
+ Write text to the output buffer.
60
+
61
+ Returns:
62
+ int: Number of characters actually written
63
+ """
64
+ if self.is_full:
65
+ return 0
66
+
67
+ lines = text.splitlines(keepends=True)
68
+ if not lines:
69
+ return 0
70
+
71
+ chars_written = 0
72
+
73
+ for line in lines:
74
+ if self.is_full:
75
+ break
76
+
77
+ original_line = line
78
+ remaining_chars = self.max_chars - self._n_chars
79
+ limit = (
80
+ min(remaining_chars, self.max_line_length)
81
+ if self.max_line_length is not None
82
+ else remaining_chars
83
+ )
84
+ line = truncate_line(line, limit, self._marker)
85
+ if line != original_line:
86
+ self._truncation_happened = True
87
+
88
+ self._buffer.append(line)
89
+ chars_written += len(line)
90
+ self._n_chars += len(line)
91
+ if line.endswith("\n"):
92
+ self._n_lines += 1
93
+
94
+ return chars_written
95
+
96
+ def ok(self, message: str = "", *, brief: str = "") -> ToolOk:
97
+ """Create a ToolOk result with the current output."""
98
+ output = "".join(self._buffer)
99
+
100
+ final_message = message
101
+ if final_message and not final_message.endswith("."):
102
+ final_message += "."
103
+ truncation_msg = "Output is truncated to fit in the message."
104
+ if self._truncation_happened:
105
+ if final_message:
106
+ final_message += f" {truncation_msg}"
107
+ else:
108
+ final_message = truncation_msg
109
+
110
+ return ToolOk(output=output, message=final_message, brief=brief)
111
+
112
+ def error(self, message: str, *, brief: str) -> ToolError:
113
+ """Create a ToolError result with the current output."""
114
+ output = "".join(self._buffer)
115
+
116
+ final_message = message
117
+ if self._truncation_happened:
118
+ truncation_msg = "Output is truncated to fit in the message."
119
+ if final_message:
120
+ final_message += f" {truncation_msg}"
121
+ else:
122
+ final_message = truncation_msg
123
+
124
+ return ToolError(output=output, message=final_message, brief=brief)
125
+
126
+ @property
127
+ def is_full(self) -> bool:
128
+ """Check if output buffer is full due to character limit."""
129
+ return self._n_chars >= self.max_chars
130
+
131
+ @property
132
+ def n_chars(self) -> int:
133
+ """Get current character count."""
134
+ return self._n_chars
135
+
136
+ @property
137
+ def n_lines(self) -> int:
138
+ """Get current line count."""
139
+ return self._n_lines
140
+
141
+
142
+ class ToolRejectedError(ToolError):
143
+ def __init__(self):
144
+ super().__init__(
145
+ message=(
146
+ "The tool call is rejected by the user. "
147
+ "Please follow the new instructions from the user."
148
+ ),
149
+ brief="Rejected by user",
150
+ )
@@ -0,0 +1,4 @@
1
+ from .fetch import FetchURL
2
+ from .search import SearchWeb
3
+
4
+ __all__ = ("SearchWeb", "FetchURL")
@@ -0,0 +1 @@
1
+ Fetch a web page from a URL and extract main text content from it.
@@ -0,0 +1,94 @@
1
+ from pathlib import Path
2
+ from typing import override
3
+
4
+ import aiohttp
5
+ import trafilatura
6
+ from kosong.tooling import CallableTool2, ToolReturnType
7
+ from pydantic import BaseModel, Field
8
+
9
+ from kimi_cli.tools.utils import ToolResultBuilder, load_desc
10
+
11
+
12
+ class Params(BaseModel):
13
+ url: str = Field(description="The URL to fetch content from.")
14
+
15
+
16
+ class FetchURL(CallableTool2[Params]):
17
+ name: str = "FetchURL"
18
+ description: str = load_desc(Path(__file__).parent / "fetch.md", {})
19
+ params: type[Params] = Params
20
+
21
+ @override
22
+ async def __call__(self, params: Params) -> ToolReturnType:
23
+ builder = ToolResultBuilder(max_line_length=None)
24
+
25
+ try:
26
+ async with (
27
+ aiohttp.ClientSession() as session,
28
+ session.get(
29
+ params.url,
30
+ headers={
31
+ "User-Agent": (
32
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
33
+ "(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
34
+ ),
35
+ },
36
+ ) as response,
37
+ ):
38
+ if response.status >= 400:
39
+ return builder.error(
40
+ (
41
+ f"Failed to fetch URL. Status: {response.status}. "
42
+ f"This may indicate the page is not accessible or the server is down."
43
+ ),
44
+ brief=f"HTTP {response.status} error",
45
+ )
46
+
47
+ html = await response.text()
48
+ except aiohttp.ClientError as e:
49
+ return builder.error(
50
+ (
51
+ f"Failed to fetch URL due to network error: {str(e)}. "
52
+ "This may indicate the URL is invalid or the server is unreachable."
53
+ ),
54
+ brief="Network error",
55
+ )
56
+
57
+ if not html:
58
+ return builder.ok(
59
+ "The response body is empty.",
60
+ brief="Empty response body",
61
+ )
62
+
63
+ extracted_text = trafilatura.extract(
64
+ html,
65
+ include_comments=True,
66
+ include_tables=True,
67
+ include_formatting=False,
68
+ output_format="txt",
69
+ with_metadata=True,
70
+ )
71
+
72
+ if not extracted_text:
73
+ return builder.error(
74
+ (
75
+ "Failed to extract meaningful content from the page. "
76
+ "This may indicate the page content is not suitable for text extraction, "
77
+ "or the page requires JavaScript to render its content."
78
+ ),
79
+ brief="No content extracted",
80
+ )
81
+
82
+ builder.write(extracted_text)
83
+ return builder.ok("The returned content is the main text content extracted from the page.")
84
+
85
+
86
+ if __name__ == "__main__":
87
+ import asyncio
88
+
89
+ async def main():
90
+ fetch_url_tool = FetchURL()
91
+ result = await fetch_url_tool(Params(url="https://trafilatura.readthedocs.io/en/latest/"))
92
+ print(result)
93
+
94
+ asyncio.run(main())
@@ -0,0 +1 @@
1
+ WebSearch tool allows you to search on the internet to get latest information, including news, documents, release notes, blog posts, papers, etc.
@@ -0,0 +1,126 @@
1
+ from pathlib import Path
2
+ from typing import override
3
+
4
+ import aiohttp
5
+ from kosong.tooling import CallableTool2, ToolReturnType
6
+ from pydantic import BaseModel, Field, ValidationError
7
+
8
+ import kimi_cli
9
+ from kimi_cli.config import Config
10
+ from kimi_cli.soul.toolset import get_current_tool_call_or_none
11
+ from kimi_cli.tools.utils import ToolResultBuilder, load_desc
12
+
13
+
14
+ class Params(BaseModel):
15
+ query: str = Field(description="The query text to search for.")
16
+ limit: int = Field(
17
+ description=(
18
+ "The number of results to return. "
19
+ "Typically you do not need to set this value. "
20
+ "When the results do not contain what you need, "
21
+ "you probably want to give a more concrete query."
22
+ ),
23
+ default=5,
24
+ ge=1,
25
+ le=20,
26
+ )
27
+ include_content: bool = Field(
28
+ description=(
29
+ "Whether to include the content of the web pages in the results. "
30
+ "It can consume a large amount of tokens when this is set to True. "
31
+ "You should avoid enabling this when `limit` is set to a large value."
32
+ ),
33
+ default=False,
34
+ )
35
+
36
+
37
+ class SearchWeb(CallableTool2[Params]):
38
+ name: str = "SearchWeb"
39
+ description: str = load_desc(Path(__file__).parent / "search.md", {})
40
+ params: type[Params] = Params
41
+
42
+ def __init__(self, config: Config, **kwargs):
43
+ super().__init__(**kwargs)
44
+ if config.services.moonshot_search is not None:
45
+ self._base_url = config.services.moonshot_search.base_url
46
+ self._api_key = config.services.moonshot_search.api_key.get_secret_value()
47
+ else:
48
+ self._base_url = ""
49
+ self._api_key = ""
50
+
51
+ @override
52
+ async def __call__(self, params: Params) -> ToolReturnType:
53
+ builder = ToolResultBuilder(max_line_length=None)
54
+
55
+ if not self._base_url or not self._api_key:
56
+ return builder.error(
57
+ "Search service is not configured. You may want to try other methods to search.",
58
+ brief="Search service not configured",
59
+ )
60
+
61
+ tool_call = get_current_tool_call_or_none()
62
+ assert tool_call is not None, "Tool call is expected to be set"
63
+
64
+ async with (
65
+ aiohttp.ClientSession() as session,
66
+ session.post(
67
+ self._base_url,
68
+ headers={
69
+ "User-Agent": kimi_cli.USER_AGENT,
70
+ "Authorization": f"Bearer {self._api_key}",
71
+ "X-Msh-Tool-Call-Id": tool_call.id,
72
+ },
73
+ json={
74
+ "text_query": params.query,
75
+ "limit": params.limit,
76
+ "enable_page_crawling": params.include_content,
77
+ "timeout_seconds": 30,
78
+ },
79
+ ) as response,
80
+ ):
81
+ if response.status != 200:
82
+ return builder.error(
83
+ (
84
+ f"Failed to search. Status: {response.status}. "
85
+ "This may indicates that the search service is currently unavailable."
86
+ ),
87
+ brief="Failed to search",
88
+ )
89
+
90
+ try:
91
+ results = Response(**await response.json()).search_results
92
+ except ValidationError as e:
93
+ return builder.error(
94
+ (
95
+ f"Failed to parse search results. Error: {e}. "
96
+ "This may indicates that the search service is currently unavailable."
97
+ ),
98
+ brief="Failed to parse search results",
99
+ )
100
+
101
+ for i, result in enumerate(results):
102
+ if i > 0:
103
+ builder.write("---\n\n")
104
+ builder.write(
105
+ f"Title: {result.title}\nDate: {result.date}\n"
106
+ f"URL: {result.url}\nSummary: {result.snippet}\n\n"
107
+ )
108
+ if result.content:
109
+ builder.write(f"{result.content}\n\n")
110
+
111
+ return builder.ok()
112
+
113
+
114
+ class SearchResult(BaseModel):
115
+ site_name: str
116
+ title: str
117
+ url: str
118
+ snippet: str
119
+ content: str = ""
120
+ date: str = ""
121
+ icon: str = ""
122
+ mime: str = ""
123
+
124
+
125
+ class Response(BaseModel):
126
+ search_results: list[SearchResult]
@@ -0,0 +1,68 @@
1
+ import asyncio
2
+ import contextlib
3
+ from collections.abc import Callable, Coroutine
4
+ from typing import Any
5
+
6
+ from kimi_cli.soul import Soul
7
+ from kimi_cli.soul.wire import Wire
8
+ from kimi_cli.utils.logging import logger
9
+
10
+ type UILoopFn = Callable[[Wire], Coroutine[Any, Any, None]]
11
+ """A long-running async function to visualize the agent behavior."""
12
+
13
+
14
+ class RunCancelled(Exception):
15
+ """The run was cancelled by the cancel event."""
16
+
17
+
18
+ async def run_soul(
19
+ soul: Soul,
20
+ user_input: str,
21
+ ui_loop_fn: UILoopFn,
22
+ cancel_event: asyncio.Event,
23
+ ):
24
+ """
25
+ Run the soul with the given user input.
26
+
27
+ `cancel_event` is a outside handle that can be used to cancel the run. When the event is set,
28
+ the run will be gracefully stopped and a `RunCancelled` will be raised.
29
+
30
+ Raises:
31
+ ChatProviderError: When the LLM provider returns an error.
32
+ MaxStepsReached: When the maximum number of steps is reached.
33
+ RunCancelled: When the run is cancelled by the cancel event.
34
+ """
35
+ wire = Wire()
36
+ logger.debug("Starting UI loop with function: {ui_loop_fn}", ui_loop_fn=ui_loop_fn)
37
+
38
+ ui_task = asyncio.create_task(ui_loop_fn(wire))
39
+ soul_task = asyncio.create_task(soul.run(user_input, wire))
40
+
41
+ cancel_event_task = asyncio.create_task(cancel_event.wait())
42
+ await asyncio.wait(
43
+ [soul_task, cancel_event_task],
44
+ return_when=asyncio.FIRST_COMPLETED,
45
+ )
46
+
47
+ try:
48
+ if cancel_event.is_set():
49
+ logger.debug("Cancelling the run task")
50
+ soul_task.cancel()
51
+ try:
52
+ await soul_task
53
+ except asyncio.CancelledError:
54
+ raise RunCancelled from None
55
+ else:
56
+ assert soul_task.done() # either stop event is set or the run task is done
57
+ cancel_event_task.cancel()
58
+ with contextlib.suppress(asyncio.CancelledError):
59
+ await cancel_event_task
60
+ soul_task.result() # this will raise if any exception was raised in the run task
61
+ finally:
62
+ logger.debug("Shutting down the visualization loop")
63
+ # shutting down the event queue should break the visualization loop
64
+ wire.shutdown()
65
+ try:
66
+ await asyncio.wait_for(ui_task, timeout=0.5)
67
+ except TimeoutError:
68
+ logger.warning("Visualization loop timed out")