klaude-code 1.2.6__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/__init__.py +0 -0
- klaude_code/cli/__init__.py +1 -0
- klaude_code/cli/main.py +298 -0
- klaude_code/cli/runtime.py +331 -0
- klaude_code/cli/session_cmd.py +80 -0
- klaude_code/command/__init__.py +43 -0
- klaude_code/command/clear_cmd.py +20 -0
- klaude_code/command/command_abc.py +92 -0
- klaude_code/command/diff_cmd.py +138 -0
- klaude_code/command/export_cmd.py +86 -0
- klaude_code/command/help_cmd.py +51 -0
- klaude_code/command/model_cmd.py +43 -0
- klaude_code/command/prompt-dev-docs-update.md +56 -0
- klaude_code/command/prompt-dev-docs.md +46 -0
- klaude_code/command/prompt-init.md +45 -0
- klaude_code/command/prompt_command.py +69 -0
- klaude_code/command/refresh_cmd.py +43 -0
- klaude_code/command/registry.py +110 -0
- klaude_code/command/status_cmd.py +111 -0
- klaude_code/command/terminal_setup_cmd.py +252 -0
- klaude_code/config/__init__.py +11 -0
- klaude_code/config/config.py +177 -0
- klaude_code/config/list_model.py +162 -0
- klaude_code/config/select_model.py +67 -0
- klaude_code/const/__init__.py +133 -0
- klaude_code/core/__init__.py +0 -0
- klaude_code/core/agent.py +165 -0
- klaude_code/core/executor.py +485 -0
- klaude_code/core/manager/__init__.py +19 -0
- klaude_code/core/manager/agent_manager.py +127 -0
- klaude_code/core/manager/llm_clients.py +42 -0
- klaude_code/core/manager/llm_clients_builder.py +49 -0
- klaude_code/core/manager/sub_agent_manager.py +86 -0
- klaude_code/core/prompt.py +89 -0
- klaude_code/core/prompts/prompt-claude-code.md +98 -0
- klaude_code/core/prompts/prompt-codex.md +331 -0
- klaude_code/core/prompts/prompt-gemini.md +43 -0
- klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
- klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
- klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
- klaude_code/core/prompts/prompt-subagent.md +8 -0
- klaude_code/core/reminders.py +445 -0
- klaude_code/core/task.py +237 -0
- klaude_code/core/tool/__init__.py +75 -0
- klaude_code/core/tool/file/__init__.py +0 -0
- klaude_code/core/tool/file/apply_patch.py +492 -0
- klaude_code/core/tool/file/apply_patch_tool.md +1 -0
- klaude_code/core/tool/file/apply_patch_tool.py +204 -0
- klaude_code/core/tool/file/edit_tool.md +9 -0
- klaude_code/core/tool/file/edit_tool.py +274 -0
- klaude_code/core/tool/file/multi_edit_tool.md +42 -0
- klaude_code/core/tool/file/multi_edit_tool.py +199 -0
- klaude_code/core/tool/file/read_tool.md +14 -0
- klaude_code/core/tool/file/read_tool.py +326 -0
- klaude_code/core/tool/file/write_tool.md +8 -0
- klaude_code/core/tool/file/write_tool.py +146 -0
- klaude_code/core/tool/memory/__init__.py +0 -0
- klaude_code/core/tool/memory/memory_tool.md +16 -0
- klaude_code/core/tool/memory/memory_tool.py +462 -0
- klaude_code/core/tool/memory/skill_loader.py +245 -0
- klaude_code/core/tool/memory/skill_tool.md +24 -0
- klaude_code/core/tool/memory/skill_tool.py +97 -0
- klaude_code/core/tool/shell/__init__.py +0 -0
- klaude_code/core/tool/shell/bash_tool.md +43 -0
- klaude_code/core/tool/shell/bash_tool.py +123 -0
- klaude_code/core/tool/shell/command_safety.py +363 -0
- klaude_code/core/tool/sub_agent_tool.py +83 -0
- klaude_code/core/tool/todo/__init__.py +0 -0
- klaude_code/core/tool/todo/todo_write_tool.md +182 -0
- klaude_code/core/tool/todo/todo_write_tool.py +121 -0
- klaude_code/core/tool/todo/update_plan_tool.md +3 -0
- klaude_code/core/tool/todo/update_plan_tool.py +104 -0
- klaude_code/core/tool/tool_abc.py +25 -0
- klaude_code/core/tool/tool_context.py +106 -0
- klaude_code/core/tool/tool_registry.py +78 -0
- klaude_code/core/tool/tool_runner.py +252 -0
- klaude_code/core/tool/truncation.py +170 -0
- klaude_code/core/tool/web/__init__.py +0 -0
- klaude_code/core/tool/web/mermaid_tool.md +21 -0
- klaude_code/core/tool/web/mermaid_tool.py +76 -0
- klaude_code/core/tool/web/web_fetch_tool.md +8 -0
- klaude_code/core/tool/web/web_fetch_tool.py +159 -0
- klaude_code/core/turn.py +220 -0
- klaude_code/llm/__init__.py +21 -0
- klaude_code/llm/anthropic/__init__.py +3 -0
- klaude_code/llm/anthropic/client.py +221 -0
- klaude_code/llm/anthropic/input.py +200 -0
- klaude_code/llm/client.py +49 -0
- klaude_code/llm/input_common.py +239 -0
- klaude_code/llm/openai_compatible/__init__.py +3 -0
- klaude_code/llm/openai_compatible/client.py +211 -0
- klaude_code/llm/openai_compatible/input.py +109 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
- klaude_code/llm/openrouter/__init__.py +3 -0
- klaude_code/llm/openrouter/client.py +200 -0
- klaude_code/llm/openrouter/input.py +160 -0
- klaude_code/llm/openrouter/reasoning_handler.py +209 -0
- klaude_code/llm/registry.py +22 -0
- klaude_code/llm/responses/__init__.py +3 -0
- klaude_code/llm/responses/client.py +216 -0
- klaude_code/llm/responses/input.py +167 -0
- klaude_code/llm/usage.py +109 -0
- klaude_code/protocol/__init__.py +4 -0
- klaude_code/protocol/commands.py +21 -0
- klaude_code/protocol/events.py +163 -0
- klaude_code/protocol/llm_param.py +147 -0
- klaude_code/protocol/model.py +287 -0
- klaude_code/protocol/op.py +89 -0
- klaude_code/protocol/op_handler.py +28 -0
- klaude_code/protocol/sub_agent.py +348 -0
- klaude_code/protocol/tools.py +15 -0
- klaude_code/session/__init__.py +4 -0
- klaude_code/session/export.py +624 -0
- klaude_code/session/selector.py +76 -0
- klaude_code/session/session.py +474 -0
- klaude_code/session/templates/export_session.html +1434 -0
- klaude_code/trace/__init__.py +3 -0
- klaude_code/trace/log.py +168 -0
- klaude_code/ui/__init__.py +91 -0
- klaude_code/ui/core/__init__.py +1 -0
- klaude_code/ui/core/display.py +103 -0
- klaude_code/ui/core/input.py +71 -0
- klaude_code/ui/core/stage_manager.py +55 -0
- klaude_code/ui/modes/__init__.py +1 -0
- klaude_code/ui/modes/debug/__init__.py +1 -0
- klaude_code/ui/modes/debug/display.py +36 -0
- klaude_code/ui/modes/exec/__init__.py +1 -0
- klaude_code/ui/modes/exec/display.py +63 -0
- klaude_code/ui/modes/repl/__init__.py +51 -0
- klaude_code/ui/modes/repl/clipboard.py +152 -0
- klaude_code/ui/modes/repl/completers.py +429 -0
- klaude_code/ui/modes/repl/display.py +60 -0
- klaude_code/ui/modes/repl/event_handler.py +375 -0
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
- klaude_code/ui/modes/repl/key_bindings.py +170 -0
- klaude_code/ui/modes/repl/renderer.py +281 -0
- klaude_code/ui/renderers/__init__.py +0 -0
- klaude_code/ui/renderers/assistant.py +21 -0
- klaude_code/ui/renderers/common.py +8 -0
- klaude_code/ui/renderers/developer.py +158 -0
- klaude_code/ui/renderers/diffs.py +215 -0
- klaude_code/ui/renderers/errors.py +16 -0
- klaude_code/ui/renderers/metadata.py +190 -0
- klaude_code/ui/renderers/sub_agent.py +71 -0
- klaude_code/ui/renderers/thinking.py +39 -0
- klaude_code/ui/renderers/tools.py +551 -0
- klaude_code/ui/renderers/user_input.py +65 -0
- klaude_code/ui/rich/__init__.py +1 -0
- klaude_code/ui/rich/live.py +65 -0
- klaude_code/ui/rich/markdown.py +308 -0
- klaude_code/ui/rich/quote.py +34 -0
- klaude_code/ui/rich/searchable_text.py +71 -0
- klaude_code/ui/rich/status.py +240 -0
- klaude_code/ui/rich/theme.py +274 -0
- klaude_code/ui/terminal/__init__.py +1 -0
- klaude_code/ui/terminal/color.py +244 -0
- klaude_code/ui/terminal/control.py +147 -0
- klaude_code/ui/terminal/notifier.py +107 -0
- klaude_code/ui/terminal/progress_bar.py +87 -0
- klaude_code/ui/utils/__init__.py +1 -0
- klaude_code/ui/utils/common.py +108 -0
- klaude_code/ui/utils/debouncer.py +42 -0
- klaude_code/version.py +163 -0
- klaude_code-1.2.6.dist-info/METADATA +178 -0
- klaude_code-1.2.6.dist-info/RECORD +167 -0
- klaude_code-1.2.6.dist-info/WHEEL +4 -0
- klaude_code-1.2.6.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import difflib
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from klaude_code.core.tool.file.edit_tool import EditTool
|
|
11
|
+
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
12
|
+
from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
13
|
+
from klaude_code.core.tool.tool_registry import register
|
|
14
|
+
from klaude_code.protocol import llm_param, model, tools
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_directory(path: str) -> bool:
|
|
18
|
+
try:
|
|
19
|
+
return Path(path).is_dir()
|
|
20
|
+
except Exception:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _file_exists(path: str) -> bool:
|
|
25
|
+
try:
|
|
26
|
+
return Path(path).exists()
|
|
27
|
+
except Exception:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_text(path: str) -> str:
|
|
32
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
33
|
+
return f.read()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write_text(path: str, content: str) -> None:
|
|
37
|
+
parent = Path(path).parent
|
|
38
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
40
|
+
f.write(content)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@register(tools.MULTI_EDIT)
|
|
44
|
+
class MultiEditTool(ToolABC):
|
|
45
|
+
class MultiEditEditItem(BaseModel):
|
|
46
|
+
old_string: str
|
|
47
|
+
new_string: str
|
|
48
|
+
replace_all: bool = Field(default=False)
|
|
49
|
+
|
|
50
|
+
class MultiEditArguments(BaseModel):
|
|
51
|
+
file_path: str
|
|
52
|
+
edits: list[MultiEditTool.MultiEditEditItem]
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def schema(cls) -> llm_param.ToolSchema:
|
|
56
|
+
return llm_param.ToolSchema(
|
|
57
|
+
name=tools.MULTI_EDIT,
|
|
58
|
+
type="function",
|
|
59
|
+
description=load_desc(Path(__file__).parent / "multi_edit_tool.md"),
|
|
60
|
+
parameters={
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"file_path": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"description": "The absolute path to the file to modify",
|
|
66
|
+
},
|
|
67
|
+
"edits": {
|
|
68
|
+
"type": "array",
|
|
69
|
+
"items": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {
|
|
72
|
+
"old_string": {
|
|
73
|
+
"type": "string",
|
|
74
|
+
"description": "The text to replace",
|
|
75
|
+
},
|
|
76
|
+
"new_string": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"description": "The text to replace it with",
|
|
79
|
+
},
|
|
80
|
+
"replace_all": {
|
|
81
|
+
"type": "boolean",
|
|
82
|
+
"default": False,
|
|
83
|
+
"description": "Replace all occurences of old_string (default false).",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
"required": ["old_string", "new_string"],
|
|
87
|
+
"additionalProperties": False,
|
|
88
|
+
},
|
|
89
|
+
"minItems": 1,
|
|
90
|
+
"description": "Array of edit operations to perform sequentially on the file",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
"required": ["file_path", "edits"],
|
|
94
|
+
"additionalProperties": False,
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
async def call(cls, arguments: str) -> model.ToolResultItem:
|
|
100
|
+
try:
|
|
101
|
+
args = MultiEditTool.MultiEditArguments.model_validate_json(arguments)
|
|
102
|
+
except Exception as e: # pragma: no cover - defensive
|
|
103
|
+
return model.ToolResultItem(status="error", output=f"Invalid arguments: {e}")
|
|
104
|
+
|
|
105
|
+
file_path = os.path.abspath(args.file_path)
|
|
106
|
+
|
|
107
|
+
# Directory error first
|
|
108
|
+
if _is_directory(file_path):
|
|
109
|
+
return model.ToolResultItem(
|
|
110
|
+
status="error",
|
|
111
|
+
output="<tool_use_error>Illegal operation on a directory. multi_edit</tool_use_error>",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
file_tracker = get_current_file_tracker()
|
|
115
|
+
|
|
116
|
+
# FileTracker check:
|
|
117
|
+
if _file_exists(file_path):
|
|
118
|
+
if file_tracker is not None:
|
|
119
|
+
tracked = file_tracker.get(file_path)
|
|
120
|
+
if tracked is None:
|
|
121
|
+
return model.ToolResultItem(
|
|
122
|
+
status="error",
|
|
123
|
+
output=("File has not been read yet. Read it first before writing to it."),
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
current_mtime = Path(file_path).stat().st_mtime
|
|
127
|
+
except Exception:
|
|
128
|
+
current_mtime = tracked
|
|
129
|
+
if current_mtime != tracked:
|
|
130
|
+
return model.ToolResultItem(
|
|
131
|
+
status="error",
|
|
132
|
+
output=(
|
|
133
|
+
"File has been modified externally. Either by user or a linter. Read it first before writing to it."
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
# Allow creation only if first edit is creating content (old_string == "")
|
|
138
|
+
if not args.edits or args.edits[0].old_string != "":
|
|
139
|
+
return model.ToolResultItem(
|
|
140
|
+
status="error",
|
|
141
|
+
output=("File has not been read yet. Read it first before writing to it."),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Load initial content (empty for new file case)
|
|
145
|
+
if _file_exists(file_path):
|
|
146
|
+
before = await asyncio.to_thread(_read_text, file_path)
|
|
147
|
+
else:
|
|
148
|
+
before = ""
|
|
149
|
+
|
|
150
|
+
# Validate all edits atomically against staged content
|
|
151
|
+
staged = before
|
|
152
|
+
for edit in args.edits:
|
|
153
|
+
err = EditTool.valid(
|
|
154
|
+
content=staged,
|
|
155
|
+
old_string=edit.old_string,
|
|
156
|
+
new_string=edit.new_string,
|
|
157
|
+
replace_all=edit.replace_all,
|
|
158
|
+
)
|
|
159
|
+
if err is not None:
|
|
160
|
+
return model.ToolResultItem(status="error", output=err)
|
|
161
|
+
# Apply to staged content
|
|
162
|
+
staged = EditTool.execute(
|
|
163
|
+
content=staged,
|
|
164
|
+
old_string=edit.old_string,
|
|
165
|
+
new_string=edit.new_string,
|
|
166
|
+
replace_all=edit.replace_all,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# All edits valid; write to disk
|
|
170
|
+
try:
|
|
171
|
+
await asyncio.to_thread(_write_text, file_path, staged)
|
|
172
|
+
except Exception as e: # pragma: no cover
|
|
173
|
+
return model.ToolResultItem(status="error", output=f"<tool_use_error>{e}</tool_use_error>")
|
|
174
|
+
|
|
175
|
+
# Prepare UI extra: unified diff
|
|
176
|
+
diff_lines = list(
|
|
177
|
+
difflib.unified_diff(
|
|
178
|
+
before.splitlines(),
|
|
179
|
+
staged.splitlines(),
|
|
180
|
+
fromfile=file_path,
|
|
181
|
+
tofile=file_path,
|
|
182
|
+
n=3,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
diff_text = "\n".join(diff_lines)
|
|
186
|
+
ui_extra = model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
|
|
187
|
+
|
|
188
|
+
# Update tracker
|
|
189
|
+
if file_tracker is not None:
|
|
190
|
+
try:
|
|
191
|
+
file_tracker[file_path] = Path(file_path).stat().st_mtime
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
# Build output message
|
|
196
|
+
lines = [f"Applied {len(args.edits)} edits to {file_path}:"]
|
|
197
|
+
for i, edit in enumerate(args.edits, start=1):
|
|
198
|
+
lines.append(f'{i}. Replaced "{edit.old_string}" with "{edit.new_string}"')
|
|
199
|
+
return model.ToolResultItem(status="success", output="\n".join(lines), ui_extra=ui_extra)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Reads a file from the local filesystem. You can access any file directly by using this tool.
|
|
2
|
+
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
- The file_path parameter must be an absolute path, not a relative path
|
|
6
|
+
- By default, it reads up to 2000 lines starting from the beginning of the file
|
|
7
|
+
- This tool allows you to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as you are a multimodal LLM.
|
|
8
|
+
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
|
9
|
+
- Any lines longer than 2000 characters will be truncated
|
|
10
|
+
- Results are returned using cat -n format, with line numbers starting at 1
|
|
11
|
+
- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.
|
|
12
|
+
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
|
|
13
|
+
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
|
14
|
+
- This tool does NOT support reading PDF files. Use a Python script with `pdfplumber` (for text/tables) or `pypdf` (for basic operations) to extract content from PDFs.
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from base64 import b64encode
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from klaude_code import const
|
|
12
|
+
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
13
|
+
from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
14
|
+
from klaude_code.core.tool.tool_registry import register
|
|
15
|
+
from klaude_code.protocol import llm_param, model, tools
|
|
16
|
+
|
|
17
|
+
SYSTEM_REMINDER_MALICIOUS = (
|
|
18
|
+
"<system-reminder>\n"
|
|
19
|
+
"Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.\n"
|
|
20
|
+
"</system-reminder>"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_IMAGE_MIME_TYPES: dict[str, str] = {
|
|
24
|
+
".png": "image/png",
|
|
25
|
+
".jpg": "image/jpeg",
|
|
26
|
+
".jpeg": "image/jpeg",
|
|
27
|
+
".gif": "image/gif",
|
|
28
|
+
".webp": "image/webp",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _format_numbered_line(line_no: int, content: str) -> str:
|
|
33
|
+
# 6-width right-aligned line number followed by a right arrow
|
|
34
|
+
return f"{line_no:>6}→{content}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_directory(path: str) -> bool:
|
|
38
|
+
try:
|
|
39
|
+
return Path(path).is_dir()
|
|
40
|
+
except Exception:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _file_exists(path: str) -> bool:
|
|
45
|
+
try:
|
|
46
|
+
return Path(path).exists()
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ReadOptions:
|
|
53
|
+
file_path: str
|
|
54
|
+
offset: int
|
|
55
|
+
limit: int | None
|
|
56
|
+
char_limit_per_line: int | None = const.READ_CHAR_LIMIT_PER_LINE
|
|
57
|
+
global_line_cap: int | None = const.READ_GLOBAL_LINE_CAP
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ReadSegmentResult:
|
|
62
|
+
total_lines: int
|
|
63
|
+
selected_lines: list[tuple[int, str]]
|
|
64
|
+
selected_chars_count: int
|
|
65
|
+
remaining_selected_beyond_cap: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_segment(options: ReadOptions) -> ReadSegmentResult:
|
|
69
|
+
total_lines = 0
|
|
70
|
+
selected_lines_count = 0
|
|
71
|
+
remaining_selected_beyond_cap = 0
|
|
72
|
+
selected_lines: list[tuple[int, str]] = []
|
|
73
|
+
selected_chars = 0
|
|
74
|
+
with open(options.file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
75
|
+
for line_no, raw_line in enumerate(f, start=1):
|
|
76
|
+
total_lines = line_no
|
|
77
|
+
within = line_no >= options.offset and (options.limit is None or selected_lines_count < options.limit)
|
|
78
|
+
if not within:
|
|
79
|
+
continue
|
|
80
|
+
selected_lines_count += 1
|
|
81
|
+
content = raw_line.rstrip("\n")
|
|
82
|
+
original_len = len(content)
|
|
83
|
+
if options.char_limit_per_line is not None and original_len > options.char_limit_per_line:
|
|
84
|
+
truncated_chars = original_len - options.char_limit_per_line
|
|
85
|
+
content = (
|
|
86
|
+
content[: options.char_limit_per_line]
|
|
87
|
+
+ f" ... (more {truncated_chars} characters in this line are truncated)"
|
|
88
|
+
)
|
|
89
|
+
selected_chars += len(content) + 1
|
|
90
|
+
if options.global_line_cap is None or len(selected_lines) < options.global_line_cap:
|
|
91
|
+
selected_lines.append((line_no, content))
|
|
92
|
+
else:
|
|
93
|
+
remaining_selected_beyond_cap += 1
|
|
94
|
+
return ReadSegmentResult(
|
|
95
|
+
total_lines=total_lines,
|
|
96
|
+
selected_lines=selected_lines,
|
|
97
|
+
selected_chars_count=selected_chars,
|
|
98
|
+
remaining_selected_beyond_cap=remaining_selected_beyond_cap,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _track_file_access(file_path: str) -> None:
|
|
103
|
+
file_tracker = get_current_file_tracker()
|
|
104
|
+
if file_tracker is None or not _file_exists(file_path) or _is_directory(file_path):
|
|
105
|
+
return
|
|
106
|
+
try:
|
|
107
|
+
file_tracker[file_path] = Path(file_path).stat().st_mtime
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_supported_image_file(file_path: str) -> bool:
|
|
113
|
+
return Path(file_path).suffix.lower() in _IMAGE_MIME_TYPES
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _image_mime_type(file_path: str) -> str:
|
|
117
|
+
suffix = Path(file_path).suffix.lower()
|
|
118
|
+
mime_type = _IMAGE_MIME_TYPES.get(suffix)
|
|
119
|
+
if mime_type is None:
|
|
120
|
+
raise ValueError(f"Unsupported image file extension: {suffix}")
|
|
121
|
+
return mime_type
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _encode_image_to_data_url(file_path: str, mime_type: str) -> str:
|
|
125
|
+
with open(file_path, "rb") as image_file:
|
|
126
|
+
encoded = b64encode(image_file.read()).decode("ascii")
|
|
127
|
+
return f"data:{mime_type};base64,{encoded}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@register(tools.READ)
|
|
131
|
+
class ReadTool(ToolABC):
|
|
132
|
+
class ReadArguments(BaseModel):
|
|
133
|
+
file_path: str
|
|
134
|
+
offset: int | None = Field(default=None)
|
|
135
|
+
limit: int | None = Field(default=None)
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def schema(cls) -> llm_param.ToolSchema:
|
|
139
|
+
return llm_param.ToolSchema(
|
|
140
|
+
name=tools.READ,
|
|
141
|
+
type="function",
|
|
142
|
+
description=load_desc(Path(__file__).parent / "read_tool.md"),
|
|
143
|
+
parameters={
|
|
144
|
+
"type": "object",
|
|
145
|
+
"properties": {
|
|
146
|
+
"file_path": {
|
|
147
|
+
"type": "string",
|
|
148
|
+
"description": "The absolute path to the file to read",
|
|
149
|
+
},
|
|
150
|
+
"offset": {
|
|
151
|
+
"type": "number",
|
|
152
|
+
"description": "The line number to start reading from. Only provide if the file is too large to read at once",
|
|
153
|
+
},
|
|
154
|
+
"limit": {
|
|
155
|
+
"type": "number",
|
|
156
|
+
"description": "The number of lines to read. Only provide if the file is too large to read at once.",
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
"required": ["file_path"],
|
|
160
|
+
"additionalProperties": False,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
async def call(cls, arguments: str) -> model.ToolResultItem:
|
|
166
|
+
try:
|
|
167
|
+
args = ReadTool.ReadArguments.model_validate_json(arguments)
|
|
168
|
+
except Exception as e: # pragma: no cover - defensive
|
|
169
|
+
return model.ToolResultItem(status="error", output=f"Invalid arguments: {e}")
|
|
170
|
+
return await cls.call_with_args(args)
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def _effective_limits(cls) -> tuple[int | None, int | None, int | None, int | None]:
|
|
174
|
+
"""Return effective limits based on current policy: char_per_line, global_line_cap, max_chars, max_kb"""
|
|
175
|
+
return (
|
|
176
|
+
const.READ_CHAR_LIMIT_PER_LINE,
|
|
177
|
+
const.READ_GLOBAL_LINE_CAP,
|
|
178
|
+
const.READ_MAX_CHARS,
|
|
179
|
+
const.READ_MAX_KB,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
async def call_with_args(cls, args: ReadTool.ReadArguments) -> model.ToolResultItem:
|
|
184
|
+
# Accept relative path by resolving to absolute (schema encourages absolute)
|
|
185
|
+
file_path = os.path.abspath(args.file_path)
|
|
186
|
+
|
|
187
|
+
# Get effective limits based on policy
|
|
188
|
+
char_per_line, line_cap, max_chars, max_kb = cls._effective_limits()
|
|
189
|
+
|
|
190
|
+
# Common file errors
|
|
191
|
+
if _is_directory(file_path):
|
|
192
|
+
return model.ToolResultItem(
|
|
193
|
+
status="error",
|
|
194
|
+
output="<tool_use_error>Illegal operation on a directory. read</tool_use_error>",
|
|
195
|
+
)
|
|
196
|
+
if not _file_exists(file_path):
|
|
197
|
+
return model.ToolResultItem(
|
|
198
|
+
status="error",
|
|
199
|
+
output="<tool_use_error>File does not exist.</tool_use_error>",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Check for PDF files
|
|
203
|
+
if Path(file_path).suffix.lower() == ".pdf":
|
|
204
|
+
return model.ToolResultItem(
|
|
205
|
+
status="error",
|
|
206
|
+
output=(
|
|
207
|
+
"<tool_use_error>PDF files are not supported by this tool. "
|
|
208
|
+
"Please use a Python script with `pdfplumber` to extract text/tables:\n\n"
|
|
209
|
+
"```python\n"
|
|
210
|
+
"# /// script\n"
|
|
211
|
+
'# dependencies = ["pdfplumber"]\n'
|
|
212
|
+
"# ///\n"
|
|
213
|
+
"import pdfplumber\n\n"
|
|
214
|
+
"with pdfplumber.open('file.pdf') as pdf:\n"
|
|
215
|
+
" for page in pdf.pages:\n"
|
|
216
|
+
" print(page.extract_text())\n"
|
|
217
|
+
"```\n"
|
|
218
|
+
"</tool_use_error>"
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# If file is too large and no pagination provided (only check if limits are enabled)
|
|
223
|
+
try:
|
|
224
|
+
size_bytes = Path(file_path).stat().st_size
|
|
225
|
+
except Exception:
|
|
226
|
+
size_bytes = 0
|
|
227
|
+
|
|
228
|
+
is_image_file = _is_supported_image_file(file_path)
|
|
229
|
+
if is_image_file:
|
|
230
|
+
if size_bytes > const.READ_MAX_IMAGE_BYTES:
|
|
231
|
+
size_mb = size_bytes / (1024 * 1024)
|
|
232
|
+
return model.ToolResultItem(
|
|
233
|
+
status="error",
|
|
234
|
+
output=(
|
|
235
|
+
f"<tool_use_error>Image size ({size_mb:.2f}MB) exceeds maximum supported size (4.00MB) for inline transfer.</tool_use_error>"
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
try:
|
|
239
|
+
mime_type = _image_mime_type(file_path)
|
|
240
|
+
data_url = _encode_image_to_data_url(file_path, mime_type)
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
return model.ToolResultItem(
|
|
243
|
+
status="error",
|
|
244
|
+
output=f"<tool_use_error>Failed to read image file: {exc}</tool_use_error>",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
_track_file_access(file_path)
|
|
248
|
+
size_kb = size_bytes / 1024.0 if size_bytes else 0.0
|
|
249
|
+
output_text = f"[image] {Path(file_path).name} ({size_kb:.1f}KB)"
|
|
250
|
+
image_part = model.ImageURLPart(image_url=model.ImageURLPart.ImageURL(url=data_url, id=None))
|
|
251
|
+
return model.ToolResultItem(status="success", output=output_text, images=[image_part])
|
|
252
|
+
|
|
253
|
+
if (
|
|
254
|
+
not is_image_file
|
|
255
|
+
and max_kb is not None
|
|
256
|
+
and args.offset is None
|
|
257
|
+
and args.limit is None
|
|
258
|
+
and size_bytes > max_kb * 1024
|
|
259
|
+
):
|
|
260
|
+
size_kb = size_bytes / 1024.0
|
|
261
|
+
return model.ToolResultItem(
|
|
262
|
+
status="error",
|
|
263
|
+
output=(
|
|
264
|
+
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."
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
offset = 1 if args.offset is None or args.offset < 1 else int(args.offset)
|
|
269
|
+
limit = None if args.limit is None else int(args.limit)
|
|
270
|
+
if limit is not None and limit < 0:
|
|
271
|
+
limit = 0
|
|
272
|
+
|
|
273
|
+
# Stream file line-by-line and build response
|
|
274
|
+
read_result: ReadSegmentResult | None = None
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
read_result = await asyncio.to_thread(
|
|
278
|
+
_read_segment,
|
|
279
|
+
ReadOptions(
|
|
280
|
+
file_path=file_path,
|
|
281
|
+
offset=offset,
|
|
282
|
+
limit=limit,
|
|
283
|
+
char_limit_per_line=char_per_line,
|
|
284
|
+
global_line_cap=line_cap,
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
except FileNotFoundError:
|
|
289
|
+
return model.ToolResultItem(
|
|
290
|
+
status="error",
|
|
291
|
+
output="<tool_use_error>File does not exist.</tool_use_error>",
|
|
292
|
+
)
|
|
293
|
+
except IsADirectoryError:
|
|
294
|
+
return model.ToolResultItem(
|
|
295
|
+
status="error",
|
|
296
|
+
output="<tool_use_error>Illegal operation on a directory. read</tool_use_error>",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# If offset beyond total lines, emit system reminder warning
|
|
300
|
+
if offset > max(read_result.total_lines, 0):
|
|
301
|
+
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>"
|
|
302
|
+
# Update FileTracker (we still consider it as a read attempt)
|
|
303
|
+
_track_file_access(file_path)
|
|
304
|
+
return model.ToolResultItem(status="success", output=warn)
|
|
305
|
+
|
|
306
|
+
# After limit/offset, if total selected chars exceed limit, error (only check if limits are enabled)
|
|
307
|
+
if max_chars is not None and read_result.selected_chars_count > max_chars:
|
|
308
|
+
return model.ToolResultItem(
|
|
309
|
+
status="error",
|
|
310
|
+
output=(
|
|
311
|
+
f"File content ({read_result.selected_chars_count} chars) exceeds maximum allowed tokens ({max_chars}). Please use offset and limit parameters to read specific portions of the file, or use the `rg` command to search for specific content."
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Build display with numbering and reminders
|
|
316
|
+
lines_out: list[str] = [_format_numbered_line(no, content) for no, content in read_result.selected_lines]
|
|
317
|
+
if read_result.remaining_selected_beyond_cap > 0:
|
|
318
|
+
lines_out.append(f"... (more {read_result.remaining_selected_beyond_cap} lines are truncated)")
|
|
319
|
+
read_result_str = "\n".join(lines_out)
|
|
320
|
+
# if read_result_str:
|
|
321
|
+
# read_result_str += "\n\n" + SYSTEM_REMINDER_MALICIOUS
|
|
322
|
+
|
|
323
|
+
# Update FileTracker with last modified time
|
|
324
|
+
_track_file_access(file_path)
|
|
325
|
+
|
|
326
|
+
return model.ToolResultItem(status="success", output=read_result_str)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Writes a file to the local filesystem.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
This tool will overwrite the existing file if there is one at the provided path.
|
|
5
|
+
If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.
|
|
6
|
+
ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
|
7
|
+
NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
|
8
|
+
Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.
|