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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Markdown Sample Document
|
|
2
|
+
|
|
3
|
+
This is a comprehensive sample document showcasing various Markdown elements.
|
|
4
|
+
|
|
5
|
+
## Level 2 Heading
|
|
6
|
+
|
|
7
|
+
### Level 3 Heading
|
|
8
|
+
|
|
9
|
+
Here's some regular text with **bold text**, *italic text*, and `inline code`.
|
|
10
|
+
|
|
11
|
+
## Lists
|
|
12
|
+
|
|
13
|
+
### Unordered List
|
|
14
|
+
|
|
15
|
+
- First item
|
|
16
|
+
- Second item
|
|
17
|
+
- Nested item 1
|
|
18
|
+
- Nested item 2
|
|
19
|
+
- Third item
|
|
20
|
+
|
|
21
|
+
### Ordered List
|
|
22
|
+
|
|
23
|
+
1. First step
|
|
24
|
+
2. Second step
|
|
25
|
+
1. Sub-step A
|
|
26
|
+
2. Sub-step B
|
|
27
|
+
3. Third step
|
|
28
|
+
|
|
29
|
+
### Mixed List
|
|
30
|
+
|
|
31
|
+
1. First item
|
|
32
|
+
- Sub-item with bullet
|
|
33
|
+
- Another sub-item
|
|
34
|
+
2. Second item
|
|
35
|
+
1. Numbered sub-item
|
|
36
|
+
2. Another numbered sub-item
|
|
37
|
+
|
|
38
|
+
## Links and References
|
|
39
|
+
|
|
40
|
+
Here's a [link to GitHub](https://github.com) and another [relative link](../README.md).
|
|
41
|
+
|
|
42
|
+
## Code Blocks
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
def hello_world():
|
|
46
|
+
"""A simple function to demonstrate code blocks."""
|
|
47
|
+
print("Hello, World!")
|
|
48
|
+
return 42
|
|
49
|
+
|
|
50
|
+
# Call the function
|
|
51
|
+
result = hello_world()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Bash example
|
|
56
|
+
echo "This is a bash script"
|
|
57
|
+
ls -la /tmp
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Blockquotes
|
|
61
|
+
|
|
62
|
+
> This is a blockquote.
|
|
63
|
+
> It can span multiple lines.
|
|
64
|
+
>
|
|
65
|
+
> > And it can be nested too!
|
|
66
|
+
|
|
67
|
+
## Tables
|
|
68
|
+
|
|
69
|
+
| Column 1 | Column 2 | Column 3 |
|
|
70
|
+
|----------|----------|----------|
|
|
71
|
+
| Cell 1 | Cell 2 | Cell 3 |
|
|
72
|
+
| Left | Center | Right |
|
|
73
|
+
| Foo | Bar | Baz |
|
|
74
|
+
|
|
75
|
+
## Horizontal Rules
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
Here's some text after a horizontal rule.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Inline Formatting
|
|
84
|
+
|
|
85
|
+
You can combine **bold and *italic*** text, or use `code` within paragraphs.
|
|
86
|
+
|
|
87
|
+
**Important**: Always test your `code` snippets before deployment.
|
|
88
|
+
|
|
89
|
+
## Advanced Features
|
|
90
|
+
|
|
91
|
+
### Task Lists
|
|
92
|
+
|
|
93
|
+
- [x] Completed task
|
|
94
|
+
- [ ] Pending task
|
|
95
|
+
- [ ] Another pending task
|
|
96
|
+
|
|
97
|
+
### Definition Lists
|
|
98
|
+
|
|
99
|
+
Term 1
|
|
100
|
+
: Definition of term 1
|
|
101
|
+
|
|
102
|
+
Term 2
|
|
103
|
+
: Definition of term 2
|
|
104
|
+
: Another definition for term 2
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
*This document demonstrates comprehensive Markdown formatting capabilities.*
|
kimi_cli/utils/signals.py
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import overload
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
8
|
+
class SlashCommand[F: Callable[..., None | Awaitable[None]]]:
|
|
9
|
+
name: str
|
|
10
|
+
description: str
|
|
11
|
+
func: F
|
|
12
|
+
aliases: list[str]
|
|
13
|
+
|
|
14
|
+
def slash_name(self):
|
|
15
|
+
"""/name (aliases)"""
|
|
16
|
+
if self.aliases:
|
|
17
|
+
return f"/{self.name} ({', '.join(self.aliases)})"
|
|
18
|
+
return f"/{self.name}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SlashCommandRegistry[F: Callable[..., None | Awaitable[None]]]:
|
|
22
|
+
"""Registry for slash commands."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._commands: dict[str, SlashCommand[F]] = {}
|
|
26
|
+
"""Primary name -> SlashCommand"""
|
|
27
|
+
self._command_aliases: dict[str, SlashCommand[F]] = {}
|
|
28
|
+
"""Primary name or alias -> SlashCommand"""
|
|
29
|
+
|
|
30
|
+
@overload
|
|
31
|
+
def command(self, func: F, /) -> F: ...
|
|
32
|
+
|
|
33
|
+
@overload
|
|
34
|
+
def command(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
name: str | None = None,
|
|
38
|
+
aliases: Sequence[str] | None = None,
|
|
39
|
+
) -> Callable[[F], F]: ...
|
|
40
|
+
|
|
41
|
+
def command(
|
|
42
|
+
self,
|
|
43
|
+
func: F | None = None,
|
|
44
|
+
*,
|
|
45
|
+
name: str | None = None,
|
|
46
|
+
aliases: Sequence[str] | None = None,
|
|
47
|
+
) -> F | Callable[[F], F]:
|
|
48
|
+
"""
|
|
49
|
+
Decorator to register a slash command with optional custom name and aliases.
|
|
50
|
+
|
|
51
|
+
Usage examples:
|
|
52
|
+
@registry.command
|
|
53
|
+
def help(app: App, args: str): ...
|
|
54
|
+
|
|
55
|
+
@registry.command(name="run")
|
|
56
|
+
def start(app: App, args: str): ...
|
|
57
|
+
|
|
58
|
+
@registry.command(aliases=["h", "?", "assist"])
|
|
59
|
+
def help(app: App, args: str): ...
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def _register(f: F) -> F:
|
|
63
|
+
primary = name or f.__name__
|
|
64
|
+
alias_list = list(aliases) if aliases else []
|
|
65
|
+
|
|
66
|
+
# Create the primary command with aliases
|
|
67
|
+
cmd = SlashCommand[F](
|
|
68
|
+
name=primary,
|
|
69
|
+
description=(f.__doc__ or "").strip(),
|
|
70
|
+
func=f,
|
|
71
|
+
aliases=alias_list,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Register primary command
|
|
75
|
+
self._commands[primary] = cmd
|
|
76
|
+
self._command_aliases[primary] = cmd
|
|
77
|
+
|
|
78
|
+
# Register aliases pointing to the same command
|
|
79
|
+
for alias in alias_list:
|
|
80
|
+
self._command_aliases[alias] = cmd
|
|
81
|
+
|
|
82
|
+
return f
|
|
83
|
+
|
|
84
|
+
if func is not None:
|
|
85
|
+
return _register(func)
|
|
86
|
+
return _register
|
|
87
|
+
|
|
88
|
+
def find_command(self, name: str) -> SlashCommand[F] | None:
|
|
89
|
+
return self._command_aliases.get(name)
|
|
90
|
+
|
|
91
|
+
def list_commands(self) -> list[SlashCommand[F]]:
|
|
92
|
+
"""Get all unique primary slash commands (without duplicating aliases)."""
|
|
93
|
+
return list(self._commands.values())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
97
|
+
class SlashCommandCall:
|
|
98
|
+
name: str
|
|
99
|
+
args: str
|
|
100
|
+
raw_input: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_slash_command_call(user_input: str) -> SlashCommandCall | None:
|
|
104
|
+
"""
|
|
105
|
+
Parse a slash command call from user input.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
SlashCommandCall if a slash command is found, else None. The `args` field contains
|
|
109
|
+
the raw argument string after the command name.
|
|
110
|
+
"""
|
|
111
|
+
user_input = user_input.strip()
|
|
112
|
+
if not user_input or not user_input.startswith("/"):
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
name_match = re.match(r"^\/([a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)*)", user_input)
|
|
116
|
+
|
|
117
|
+
if not name_match:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
command_name = name_match.group(1)
|
|
121
|
+
if len(user_input) > name_match.end() and not user_input[name_match.end()].isspace():
|
|
122
|
+
return None
|
|
123
|
+
raw_args = user_input[name_match.end() :].lstrip()
|
|
124
|
+
return SlashCommandCall(name=command_name, args=raw_args, raw_input=user_input)
|
kimi_cli/utils/string.py
CHANGED
kimi_cli/utils/term.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ensure_new_line() -> None:
|
|
11
|
+
"""Ensure the next prompt starts at column 0 regardless of prior command output."""
|
|
12
|
+
|
|
13
|
+
if not sys.stdout.isatty() or not sys.stdin.isatty():
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
needs_break = True
|
|
17
|
+
if sys.platform == "win32":
|
|
18
|
+
column = _cursor_column_windows()
|
|
19
|
+
needs_break = column not in (None, 0)
|
|
20
|
+
else:
|
|
21
|
+
column = _cursor_column_unix()
|
|
22
|
+
needs_break = column not in (None, 1)
|
|
23
|
+
|
|
24
|
+
if needs_break:
|
|
25
|
+
_write_newline()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_tty_sane() -> None:
|
|
29
|
+
"""Restore basic tty settings so Ctrl-C works after raw-mode operations."""
|
|
30
|
+
if sys.platform == "win32" or not sys.stdin.isatty():
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
import termios
|
|
35
|
+
except Exception:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
fd = sys.stdin.fileno()
|
|
40
|
+
attrs = termios.tcgetattr(fd)
|
|
41
|
+
except Exception:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
desired = termios.ISIG | termios.IEXTEN | termios.ICANON | termios.ECHO
|
|
45
|
+
if (attrs[3] & desired) == desired:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
attrs[3] |= desired
|
|
49
|
+
with contextlib.suppress(OSError):
|
|
50
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, attrs)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cursor_position_unix() -> tuple[int, int] | None:
|
|
54
|
+
"""Get cursor position (row, column) on Unix. Both are 1-indexed."""
|
|
55
|
+
assert sys.platform != "win32"
|
|
56
|
+
|
|
57
|
+
import select
|
|
58
|
+
import termios
|
|
59
|
+
import tty
|
|
60
|
+
|
|
61
|
+
_CURSOR_QUERY = "\x1b[6n"
|
|
62
|
+
_CURSOR_POSITION_RE = re.compile(r"\x1b\[(\d+);(\d+)R")
|
|
63
|
+
|
|
64
|
+
fd = sys.stdin.fileno()
|
|
65
|
+
oldterm = termios.tcgetattr(fd)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
tty.setcbreak(fd)
|
|
69
|
+
sys.stdout.write(_CURSOR_QUERY)
|
|
70
|
+
sys.stdout.flush()
|
|
71
|
+
|
|
72
|
+
response = ""
|
|
73
|
+
deadline = time.monotonic() + 0.2
|
|
74
|
+
while time.monotonic() < deadline:
|
|
75
|
+
timeout = max(0.01, deadline - time.monotonic())
|
|
76
|
+
ready, _, _ = select.select([sys.stdin], [], [], timeout)
|
|
77
|
+
if not ready:
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
chunk = os.read(fd, 32)
|
|
81
|
+
except OSError:
|
|
82
|
+
break
|
|
83
|
+
if not chunk:
|
|
84
|
+
break
|
|
85
|
+
response += chunk.decode(encoding="utf-8", errors="ignore")
|
|
86
|
+
match = _CURSOR_POSITION_RE.search(response)
|
|
87
|
+
if match:
|
|
88
|
+
return int(match.group(1)), int(match.group(2))
|
|
89
|
+
finally:
|
|
90
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, oldterm)
|
|
91
|
+
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _cursor_column_unix() -> int | None:
|
|
96
|
+
pos = _cursor_position_unix()
|
|
97
|
+
return pos[1] if pos else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cursor_position_windows() -> tuple[int, int] | None:
|
|
101
|
+
"""Get cursor position (row, column) on Windows. Both are 1-indexed."""
|
|
102
|
+
assert sys.platform == "win32"
|
|
103
|
+
|
|
104
|
+
import ctypes
|
|
105
|
+
from ctypes import wintypes
|
|
106
|
+
|
|
107
|
+
kernel32 = ctypes.windll.kernel32
|
|
108
|
+
_STD_OUTPUT_HANDLE = -11 # Windows API constant for standard output handle
|
|
109
|
+
handle = kernel32.GetStdHandle(_STD_OUTPUT_HANDLE)
|
|
110
|
+
invalid_handle_value = ctypes.c_void_p(-1).value
|
|
111
|
+
if handle in (0, invalid_handle_value):
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
class COORD(ctypes.Structure):
|
|
115
|
+
_fields_ = [("X", wintypes.SHORT), ("Y", wintypes.SHORT)]
|
|
116
|
+
|
|
117
|
+
class SMALL_RECT(ctypes.Structure):
|
|
118
|
+
_fields_ = [
|
|
119
|
+
("Left", wintypes.SHORT),
|
|
120
|
+
("Top", wintypes.SHORT),
|
|
121
|
+
("Right", wintypes.SHORT),
|
|
122
|
+
("Bottom", wintypes.SHORT),
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure):
|
|
126
|
+
_fields_ = [
|
|
127
|
+
("dwSize", COORD),
|
|
128
|
+
("dwCursorPosition", COORD),
|
|
129
|
+
("wAttributes", wintypes.WORD),
|
|
130
|
+
("srWindow", SMALL_RECT),
|
|
131
|
+
("dwMaximumWindowSize", COORD),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
csbi = CONSOLE_SCREEN_BUFFER_INFO()
|
|
135
|
+
if not kernel32.GetConsoleScreenBufferInfo(handle, ctypes.byref(csbi)):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
# Windows returns 0-indexed, convert to 1-indexed for consistency
|
|
139
|
+
return int(csbi.dwCursorPosition.Y) + 1, int(csbi.dwCursorPosition.X) + 1
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _cursor_column_windows() -> int | None:
|
|
143
|
+
pos = _cursor_position_windows()
|
|
144
|
+
return pos[1] if pos else None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _write_newline() -> None:
|
|
148
|
+
sys.stdout.write("\n")
|
|
149
|
+
sys.stdout.flush()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_cursor_row() -> int | None:
|
|
153
|
+
"""Get the current cursor row (1-indexed)."""
|
|
154
|
+
if not sys.stdout.isatty() or not sys.stdin.isatty():
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
if sys.platform == "win32":
|
|
158
|
+
pos = _cursor_position_windows()
|
|
159
|
+
else:
|
|
160
|
+
pos = _cursor_position_unix()
|
|
161
|
+
|
|
162
|
+
return pos[0] if pos else None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
print("test", end="", flush=True)
|
|
167
|
+
ensure_new_line()
|
|
168
|
+
print("next line")
|
kimi_cli/utils/typing.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from types import UnionType
|
|
2
|
+
from typing import Any, TypeAliasType, Union, get_args, get_origin
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def flatten_union(tp: Any) -> tuple[Any, ...]:
|
|
6
|
+
"""
|
|
7
|
+
If `tp` is a `UnionType`, return its flattened arguments as a tuple.
|
|
8
|
+
Otherwise, return a tuple with `tp` as the only element.
|
|
9
|
+
"""
|
|
10
|
+
if isinstance(tp, TypeAliasType):
|
|
11
|
+
tp = tp.__value__
|
|
12
|
+
origin = get_origin(tp)
|
|
13
|
+
if origin in (UnionType, Union):
|
|
14
|
+
args = get_args(tp)
|
|
15
|
+
flattened_args: list[Any] = []
|
|
16
|
+
for arg in args:
|
|
17
|
+
flattened_args.extend(flatten_union(arg))
|
|
18
|
+
return tuple(flattened_args)
|
|
19
|
+
else:
|
|
20
|
+
return (tp,)
|
kimi_cli/wire/__init__.py
CHANGED
|
@@ -1,54 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import asyncio
|
|
4
|
+
import copy
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
2
8
|
|
|
3
|
-
|
|
9
|
+
import aiofiles
|
|
10
|
+
from kosong.message import MergeableMixin
|
|
4
11
|
|
|
12
|
+
from kimi_cli.utils.aioqueue import Queue, QueueShutDown
|
|
13
|
+
from kimi_cli.utils.broadcast import BroadcastQueue
|
|
5
14
|
from kimi_cli.utils.logging import logger
|
|
6
|
-
from kimi_cli.wire.
|
|
15
|
+
from kimi_cli.wire.serde import WireMessageRecord
|
|
16
|
+
from kimi_cli.wire.types import ContentPart, ToolCallPart, WireMessage, is_wire_message
|
|
17
|
+
|
|
18
|
+
WireMessageQueue = BroadcastQueue[WireMessage]
|
|
7
19
|
|
|
8
20
|
|
|
9
21
|
class Wire:
|
|
10
22
|
"""
|
|
11
|
-
A channel for communication between the soul and the UI during a soul run.
|
|
23
|
+
A spmc channel for communication between the soul and the UI during a soul run.
|
|
12
24
|
"""
|
|
13
25
|
|
|
14
|
-
def __init__(self):
|
|
15
|
-
self.
|
|
16
|
-
self.
|
|
17
|
-
|
|
26
|
+
def __init__(self, *, file_backend: Path | None = None):
|
|
27
|
+
self._raw_queue = WireMessageQueue()
|
|
28
|
+
self._merged_queue = WireMessageQueue()
|
|
29
|
+
|
|
30
|
+
self._soul_side = WireSoulSide(self._raw_queue, self._merged_queue)
|
|
31
|
+
|
|
32
|
+
if file_backend is not None:
|
|
33
|
+
# record all complete Wire messages to the file backend
|
|
34
|
+
self._recorder = _WireRecorder(file_backend, self._merged_queue.subscribe())
|
|
35
|
+
else:
|
|
36
|
+
self._recorder = None
|
|
18
37
|
|
|
19
38
|
@property
|
|
20
|
-
def soul_side(self) ->
|
|
39
|
+
def soul_side(self) -> WireSoulSide:
|
|
21
40
|
return self._soul_side
|
|
22
41
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
42
|
+
def ui_side(self, *, merge: bool) -> WireUISide:
|
|
43
|
+
"""
|
|
44
|
+
Create a UI side of the `Wire`.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
merge: Whether to merge `Wire` messages as much as possible.
|
|
48
|
+
"""
|
|
49
|
+
if merge:
|
|
50
|
+
return WireUISide(self._merged_queue.subscribe())
|
|
51
|
+
else:
|
|
52
|
+
return WireUISide(self._raw_queue.subscribe())
|
|
26
53
|
|
|
27
54
|
def shutdown(self) -> None:
|
|
55
|
+
self.soul_side.flush()
|
|
28
56
|
logger.debug("Shutting down wire")
|
|
29
|
-
self.
|
|
57
|
+
self._raw_queue.shutdown()
|
|
58
|
+
self._merged_queue.shutdown()
|
|
30
59
|
|
|
31
60
|
|
|
32
61
|
class WireSoulSide:
|
|
33
62
|
"""
|
|
34
|
-
The soul side of a
|
|
63
|
+
The soul side of a `Wire`.
|
|
35
64
|
"""
|
|
36
65
|
|
|
37
|
-
def __init__(self,
|
|
38
|
-
self.
|
|
66
|
+
def __init__(self, raw_queue: WireMessageQueue, merged_queue: WireMessageQueue):
|
|
67
|
+
self._raw_queue = raw_queue
|
|
68
|
+
self._merged_queue = merged_queue
|
|
69
|
+
self._merge_buffer: MergeableMixin | None = None
|
|
39
70
|
|
|
40
71
|
def send(self, msg: WireMessage) -> None:
|
|
41
72
|
if not isinstance(msg, ContentPart | ToolCallPart):
|
|
42
73
|
logger.debug("Sending wire message: {msg}", msg=msg)
|
|
43
|
-
|
|
74
|
+
|
|
75
|
+
# send raw message
|
|
76
|
+
try:
|
|
77
|
+
self._raw_queue.publish_nowait(msg)
|
|
78
|
+
except QueueShutDown:
|
|
79
|
+
logger.info("Failed to send raw wire message, queue is shut down: {msg}", msg=msg)
|
|
80
|
+
|
|
81
|
+
# merge and send merged message
|
|
82
|
+
match msg:
|
|
83
|
+
case MergeableMixin():
|
|
84
|
+
if self._merge_buffer is None:
|
|
85
|
+
self._merge_buffer = copy.deepcopy(msg)
|
|
86
|
+
elif self._merge_buffer.merge_in_place(msg):
|
|
87
|
+
pass
|
|
88
|
+
else:
|
|
89
|
+
self.flush()
|
|
90
|
+
self._merge_buffer = copy.deepcopy(msg)
|
|
91
|
+
case _:
|
|
92
|
+
self.flush()
|
|
93
|
+
self._send_merged(msg)
|
|
94
|
+
|
|
95
|
+
def flush(self) -> None:
|
|
96
|
+
if self._merge_buffer is not None:
|
|
97
|
+
assert is_wire_message(self._merge_buffer)
|
|
98
|
+
self._send_merged(self._merge_buffer)
|
|
99
|
+
self._merge_buffer = None
|
|
100
|
+
|
|
101
|
+
def _send_merged(self, msg: WireMessage) -> None:
|
|
102
|
+
try:
|
|
103
|
+
self._merged_queue.publish_nowait(msg)
|
|
104
|
+
except QueueShutDown:
|
|
105
|
+
logger.info("Failed to send merged wire message, queue is shut down: {msg}", msg=msg)
|
|
44
106
|
|
|
45
107
|
|
|
46
108
|
class WireUISide:
|
|
47
109
|
"""
|
|
48
|
-
The UI side of a
|
|
110
|
+
The UI side of a `Wire`.
|
|
49
111
|
"""
|
|
50
112
|
|
|
51
|
-
def __init__(self, queue:
|
|
113
|
+
def __init__(self, queue: Queue[WireMessage]):
|
|
52
114
|
self._queue = queue
|
|
53
115
|
|
|
54
116
|
async def receive(self) -> WireMessage:
|
|
@@ -57,14 +119,21 @@ class WireUISide:
|
|
|
57
119
|
logger.debug("Receiving wire message: {msg}", msg=msg)
|
|
58
120
|
return msg
|
|
59
121
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
122
|
+
|
|
123
|
+
class _WireRecorder:
|
|
124
|
+
def __init__(self, file_backend: Path, queue: Queue[WireMessage]) -> None:
|
|
125
|
+
self._file_backend = file_backend
|
|
126
|
+
self._task = asyncio.create_task(self._consume_loop(queue))
|
|
127
|
+
|
|
128
|
+
async def _consume_loop(self, queue: Queue[WireMessage]) -> None:
|
|
129
|
+
while True:
|
|
130
|
+
try:
|
|
131
|
+
msg = await queue.get()
|
|
132
|
+
await self._record(msg)
|
|
133
|
+
except QueueShutDown:
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
async def _record(self, msg: WireMessage) -> None:
|
|
137
|
+
record = WireMessageRecord.from_wire_message(msg, timestamp=time.time())
|
|
138
|
+
async with aiofiles.open(self._file_backend, mode="a", encoding="utf-8") as f:
|
|
139
|
+
await f.write(json.dumps(record.model_dump(mode="json"), ensure_ascii=False) + "\n")
|
kimi_cli/wire/serde.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from kosong.utils.typing import JsonType
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
|
|
8
|
+
from kimi_cli.wire.types import WireMessage, WireMessageEnvelope
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def serialize_wire_message(msg: WireMessage) -> dict[str, JsonType]:
|
|
12
|
+
"""
|
|
13
|
+
Convert a `WireMessage` into a jsonifiable dict.
|
|
14
|
+
"""
|
|
15
|
+
envelope = WireMessageEnvelope.from_wire_message(msg)
|
|
16
|
+
return envelope.model_dump(mode="json")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def deserialize_wire_message(data: dict[str, JsonType] | Any) -> WireMessage:
|
|
20
|
+
"""
|
|
21
|
+
Convert a jsonifiable dict into a `WireMessage`.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ValueError: If the message type is unknown or the payload is invalid.
|
|
25
|
+
"""
|
|
26
|
+
envelope = WireMessageEnvelope.model_validate(data)
|
|
27
|
+
return envelope.to_wire_message()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WireMessageRecord(BaseModel):
|
|
31
|
+
"""
|
|
32
|
+
The persisted record of a `WireMessage`.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(extra="ignore")
|
|
36
|
+
|
|
37
|
+
timestamp: float
|
|
38
|
+
message: WireMessageEnvelope
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_wire_message(cls, msg: WireMessage, *, timestamp: float) -> WireMessageRecord:
|
|
42
|
+
return cls(timestamp=timestamp, message=WireMessageEnvelope.from_wire_message(msg))
|
|
43
|
+
|
|
44
|
+
def to_wire_message(self) -> WireMessage:
|
|
45
|
+
return self.message.to_wire_message()
|