klaude-code 1.2.21__py3-none-any.whl → 1.2.22__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.
- klaude_code/cli/debug.py +8 -10
- klaude_code/command/__init__.py +0 -3
- klaude_code/command/prompt-deslop.md +1 -1
- klaude_code/const/__init__.py +2 -5
- klaude_code/core/prompt.py +5 -2
- klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
- klaude_code/core/prompts/{prompt-codex-gpt-5-1.md → prompt-codex.md} +9 -42
- klaude_code/core/reminders.py +36 -2
- klaude_code/core/tool/__init__.py +0 -5
- klaude_code/core/tool/file/_utils.py +6 -0
- klaude_code/core/tool/file/apply_patch_tool.py +30 -72
- klaude_code/core/tool/file/diff_builder.py +151 -0
- klaude_code/core/tool/file/edit_tool.py +35 -18
- klaude_code/core/tool/file/read_tool.py +45 -86
- klaude_code/core/tool/file/write_tool.py +40 -30
- klaude_code/core/tool/shell/bash_tool.py +147 -0
- klaude_code/protocol/commands.py +0 -1
- klaude_code/protocol/model.py +29 -10
- klaude_code/protocol/tools.py +1 -2
- klaude_code/session/export.py +75 -20
- klaude_code/session/templates/export_session.html +28 -0
- klaude_code/ui/renderers/common.py +26 -11
- klaude_code/ui/renderers/developer.py +0 -5
- klaude_code/ui/renderers/diffs.py +84 -0
- klaude_code/ui/renderers/tools.py +19 -98
- klaude_code/ui/rich/markdown.py +11 -1
- klaude_code/ui/rich/status.py +8 -11
- klaude_code/ui/rich/theme.py +14 -4
- {klaude_code-1.2.21.dist-info → klaude_code-1.2.22.dist-info}/METADATA +2 -1
- {klaude_code-1.2.21.dist-info → klaude_code-1.2.22.dist-info}/RECORD +32 -35
- klaude_code/command/diff_cmd.py +0 -136
- klaude_code/core/tool/file/multi_edit_tool.md +0 -42
- klaude_code/core/tool/file/multi_edit_tool.py +0 -175
- klaude_code/core/tool/memory/memory_tool.md +0 -20
- klaude_code/core/tool/memory/memory_tool.py +0 -456
- {klaude_code-1.2.21.dist-info → klaude_code-1.2.22.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.21.dist-info → klaude_code-1.2.22.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
from diff_match_patch import diff_match_patch # type: ignore[import-untyped]
|
|
7
|
+
|
|
8
|
+
from klaude_code.protocol import model
|
|
9
|
+
|
|
10
|
+
_MAX_LINE_LENGTH_FOR_CHAR_DIFF = 2000
|
|
11
|
+
_DEFAULT_CONTEXT_LINES = 3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_structured_diff(before: str, after: str, *, file_path: str) -> model.DiffUIExtra:
|
|
15
|
+
"""Build a structured diff with char-level spans for a single file."""
|
|
16
|
+
file_diff = _build_file_diff(before, after, file_path=file_path)
|
|
17
|
+
return model.DiffUIExtra(files=[file_diff])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_structured_file_diff(before: str, after: str, *, file_path: str) -> model.DiffFileDiff:
|
|
21
|
+
"""Build a structured diff for a single file."""
|
|
22
|
+
return _build_file_diff(before, after, file_path=file_path)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_file_diff(before: str, after: str, *, file_path: str) -> model.DiffFileDiff:
|
|
26
|
+
before_lines = _split_lines(before)
|
|
27
|
+
after_lines = _split_lines(after)
|
|
28
|
+
|
|
29
|
+
matcher = difflib.SequenceMatcher(None, before_lines, after_lines)
|
|
30
|
+
lines: list[model.DiffLine] = []
|
|
31
|
+
stats_add = 0
|
|
32
|
+
stats_remove = 0
|
|
33
|
+
|
|
34
|
+
grouped_opcodes = matcher.get_grouped_opcodes(n=_DEFAULT_CONTEXT_LINES)
|
|
35
|
+
for group_idx, group in enumerate(grouped_opcodes):
|
|
36
|
+
if group_idx > 0:
|
|
37
|
+
lines.append(_gap_line())
|
|
38
|
+
|
|
39
|
+
# Anchor line numbers to the actual start of the displayed hunk in the "after" file.
|
|
40
|
+
new_line_no = group[0][3] + 1
|
|
41
|
+
|
|
42
|
+
for tag, i1, i2, j1, j2 in group:
|
|
43
|
+
if tag == "equal":
|
|
44
|
+
for line in after_lines[j1:j2]:
|
|
45
|
+
lines.append(_ctx_line(line, new_line_no))
|
|
46
|
+
new_line_no += 1
|
|
47
|
+
elif tag == "delete":
|
|
48
|
+
for line in before_lines[i1:i2]:
|
|
49
|
+
lines.append(_remove_line([model.DiffSpan(op="equal", text=line)]))
|
|
50
|
+
stats_remove += 1
|
|
51
|
+
elif tag == "insert":
|
|
52
|
+
for line in after_lines[j1:j2]:
|
|
53
|
+
lines.append(_add_line([model.DiffSpan(op="equal", text=line)], new_line_no))
|
|
54
|
+
stats_add += 1
|
|
55
|
+
new_line_no += 1
|
|
56
|
+
elif tag == "replace":
|
|
57
|
+
old_block = before_lines[i1:i2]
|
|
58
|
+
new_block = after_lines[j1:j2]
|
|
59
|
+
max_len = max(len(old_block), len(new_block))
|
|
60
|
+
for idx in range(max_len):
|
|
61
|
+
old_line = old_block[idx] if idx < len(old_block) else None
|
|
62
|
+
new_line = new_block[idx] if idx < len(new_block) else None
|
|
63
|
+
if old_line is not None and new_line is not None:
|
|
64
|
+
remove_spans, add_spans = _diff_line_spans(old_line, new_line)
|
|
65
|
+
lines.append(_remove_line(remove_spans))
|
|
66
|
+
lines.append(_add_line(add_spans, new_line_no))
|
|
67
|
+
stats_remove += 1
|
|
68
|
+
stats_add += 1
|
|
69
|
+
new_line_no += 1
|
|
70
|
+
elif old_line is not None:
|
|
71
|
+
lines.append(_remove_line([model.DiffSpan(op="equal", text=old_line)]))
|
|
72
|
+
stats_remove += 1
|
|
73
|
+
elif new_line is not None:
|
|
74
|
+
lines.append(_add_line([model.DiffSpan(op="equal", text=new_line)], new_line_no))
|
|
75
|
+
stats_add += 1
|
|
76
|
+
new_line_no += 1
|
|
77
|
+
|
|
78
|
+
return model.DiffFileDiff(
|
|
79
|
+
file_path=file_path,
|
|
80
|
+
lines=lines,
|
|
81
|
+
stats_add=stats_add,
|
|
82
|
+
stats_remove=stats_remove,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _split_lines(text: str) -> list[str]:
|
|
87
|
+
if not text:
|
|
88
|
+
return []
|
|
89
|
+
return text.splitlines()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _ctx_line(text: str, new_line_no: int) -> model.DiffLine:
|
|
93
|
+
return model.DiffLine(
|
|
94
|
+
kind="ctx",
|
|
95
|
+
new_line_no=new_line_no,
|
|
96
|
+
spans=[model.DiffSpan(op="equal", text=text)],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _gap_line() -> model.DiffLine:
|
|
101
|
+
return model.DiffLine(
|
|
102
|
+
kind="gap",
|
|
103
|
+
new_line_no=None,
|
|
104
|
+
spans=[model.DiffSpan(op="equal", text="")],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _add_line(spans: list[model.DiffSpan], new_line_no: int) -> model.DiffLine:
|
|
109
|
+
return model.DiffLine(kind="add", new_line_no=new_line_no, spans=_ensure_spans(spans))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _remove_line(spans: list[model.DiffSpan]) -> model.DiffLine:
|
|
113
|
+
return model.DiffLine(kind="remove", new_line_no=None, spans=_ensure_spans(spans))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ensure_spans(spans: list[model.DiffSpan]) -> list[model.DiffSpan]:
|
|
117
|
+
if spans:
|
|
118
|
+
return spans
|
|
119
|
+
return [model.DiffSpan(op="equal", text="")]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _diff_line_spans(old_line: str, new_line: str) -> tuple[list[model.DiffSpan], list[model.DiffSpan]]:
|
|
123
|
+
if not _should_char_diff(old_line, new_line):
|
|
124
|
+
return (
|
|
125
|
+
[model.DiffSpan(op="equal", text=old_line)],
|
|
126
|
+
[model.DiffSpan(op="equal", text=new_line)],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
differ = diff_match_patch()
|
|
130
|
+
diffs = cast(list[tuple[int, str]], differ.diff_main(old_line, new_line)) # type: ignore[no-untyped-call]
|
|
131
|
+
differ.diff_cleanupSemantic(diffs) # type: ignore[no-untyped-call]
|
|
132
|
+
|
|
133
|
+
remove_spans: list[model.DiffSpan] = []
|
|
134
|
+
add_spans: list[model.DiffSpan] = []
|
|
135
|
+
|
|
136
|
+
for op, text in diffs:
|
|
137
|
+
if not text:
|
|
138
|
+
continue
|
|
139
|
+
if op == diff_match_patch.DIFF_EQUAL: # type: ignore[no-untyped-call]
|
|
140
|
+
remove_spans.append(model.DiffSpan(op="equal", text=text))
|
|
141
|
+
add_spans.append(model.DiffSpan(op="equal", text=text))
|
|
142
|
+
elif op == diff_match_patch.DIFF_DELETE: # type: ignore[no-untyped-call]
|
|
143
|
+
remove_spans.append(model.DiffSpan(op="delete", text=text))
|
|
144
|
+
elif op == diff_match_patch.DIFF_INSERT: # type: ignore[no-untyped-call]
|
|
145
|
+
add_spans.append(model.DiffSpan(op="insert", text=text))
|
|
146
|
+
|
|
147
|
+
return _ensure_spans(remove_spans), _ensure_spans(add_spans)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _should_char_diff(old_line: str, new_line: str) -> bool:
|
|
151
|
+
return len(old_line) <= _MAX_LINE_LENGTH_FOR_CHAR_DIFF and len(new_line) <= _MAX_LINE_LENGTH_FOR_CHAR_DIFF
|
|
@@ -8,7 +8,8 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel, Field
|
|
10
10
|
|
|
11
|
-
from klaude_code.core.tool.file._utils import file_exists, is_directory, read_text, write_text
|
|
11
|
+
from klaude_code.core.tool.file._utils import file_exists, hash_text_sha256, is_directory, read_text, write_text
|
|
12
|
+
from klaude_code.core.tool.file.diff_builder import build_structured_diff
|
|
12
13
|
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
13
14
|
from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
14
15
|
from klaude_code.core.tool.tool_registry import register
|
|
@@ -55,7 +56,6 @@ class EditTool(ToolABC):
|
|
|
55
56
|
},
|
|
56
57
|
)
|
|
57
58
|
|
|
58
|
-
# Validation utility for MultiEdit integration
|
|
59
59
|
@classmethod
|
|
60
60
|
def valid(
|
|
61
61
|
cls, *, content: str, old_string: str, new_string: str, replace_all: bool
|
|
@@ -74,7 +74,6 @@ class EditTool(ToolABC):
|
|
|
74
74
|
)
|
|
75
75
|
return None
|
|
76
76
|
|
|
77
|
-
# Execute utility for MultiEdit integration
|
|
78
77
|
@classmethod
|
|
79
78
|
def execute(cls, *, content: str, old_string: str, new_string: str, replace_all: bool) -> str:
|
|
80
79
|
if old_string == "":
|
|
@@ -112,6 +111,7 @@ class EditTool(ToolABC):
|
|
|
112
111
|
|
|
113
112
|
# FileTracker checks (only for editing existing files)
|
|
114
113
|
file_tracker = get_current_file_tracker()
|
|
114
|
+
tracked_status: model.FileStatus | None = None
|
|
115
115
|
if not file_exists(file_path):
|
|
116
116
|
# We require reading before editing
|
|
117
117
|
return model.ToolResultItem(
|
|
@@ -125,17 +125,6 @@ class EditTool(ToolABC):
|
|
|
125
125
|
status="error",
|
|
126
126
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
127
127
|
)
|
|
128
|
-
try:
|
|
129
|
-
current_mtime = Path(file_path).stat().st_mtime
|
|
130
|
-
except Exception:
|
|
131
|
-
current_mtime = tracked_status.mtime
|
|
132
|
-
if current_mtime != tracked_status.mtime:
|
|
133
|
-
return model.ToolResultItem(
|
|
134
|
-
status="error",
|
|
135
|
-
output=(
|
|
136
|
-
"File has been modified externally. Either by user or a linter. Read it first before writing to it."
|
|
137
|
-
),
|
|
138
|
-
)
|
|
139
128
|
|
|
140
129
|
# Edit existing file: validate and apply
|
|
141
130
|
try:
|
|
@@ -146,6 +135,31 @@ class EditTool(ToolABC):
|
|
|
146
135
|
output="File has not been read yet. Read it first before writing to it.",
|
|
147
136
|
)
|
|
148
137
|
|
|
138
|
+
# Re-check external modifications using content hash when available.
|
|
139
|
+
if tracked_status is not None:
|
|
140
|
+
if tracked_status.content_sha256 is not None:
|
|
141
|
+
current_sha256 = hash_text_sha256(before)
|
|
142
|
+
if current_sha256 != tracked_status.content_sha256:
|
|
143
|
+
return model.ToolResultItem(
|
|
144
|
+
status="error",
|
|
145
|
+
output=(
|
|
146
|
+
"File has been modified externally. Either by user or a linter. Read it first before writing to it."
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
# Backward-compat: old sessions only stored mtime.
|
|
151
|
+
try:
|
|
152
|
+
current_mtime = Path(file_path).stat().st_mtime
|
|
153
|
+
except Exception:
|
|
154
|
+
current_mtime = tracked_status.mtime
|
|
155
|
+
if current_mtime != tracked_status.mtime:
|
|
156
|
+
return model.ToolResultItem(
|
|
157
|
+
status="error",
|
|
158
|
+
output=(
|
|
159
|
+
"File has been modified externally. Either by user or a linter. Read it first before writing to it."
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
149
163
|
err = cls.valid(
|
|
150
164
|
content=before,
|
|
151
165
|
old_string=args.old_string,
|
|
@@ -187,15 +201,18 @@ class EditTool(ToolABC):
|
|
|
187
201
|
n=3,
|
|
188
202
|
)
|
|
189
203
|
)
|
|
190
|
-
|
|
191
|
-
ui_extra = model.DiffTextUIExtra(diff_text=diff_text)
|
|
204
|
+
ui_extra = build_structured_diff(before, after, file_path=file_path)
|
|
192
205
|
|
|
193
|
-
# Update tracker with new mtime
|
|
206
|
+
# Update tracker with new mtime and content hash
|
|
194
207
|
if file_tracker is not None:
|
|
195
208
|
with contextlib.suppress(Exception):
|
|
196
209
|
existing = file_tracker.get(file_path)
|
|
197
210
|
is_mem = existing.is_memory if existing else False
|
|
198
|
-
file_tracker[file_path] = model.FileStatus(
|
|
211
|
+
file_tracker[file_path] = model.FileStatus(
|
|
212
|
+
mtime=Path(file_path).stat().st_mtime,
|
|
213
|
+
content_sha256=hash_text_sha256(after),
|
|
214
|
+
is_memory=is_mem,
|
|
215
|
+
)
|
|
199
216
|
|
|
200
217
|
# Build output message
|
|
201
218
|
if args.replace_all:
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import contextlib
|
|
5
|
+
import hashlib
|
|
5
6
|
import os
|
|
6
7
|
from base64 import b64encode
|
|
7
8
|
from dataclasses import dataclass
|
|
@@ -37,6 +38,7 @@ class ReadOptions:
|
|
|
37
38
|
limit: int | None
|
|
38
39
|
char_limit_per_line: int | None = const.READ_CHAR_LIMIT_PER_LINE
|
|
39
40
|
global_line_cap: int | None = const.READ_GLOBAL_LINE_CAP
|
|
41
|
+
max_total_chars: int | None = const.READ_MAX_CHARS
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
@dataclass
|
|
@@ -45,29 +47,32 @@ class ReadSegmentResult:
|
|
|
45
47
|
selected_lines: list[tuple[int, str]]
|
|
46
48
|
selected_chars_count: int
|
|
47
49
|
remaining_selected_beyond_cap: int
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
remaining_due_to_char_limit: int
|
|
51
|
+
content_sha256: str
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
def _read_segment(options: ReadOptions) -> ReadSegmentResult:
|
|
53
55
|
total_lines = 0
|
|
54
56
|
selected_lines_count = 0
|
|
55
57
|
remaining_selected_beyond_cap = 0
|
|
58
|
+
remaining_due_to_char_limit = 0
|
|
56
59
|
selected_lines: list[tuple[int, str]] = []
|
|
57
60
|
selected_chars = 0
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
segment_size = 100
|
|
61
|
-
segment_char_stats: list[tuple[int, int, int]] = []
|
|
62
|
-
current_segment_start = options.offset
|
|
63
|
-
current_segment_chars = 0
|
|
61
|
+
char_limit_reached = False
|
|
62
|
+
hasher = hashlib.sha256()
|
|
64
63
|
|
|
65
64
|
with open(options.file_path, encoding="utf-8", errors="replace") as f:
|
|
66
65
|
for line_no, raw_line in enumerate(f, start=1):
|
|
67
66
|
total_lines = line_no
|
|
67
|
+
hasher.update(raw_line.encode("utf-8"))
|
|
68
68
|
within = line_no >= options.offset and (options.limit is None or selected_lines_count < options.limit)
|
|
69
69
|
if not within:
|
|
70
70
|
continue
|
|
71
|
+
|
|
72
|
+
if char_limit_reached:
|
|
73
|
+
remaining_due_to_char_limit += 1
|
|
74
|
+
continue
|
|
75
|
+
|
|
71
76
|
selected_lines_count += 1
|
|
72
77
|
content = raw_line.rstrip("\n")
|
|
73
78
|
original_len = len(content)
|
|
@@ -79,42 +84,39 @@ def _read_segment(options: ReadOptions) -> ReadSegmentResult:
|
|
|
79
84
|
)
|
|
80
85
|
line_chars = len(content) + 1
|
|
81
86
|
selected_chars += line_chars
|
|
82
|
-
current_segment_chars += line_chars
|
|
83
87
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
current_segment_chars = 0
|
|
88
|
+
if options.max_total_chars is not None and selected_chars > options.max_total_chars:
|
|
89
|
+
char_limit_reached = True
|
|
90
|
+
selected_lines.append((line_no, content))
|
|
91
|
+
continue
|
|
89
92
|
|
|
90
93
|
if options.global_line_cap is None or len(selected_lines) < options.global_line_cap:
|
|
91
94
|
selected_lines.append((line_no, content))
|
|
92
95
|
else:
|
|
93
96
|
remaining_selected_beyond_cap += 1
|
|
94
97
|
|
|
95
|
-
# Add the last partial segment if any
|
|
96
|
-
if current_segment_chars > 0 and selected_lines_count > 0:
|
|
97
|
-
last_line = options.offset + selected_lines_count - 1
|
|
98
|
-
segment_char_stats.append((current_segment_start, last_line, current_segment_chars))
|
|
99
|
-
|
|
100
98
|
return ReadSegmentResult(
|
|
101
99
|
total_lines=total_lines,
|
|
102
100
|
selected_lines=selected_lines,
|
|
103
101
|
selected_chars_count=selected_chars,
|
|
104
102
|
remaining_selected_beyond_cap=remaining_selected_beyond_cap,
|
|
105
|
-
|
|
103
|
+
remaining_due_to_char_limit=remaining_due_to_char_limit,
|
|
104
|
+
content_sha256=hasher.hexdigest(),
|
|
106
105
|
)
|
|
107
106
|
|
|
108
107
|
|
|
109
|
-
def _track_file_access(file_path: str, *, is_memory: bool = False) -> None:
|
|
108
|
+
def _track_file_access(file_path: str, *, content_sha256: str | None = None, is_memory: bool = False) -> None:
|
|
110
109
|
file_tracker = get_current_file_tracker()
|
|
111
110
|
if file_tracker is None or not file_exists(file_path) or is_directory(file_path):
|
|
112
111
|
return
|
|
113
112
|
with contextlib.suppress(Exception):
|
|
114
113
|
existing = file_tracker.get(file_path)
|
|
115
|
-
# Preserve is_memory flag if already set
|
|
116
114
|
is_mem = is_memory or (existing.is_memory if existing else False)
|
|
117
|
-
file_tracker[file_path] = model.FileStatus(
|
|
115
|
+
file_tracker[file_path] = model.FileStatus(
|
|
116
|
+
mtime=Path(file_path).stat().st_mtime,
|
|
117
|
+
content_sha256=content_sha256,
|
|
118
|
+
is_memory=is_mem,
|
|
119
|
+
)
|
|
118
120
|
|
|
119
121
|
|
|
120
122
|
def _is_supported_image_file(file_path: str) -> bool:
|
|
@@ -129,12 +131,6 @@ def _image_mime_type(file_path: str) -> str:
|
|
|
129
131
|
return mime_type
|
|
130
132
|
|
|
131
133
|
|
|
132
|
-
def _encode_image_to_data_url(file_path: str, mime_type: str) -> str:
|
|
133
|
-
with open(file_path, "rb") as image_file:
|
|
134
|
-
encoded = b64encode(image_file.read()).decode("ascii")
|
|
135
|
-
return f"data:{mime_type};base64,{encoded}"
|
|
136
|
-
|
|
137
|
-
|
|
138
134
|
@register(tools.READ)
|
|
139
135
|
class ReadTool(ToolABC):
|
|
140
136
|
class ReadArguments(BaseModel):
|
|
@@ -178,24 +174,18 @@ class ReadTool(ToolABC):
|
|
|
178
174
|
return await cls.call_with_args(args)
|
|
179
175
|
|
|
180
176
|
@classmethod
|
|
181
|
-
def _effective_limits(cls) -> tuple[int | None, int | None, int | None
|
|
182
|
-
"""Return effective limits based on current policy: char_per_line, global_line_cap, max_chars, max_kb"""
|
|
177
|
+
def _effective_limits(cls) -> tuple[int | None, int | None, int | None]:
|
|
183
178
|
return (
|
|
184
179
|
const.READ_CHAR_LIMIT_PER_LINE,
|
|
185
180
|
const.READ_GLOBAL_LINE_CAP,
|
|
186
181
|
const.READ_MAX_CHARS,
|
|
187
|
-
const.READ_MAX_KB,
|
|
188
182
|
)
|
|
189
183
|
|
|
190
184
|
@classmethod
|
|
191
185
|
async def call_with_args(cls, args: ReadTool.ReadArguments) -> model.ToolResultItem:
|
|
192
|
-
# Accept relative path by resolving to absolute (schema encourages absolute)
|
|
193
186
|
file_path = os.path.abspath(args.file_path)
|
|
187
|
+
char_per_line, line_cap, max_chars = cls._effective_limits()
|
|
194
188
|
|
|
195
|
-
# Get effective limits based on policy
|
|
196
|
-
char_per_line, line_cap, max_chars, max_kb = cls._effective_limits()
|
|
197
|
-
|
|
198
|
-
# Common file errors
|
|
199
189
|
if is_directory(file_path):
|
|
200
190
|
return model.ToolResultItem(
|
|
201
191
|
status="error",
|
|
@@ -228,11 +218,9 @@ class ReadTool(ToolABC):
|
|
|
228
218
|
),
|
|
229
219
|
)
|
|
230
220
|
|
|
231
|
-
# If file is too large and no pagination provided (only check if limits are enabled)
|
|
232
221
|
try:
|
|
233
222
|
size_bytes = Path(file_path).stat().st_size
|
|
234
223
|
except OSError:
|
|
235
|
-
# Best-effort size detection; on stat errors fall back to treating size as unknown.
|
|
236
224
|
size_bytes = 0
|
|
237
225
|
|
|
238
226
|
is_image_file = _is_supported_image_file(file_path)
|
|
@@ -247,42 +235,26 @@ class ReadTool(ToolABC):
|
|
|
247
235
|
)
|
|
248
236
|
try:
|
|
249
237
|
mime_type = _image_mime_type(file_path)
|
|
250
|
-
|
|
238
|
+
with open(file_path, "rb") as image_file:
|
|
239
|
+
image_bytes = image_file.read()
|
|
240
|
+
data_url = f"data:{mime_type};base64,{b64encode(image_bytes).decode('ascii')}"
|
|
251
241
|
except Exception as exc:
|
|
252
242
|
return model.ToolResultItem(
|
|
253
243
|
status="error",
|
|
254
244
|
output=f"<tool_use_error>Failed to read image file: {exc}</tool_use_error>",
|
|
255
245
|
)
|
|
256
246
|
|
|
257
|
-
_track_file_access(file_path)
|
|
247
|
+
_track_file_access(file_path, content_sha256=hashlib.sha256(image_bytes).hexdigest())
|
|
258
248
|
size_kb = size_bytes / 1024.0 if size_bytes else 0.0
|
|
259
249
|
output_text = f"[image] {Path(file_path).name} ({size_kb:.1f}KB)"
|
|
260
250
|
image_part = model.ImageURLPart(image_url=model.ImageURLPart.ImageURL(url=data_url, id=None))
|
|
261
251
|
return model.ToolResultItem(status="success", output=output_text, images=[image_part])
|
|
262
252
|
|
|
263
|
-
if (
|
|
264
|
-
not is_image_file
|
|
265
|
-
and max_kb is not None
|
|
266
|
-
and args.offset is None
|
|
267
|
-
and args.limit is None
|
|
268
|
-
and size_bytes > max_kb * 1024
|
|
269
|
-
):
|
|
270
|
-
size_kb = size_bytes / 1024.0
|
|
271
|
-
return model.ToolResultItem(
|
|
272
|
-
status="error",
|
|
273
|
-
output=(
|
|
274
|
-
f"File content ({size_kb:.1f}KB) exceeds maximum allowed size ({max_kb}KB). Please use offset and limit parameters to read specific portions of the file, or use the `rg` command to search for specific content."
|
|
275
|
-
),
|
|
276
|
-
)
|
|
277
|
-
|
|
278
253
|
offset = 1 if args.offset is None or args.offset < 1 else int(args.offset)
|
|
279
254
|
limit = None if args.limit is None else int(args.limit)
|
|
280
255
|
if limit is not None and limit < 0:
|
|
281
256
|
limit = 0
|
|
282
257
|
|
|
283
|
-
# Stream file line-by-line and build response
|
|
284
|
-
read_result: ReadSegmentResult | None = None
|
|
285
|
-
|
|
286
258
|
try:
|
|
287
259
|
read_result = await asyncio.to_thread(
|
|
288
260
|
_read_segment,
|
|
@@ -292,6 +264,7 @@ class ReadTool(ToolABC):
|
|
|
292
264
|
limit=limit,
|
|
293
265
|
char_limit_per_line=char_per_line,
|
|
294
266
|
global_line_cap=line_cap,
|
|
267
|
+
max_total_chars=max_chars,
|
|
295
268
|
),
|
|
296
269
|
)
|
|
297
270
|
|
|
@@ -306,40 +279,26 @@ class ReadTool(ToolABC):
|
|
|
306
279
|
output="<tool_use_error>Illegal operation on a directory. read</tool_use_error>",
|
|
307
280
|
)
|
|
308
281
|
|
|
309
|
-
# If offset beyond total lines, emit system reminder warning
|
|
310
282
|
if offset > max(read_result.total_lines, 0):
|
|
311
283
|
warn = f"<system-reminder>Warning: the file exists but is shorter than the provided offset ({offset}). The file has {read_result.total_lines} lines.</system-reminder>"
|
|
312
|
-
|
|
313
|
-
_track_file_access(file_path)
|
|
284
|
+
_track_file_access(file_path, content_sha256=read_result.content_sha256)
|
|
314
285
|
return model.ToolResultItem(status="success", output=warn)
|
|
315
286
|
|
|
316
|
-
|
|
317
|
-
if max_chars is not None and read_result.selected_chars_count > max_chars:
|
|
318
|
-
# Build segment statistics for better guidance
|
|
319
|
-
stats_lines: list[str] = []
|
|
320
|
-
for start, end, chars in read_result.segment_char_stats:
|
|
321
|
-
stats_lines.append(f" Lines {start}-{end}: {chars} chars")
|
|
322
|
-
segment_stats_str = "\n".join(stats_lines) if stats_lines else " (no segment data)"
|
|
287
|
+
lines_out: list[str] = [_format_numbered_line(no, content) for no, content in read_result.selected_lines]
|
|
323
288
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
)
|
|
289
|
+
# Show truncation info with reason
|
|
290
|
+
if read_result.remaining_due_to_char_limit > 0:
|
|
291
|
+
lines_out.append(
|
|
292
|
+
f"... ({read_result.remaining_due_to_char_limit} more lines truncated due to {max_chars} char limit, "
|
|
293
|
+
f"file has {read_result.total_lines} lines total, use offset/limit to read other parts)"
|
|
294
|
+
)
|
|
295
|
+
elif read_result.remaining_selected_beyond_cap > 0:
|
|
296
|
+
lines_out.append(
|
|
297
|
+
f"... ({read_result.remaining_selected_beyond_cap} more lines truncated due to {line_cap} line limit, "
|
|
298
|
+
f"file has {read_result.total_lines} lines total, use offset/limit to read other parts)"
|
|
334
299
|
)
|
|
335
300
|
|
|
336
|
-
# Build display with numbering and reminders
|
|
337
|
-
lines_out: list[str] = [_format_numbered_line(no, content) for no, content in read_result.selected_lines]
|
|
338
|
-
if read_result.remaining_selected_beyond_cap > 0:
|
|
339
|
-
lines_out.append(f"... (more {read_result.remaining_selected_beyond_cap} lines are truncated)")
|
|
340
301
|
read_result_str = "\n".join(lines_out)
|
|
341
|
-
|
|
342
|
-
# Update FileTracker with last modified time
|
|
343
|
-
_track_file_access(file_path)
|
|
302
|
+
_track_file_access(file_path, content_sha256=read_result.content_sha256)
|
|
344
303
|
|
|
345
304
|
return model.ToolResultItem(status="success", output=read_result_str)
|
|
@@ -2,13 +2,13 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import contextlib
|
|
5
|
-
import difflib
|
|
6
5
|
import os
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
|
|
9
8
|
from pydantic import BaseModel
|
|
10
9
|
|
|
11
|
-
from klaude_code.core.tool.file._utils import file_exists, is_directory, read_text, write_text
|
|
10
|
+
from klaude_code.core.tool.file._utils import file_exists, hash_text_sha256, is_directory, read_text, write_text
|
|
11
|
+
from klaude_code.core.tool.file.diff_builder import build_structured_diff
|
|
12
12
|
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
13
13
|
from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
14
14
|
from klaude_code.core.tool.tool_registry import register
|
|
@@ -62,36 +62,52 @@ class WriteTool(ToolABC):
|
|
|
62
62
|
|
|
63
63
|
file_tracker = get_current_file_tracker()
|
|
64
64
|
exists = file_exists(file_path)
|
|
65
|
+
tracked_status: model.FileStatus | None = None
|
|
65
66
|
|
|
66
67
|
if exists:
|
|
67
|
-
tracked_status
|
|
68
|
-
if file_tracker is not None:
|
|
69
|
-
tracked_status = file_tracker.get(file_path)
|
|
68
|
+
tracked_status = file_tracker.get(file_path) if file_tracker is not None else None
|
|
70
69
|
if tracked_status is None:
|
|
71
70
|
return model.ToolResultItem(
|
|
72
71
|
status="error",
|
|
73
72
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
74
73
|
)
|
|
75
|
-
try:
|
|
76
|
-
current_mtime = Path(file_path).stat().st_mtime
|
|
77
|
-
except Exception:
|
|
78
|
-
current_mtime = tracked_status.mtime
|
|
79
|
-
if current_mtime != tracked_status.mtime:
|
|
80
|
-
return model.ToolResultItem(
|
|
81
|
-
status="error",
|
|
82
|
-
output=(
|
|
83
|
-
"File has been modified externally. Either by user or a linter. "
|
|
84
|
-
"Read it first before writing to it."
|
|
85
|
-
),
|
|
86
|
-
)
|
|
87
74
|
|
|
88
|
-
# Capture previous content (if any) for diff generation
|
|
75
|
+
# Capture previous content (if any) for diff generation and external-change detection.
|
|
89
76
|
before = ""
|
|
77
|
+
before_read_ok = False
|
|
90
78
|
if exists:
|
|
91
79
|
try:
|
|
92
80
|
before = await asyncio.to_thread(read_text, file_path)
|
|
81
|
+
before_read_ok = True
|
|
93
82
|
except Exception:
|
|
94
83
|
before = ""
|
|
84
|
+
before_read_ok = False
|
|
85
|
+
|
|
86
|
+
# Re-check external modifications using content hash when available.
|
|
87
|
+
if before_read_ok and tracked_status is not None and tracked_status.content_sha256 is not None:
|
|
88
|
+
current_sha256 = hash_text_sha256(before)
|
|
89
|
+
if current_sha256 != tracked_status.content_sha256:
|
|
90
|
+
return model.ToolResultItem(
|
|
91
|
+
status="error",
|
|
92
|
+
output=(
|
|
93
|
+
"File has been modified externally. Either by user or a linter. "
|
|
94
|
+
"Read it first before writing to it."
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
elif tracked_status is not None:
|
|
98
|
+
# Backward-compat: old sessions only stored mtime, or we couldn't hash.
|
|
99
|
+
try:
|
|
100
|
+
current_mtime = Path(file_path).stat().st_mtime
|
|
101
|
+
except Exception:
|
|
102
|
+
current_mtime = tracked_status.mtime
|
|
103
|
+
if current_mtime != tracked_status.mtime:
|
|
104
|
+
return model.ToolResultItem(
|
|
105
|
+
status="error",
|
|
106
|
+
output=(
|
|
107
|
+
"File has been modified externally. Either by user or a linter. "
|
|
108
|
+
"Read it first before writing to it."
|
|
109
|
+
),
|
|
110
|
+
)
|
|
95
111
|
|
|
96
112
|
try:
|
|
97
113
|
await asyncio.to_thread(write_text, file_path, args.content)
|
|
@@ -102,21 +118,15 @@ class WriteTool(ToolABC):
|
|
|
102
118
|
with contextlib.suppress(Exception):
|
|
103
119
|
existing = file_tracker.get(file_path)
|
|
104
120
|
is_mem = existing.is_memory if existing else False
|
|
105
|
-
file_tracker[file_path] = model.FileStatus(
|
|
121
|
+
file_tracker[file_path] = model.FileStatus(
|
|
122
|
+
mtime=Path(file_path).stat().st_mtime,
|
|
123
|
+
content_sha256=hash_text_sha256(args.content),
|
|
124
|
+
is_memory=is_mem,
|
|
125
|
+
)
|
|
106
126
|
|
|
107
127
|
# Build diff between previous and new content
|
|
108
128
|
after = args.content
|
|
109
|
-
|
|
110
|
-
difflib.unified_diff(
|
|
111
|
-
before.splitlines(),
|
|
112
|
-
after.splitlines(),
|
|
113
|
-
fromfile=file_path,
|
|
114
|
-
tofile=file_path,
|
|
115
|
-
n=3,
|
|
116
|
-
)
|
|
117
|
-
)
|
|
118
|
-
diff_text = "\n".join(diff_lines)
|
|
119
|
-
ui_extra = model.DiffTextUIExtra(diff_text=diff_text)
|
|
129
|
+
ui_extra = build_structured_diff(before, after, file_path=file_path)
|
|
120
130
|
|
|
121
131
|
message = f"File {'overwritten' if exists else 'created'} successfully at: {file_path}"
|
|
122
132
|
return model.ToolResultItem(status="success", output=message, ui_extra=ui_extra)
|