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.

Files changed (137) hide show
  1. kimi_cli/CHANGELOG.md +349 -40
  2. kimi_cli/__init__.py +6 -0
  3. kimi_cli/acp/AGENTS.md +91 -0
  4. kimi_cli/acp/__init__.py +13 -0
  5. kimi_cli/acp/convert.py +111 -0
  6. kimi_cli/acp/kaos.py +270 -0
  7. kimi_cli/acp/mcp.py +46 -0
  8. kimi_cli/acp/server.py +335 -0
  9. kimi_cli/acp/session.py +445 -0
  10. kimi_cli/acp/tools.py +158 -0
  11. kimi_cli/acp/types.py +13 -0
  12. kimi_cli/agents/default/agent.yaml +4 -4
  13. kimi_cli/agents/default/sub.yaml +2 -1
  14. kimi_cli/agents/default/system.md +79 -21
  15. kimi_cli/agents/okabe/agent.yaml +17 -0
  16. kimi_cli/agentspec.py +53 -25
  17. kimi_cli/app.py +180 -52
  18. kimi_cli/cli/__init__.py +595 -0
  19. kimi_cli/cli/__main__.py +8 -0
  20. kimi_cli/cli/info.py +63 -0
  21. kimi_cli/cli/mcp.py +349 -0
  22. kimi_cli/config.py +153 -17
  23. kimi_cli/constant.py +3 -0
  24. kimi_cli/exception.py +23 -2
  25. kimi_cli/flow/__init__.py +117 -0
  26. kimi_cli/flow/d2.py +376 -0
  27. kimi_cli/flow/mermaid.py +218 -0
  28. kimi_cli/llm.py +129 -23
  29. kimi_cli/metadata.py +32 -7
  30. kimi_cli/platforms.py +262 -0
  31. kimi_cli/prompts/__init__.py +2 -0
  32. kimi_cli/prompts/compact.md +4 -5
  33. kimi_cli/session.py +223 -31
  34. kimi_cli/share.py +2 -0
  35. kimi_cli/skill.py +145 -0
  36. kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
  37. kimi_cli/skills/skill-creator/SKILL.md +351 -0
  38. kimi_cli/soul/__init__.py +51 -20
  39. kimi_cli/soul/agent.py +213 -85
  40. kimi_cli/soul/approval.py +86 -17
  41. kimi_cli/soul/compaction.py +64 -53
  42. kimi_cli/soul/context.py +38 -5
  43. kimi_cli/soul/denwarenji.py +2 -0
  44. kimi_cli/soul/kimisoul.py +442 -60
  45. kimi_cli/soul/message.py +54 -54
  46. kimi_cli/soul/slash.py +72 -0
  47. kimi_cli/soul/toolset.py +387 -6
  48. kimi_cli/toad.py +74 -0
  49. kimi_cli/tools/AGENTS.md +5 -0
  50. kimi_cli/tools/__init__.py +42 -34
  51. kimi_cli/tools/display.py +25 -0
  52. kimi_cli/tools/dmail/__init__.py +10 -10
  53. kimi_cli/tools/dmail/dmail.md +11 -9
  54. kimi_cli/tools/file/__init__.py +1 -3
  55. kimi_cli/tools/file/glob.py +20 -23
  56. kimi_cli/tools/file/grep.md +1 -1
  57. kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
  58. kimi_cli/tools/file/read.md +24 -6
  59. kimi_cli/tools/file/read.py +134 -50
  60. kimi_cli/tools/file/replace.md +1 -1
  61. kimi_cli/tools/file/replace.py +36 -29
  62. kimi_cli/tools/file/utils.py +282 -0
  63. kimi_cli/tools/file/write.py +43 -22
  64. kimi_cli/tools/multiagent/__init__.py +7 -0
  65. kimi_cli/tools/multiagent/create.md +11 -0
  66. kimi_cli/tools/multiagent/create.py +50 -0
  67. kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
  68. kimi_cli/tools/shell/__init__.py +120 -0
  69. kimi_cli/tools/{bash → shell}/bash.md +1 -2
  70. kimi_cli/tools/shell/powershell.md +25 -0
  71. kimi_cli/tools/test.py +4 -4
  72. kimi_cli/tools/think/__init__.py +2 -2
  73. kimi_cli/tools/todo/__init__.py +14 -8
  74. kimi_cli/tools/utils.py +64 -24
  75. kimi_cli/tools/web/fetch.py +68 -13
  76. kimi_cli/tools/web/search.py +10 -12
  77. kimi_cli/ui/acp/__init__.py +65 -412
  78. kimi_cli/ui/print/__init__.py +37 -49
  79. kimi_cli/ui/print/visualize.py +179 -0
  80. kimi_cli/ui/shell/__init__.py +141 -84
  81. kimi_cli/ui/shell/console.py +2 -0
  82. kimi_cli/ui/shell/debug.py +28 -23
  83. kimi_cli/ui/shell/keyboard.py +5 -1
  84. kimi_cli/ui/shell/prompt.py +220 -194
  85. kimi_cli/ui/shell/replay.py +111 -46
  86. kimi_cli/ui/shell/setup.py +89 -82
  87. kimi_cli/ui/shell/slash.py +422 -0
  88. kimi_cli/ui/shell/update.py +4 -2
  89. kimi_cli/ui/shell/usage.py +271 -0
  90. kimi_cli/ui/shell/visualize.py +574 -72
  91. kimi_cli/ui/wire/__init__.py +267 -0
  92. kimi_cli/ui/wire/jsonrpc.py +142 -0
  93. kimi_cli/ui/wire/protocol.py +1 -0
  94. kimi_cli/utils/__init__.py +0 -0
  95. kimi_cli/utils/aiohttp.py +2 -0
  96. kimi_cli/utils/aioqueue.py +72 -0
  97. kimi_cli/utils/broadcast.py +37 -0
  98. kimi_cli/utils/changelog.py +12 -7
  99. kimi_cli/utils/clipboard.py +12 -0
  100. kimi_cli/utils/datetime.py +37 -0
  101. kimi_cli/utils/environment.py +58 -0
  102. kimi_cli/utils/envvar.py +12 -0
  103. kimi_cli/utils/frontmatter.py +44 -0
  104. kimi_cli/utils/logging.py +7 -6
  105. kimi_cli/utils/message.py +9 -14
  106. kimi_cli/utils/path.py +99 -9
  107. kimi_cli/utils/pyinstaller.py +6 -0
  108. kimi_cli/utils/rich/__init__.py +33 -0
  109. kimi_cli/utils/rich/columns.py +99 -0
  110. kimi_cli/utils/rich/markdown.py +961 -0
  111. kimi_cli/utils/rich/markdown_sample.md +108 -0
  112. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  113. kimi_cli/utils/signals.py +2 -0
  114. kimi_cli/utils/slashcmd.py +124 -0
  115. kimi_cli/utils/string.py +2 -0
  116. kimi_cli/utils/term.py +168 -0
  117. kimi_cli/utils/typing.py +20 -0
  118. kimi_cli/wire/__init__.py +98 -29
  119. kimi_cli/wire/serde.py +45 -0
  120. kimi_cli/wire/types.py +299 -0
  121. kimi_cli-0.78.dist-info/METADATA +200 -0
  122. kimi_cli-0.78.dist-info/RECORD +135 -0
  123. kimi_cli-0.78.dist-info/entry_points.txt +4 -0
  124. kimi_cli/cli.py +0 -250
  125. kimi_cli/soul/runtime.py +0 -96
  126. kimi_cli/tools/bash/__init__.py +0 -99
  127. kimi_cli/tools/file/patch.md +0 -8
  128. kimi_cli/tools/file/patch.py +0 -143
  129. kimi_cli/tools/mcp.py +0 -85
  130. kimi_cli/ui/shell/liveview.py +0 -386
  131. kimi_cli/ui/shell/metacmd.py +0 -262
  132. kimi_cli/wire/message.py +0 -91
  133. kimi_cli-0.44.dist-info/METADATA +0 -188
  134. kimi_cli-0.44.dist-info/RECORD +0 -89
  135. kimi_cli-0.44.dist-info/entry_points.txt +0 -3
  136. /kimi_cli/tools/{task → multiagent}/task.md +0 -0
  137. {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.*
@@ -0,0 +1,2 @@
1
+ - First
2
+ - Second
kimi_cli/utils/signals.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
  import contextlib
3
5
  import signal
@@ -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
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import random
2
4
  import re
3
5
  import string
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")
@@ -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
- from kosong.base.message import ContentPart, ToolCallPart
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.message import WireMessage
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._queue = asyncio.Queue[WireMessage]()
16
- self._soul_side = WireSoulSide(self._queue)
17
- self._ui_side = WireUISide(self._queue)
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) -> "WireSoulSide":
39
+ def soul_side(self) -> WireSoulSide:
21
40
  return self._soul_side
22
41
 
23
- @property
24
- def ui_side(self) -> "WireUISide":
25
- return self._ui_side
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._queue.shutdown()
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 wire.
63
+ The soul side of a `Wire`.
35
64
  """
36
65
 
37
- def __init__(self, queue: asyncio.Queue[WireMessage]):
38
- self._queue = queue
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
- self._queue.put_nowait(msg)
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 wire.
110
+ The UI side of a `Wire`.
49
111
  """
50
112
 
51
- def __init__(self, queue: asyncio.Queue[WireMessage]):
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
- def receive_nowait(self) -> WireMessage | None:
61
- """
62
- Try receive a message without waiting. If no message is available, return None.
63
- """
64
- try:
65
- msg = self._queue.get_nowait()
66
- except asyncio.QueueEmpty:
67
- return None
68
- if not isinstance(msg, ContentPart | ToolCallPart):
69
- logger.debug("Receiving wire message: {msg}", msg=msg)
70
- return msg
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()