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