crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Diff utilities for file edit/write tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compute_diff(
|
|
9
|
+
old: str,
|
|
10
|
+
new: str,
|
|
11
|
+
filepath: str = "",
|
|
12
|
+
context_lines: int = 3,
|
|
13
|
+
) -> dict:
|
|
14
|
+
"""Compute a structured diff between old and new file content.
|
|
15
|
+
|
|
16
|
+
Returns a dict with:
|
|
17
|
+
line_range - (start, end) 1-indexed line range of first change
|
|
18
|
+
diff_text - unified diff string (compact, for model)
|
|
19
|
+
display - formatted diff string (for terminal display)
|
|
20
|
+
stats - {"added": N, "removed": N}
|
|
21
|
+
"""
|
|
22
|
+
# ``unified_diff`` supplies separators through ``lineterm``. Passing
|
|
23
|
+
# keepends=True and then joining the result inserted blank lines between
|
|
24
|
+
# every diff row in terminal and desktop renderers.
|
|
25
|
+
old_lines = old.splitlines(keepends=False)
|
|
26
|
+
new_lines = new.splitlines(keepends=False)
|
|
27
|
+
|
|
28
|
+
diff_lines = list(difflib.unified_diff(
|
|
29
|
+
old_lines,
|
|
30
|
+
new_lines,
|
|
31
|
+
fromfile=filepath,
|
|
32
|
+
tofile=filepath,
|
|
33
|
+
lineterm="",
|
|
34
|
+
n=context_lines,
|
|
35
|
+
))
|
|
36
|
+
|
|
37
|
+
if not diff_lines:
|
|
38
|
+
return {
|
|
39
|
+
"line_range": None,
|
|
40
|
+
"diff_text": "",
|
|
41
|
+
"display": "",
|
|
42
|
+
"stats": {"added": 0, "removed": 0},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++"))
|
|
46
|
+
removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---"))
|
|
47
|
+
|
|
48
|
+
first_change_line = _find_first_change_line(old_lines, new_lines)
|
|
49
|
+
last_change_line = _find_last_change_line(old_lines, new_lines)
|
|
50
|
+
|
|
51
|
+
diff_text = "\n".join(diff_lines)
|
|
52
|
+
|
|
53
|
+
max_diff_chars = 10_000
|
|
54
|
+
if len(diff_text) > max_diff_chars:
|
|
55
|
+
diff_text = diff_text[:max_diff_chars] + "\n... (diff truncated)"
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"line_range": (first_change_line, last_change_line),
|
|
59
|
+
"diff_text": diff_text,
|
|
60
|
+
"display": diff_text,
|
|
61
|
+
"stats": {"added": added, "removed": removed},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _find_first_change_line(old: list[str], new: list[str]) -> int:
|
|
66
|
+
"""Find the 1-indexed line number of the first difference."""
|
|
67
|
+
for i, (a, b) in enumerate(zip(old, new)):
|
|
68
|
+
if a != b:
|
|
69
|
+
return i + 1
|
|
70
|
+
return min(len(old), len(new)) + 1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _find_last_change_line(old: list[str], new: list[str]) -> int:
|
|
74
|
+
"""Find the 1-indexed line number (in new file) of the last difference."""
|
|
75
|
+
old_rev = list(reversed(old))
|
|
76
|
+
new_rev = list(reversed(new))
|
|
77
|
+
tail_common = 0
|
|
78
|
+
for a, b in zip(old_rev, new_rev):
|
|
79
|
+
if a != b:
|
|
80
|
+
break
|
|
81
|
+
tail_common += 1
|
|
82
|
+
return max(1, len(new) - tail_common)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def format_edit_summary(
|
|
86
|
+
filepath: str,
|
|
87
|
+
diff_info: dict,
|
|
88
|
+
replacements: int = 1,
|
|
89
|
+
) -> tuple[str, str]:
|
|
90
|
+
"""Format diff info into (result_for_model, result_for_display).
|
|
91
|
+
|
|
92
|
+
Returns a pair of strings optimized for model consumption and
|
|
93
|
+
terminal display respectively.
|
|
94
|
+
"""
|
|
95
|
+
lr = diff_info["line_range"]
|
|
96
|
+
stats = diff_info["stats"]
|
|
97
|
+
|
|
98
|
+
if lr:
|
|
99
|
+
line_desc = f"lines {lr[0]}-{lr[1]}" if lr[0] != lr[1] else f"line {lr[0]}"
|
|
100
|
+
else:
|
|
101
|
+
line_desc = ""
|
|
102
|
+
|
|
103
|
+
stat_parts = []
|
|
104
|
+
if stats["added"]:
|
|
105
|
+
stat_parts.append(f"+{stats['added']}")
|
|
106
|
+
if stats["removed"]:
|
|
107
|
+
stat_parts.append(f"-{stats['removed']}")
|
|
108
|
+
stat_str = ", ".join(stat_parts)
|
|
109
|
+
|
|
110
|
+
model_msg = f"Updated {filepath}"
|
|
111
|
+
if line_desc:
|
|
112
|
+
model_msg += f" ({line_desc})"
|
|
113
|
+
if stat_str:
|
|
114
|
+
model_msg += f" [{stat_str}]"
|
|
115
|
+
if replacements > 1:
|
|
116
|
+
model_msg += f" ({replacements} replacements)"
|
|
117
|
+
|
|
118
|
+
display_parts = [model_msg]
|
|
119
|
+
if diff_info["display"]:
|
|
120
|
+
display_parts.append(diff_info["display"])
|
|
121
|
+
display_msg = "\n".join(display_parts)
|
|
122
|
+
|
|
123
|
+
return model_msg, display_msg
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""FileEditTool — string replacement editing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from crabcode_core.logging_utils import get_logger
|
|
10
|
+
from crabcode_core.text_io import normalize_newlines, read_utf8_text, write_utf8_text
|
|
11
|
+
from crabcode_core.tools.diff_utils import compute_diff, format_edit_summary
|
|
12
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
13
|
+
from crabcode_core.io_worker import run_file_tool
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _get_lsp_diagnostics(
|
|
19
|
+
file_path: str | Path,
|
|
20
|
+
context: ToolContext,
|
|
21
|
+
) -> str:
|
|
22
|
+
"""Collect LSP diagnostics for the edited file, return formatted string."""
|
|
23
|
+
lsp = context.lsp_manager
|
|
24
|
+
if lsp is None:
|
|
25
|
+
return ""
|
|
26
|
+
from crabcode_core.lsp.diagnostics import collect_and_format_diagnostics
|
|
27
|
+
return await collect_and_format_diagnostics(lsp, str(file_path))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FileEditTool(Tool):
|
|
31
|
+
name = "Edit"
|
|
32
|
+
description = "Edit a file by replacing a specific string."
|
|
33
|
+
is_read_only = False
|
|
34
|
+
is_concurrency_safe = False
|
|
35
|
+
input_schema = {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"file_path": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "The path of the file to edit.",
|
|
41
|
+
},
|
|
42
|
+
"old_string": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "The exact string to find and replace.",
|
|
45
|
+
},
|
|
46
|
+
"new_string": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"description": "The replacement string.",
|
|
49
|
+
},
|
|
50
|
+
"replace_all": {
|
|
51
|
+
"type": "boolean",
|
|
52
|
+
"description": "Replace all occurrences (default: false).",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
"required": ["file_path", "old_string", "new_string"],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
59
|
+
return (
|
|
60
|
+
"Edit a file by performing exact string replacements. "
|
|
61
|
+
"The old_string must uniquely identify the text to replace — "
|
|
62
|
+
"if it matches multiple locations, the edit will FAIL. Include "
|
|
63
|
+
"surrounding context lines to make the match unique. Preserves "
|
|
64
|
+
"exact indentation (tabs/spaces) as it appears in the file. "
|
|
65
|
+
"Use replace_all=true to replace all occurrences (useful for "
|
|
66
|
+
"renaming a variable across the file).\n\n"
|
|
67
|
+
"IMPORTANT: You MUST read the file before editing it. Never "
|
|
68
|
+
"guess the file content.\n\n"
|
|
69
|
+
"GOOD example — unique match with context:\n"
|
|
70
|
+
' old_string: "def calculate(a, b):\\n return a + b"\n'
|
|
71
|
+
' new_string: "def calculate(a, b):\\n return a * b"\n\n'
|
|
72
|
+
"BAD example — too short, likely matches multiple locations:\n"
|
|
73
|
+
' old_string: "return"\n'
|
|
74
|
+
' new_string: "return None"'
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def validate_input(self, tool_input: dict[str, Any]) -> str | None:
|
|
78
|
+
missing = [
|
|
79
|
+
key
|
|
80
|
+
for key in ("file_path", "old_string", "new_string")
|
|
81
|
+
if key not in tool_input
|
|
82
|
+
]
|
|
83
|
+
if missing:
|
|
84
|
+
return f"missing required field(s): {', '.join(missing)}"
|
|
85
|
+
|
|
86
|
+
old_string = tool_input.get("old_string")
|
|
87
|
+
new_string = tool_input.get("new_string")
|
|
88
|
+
if not isinstance(old_string, str) or not isinstance(new_string, str):
|
|
89
|
+
return "old_string and new_string must be strings"
|
|
90
|
+
if old_string == "":
|
|
91
|
+
return "old_string must not be empty"
|
|
92
|
+
if old_string == new_string:
|
|
93
|
+
return "old_string and new_string must be different"
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
async def call(
|
|
97
|
+
self, tool_input: dict[str, Any], context: ToolContext,
|
|
98
|
+
) -> ToolResult:
|
|
99
|
+
result = await run_file_tool(self.name, tool_input, context)
|
|
100
|
+
if not result.is_error and result.data:
|
|
101
|
+
result.result_for_model += await _get_lsp_diagnostics(result.data["file_path"], context)
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
async def _call_local(
|
|
105
|
+
self,
|
|
106
|
+
tool_input: dict[str, Any],
|
|
107
|
+
context: ToolContext,
|
|
108
|
+
) -> ToolResult:
|
|
109
|
+
file_path = tool_input.get("file_path", "")
|
|
110
|
+
old_string = tool_input.get("old_string", "")
|
|
111
|
+
new_string = tool_input.get("new_string", "")
|
|
112
|
+
replace_all = tool_input.get("replace_all", False)
|
|
113
|
+
|
|
114
|
+
if not file_path:
|
|
115
|
+
return ToolResult(
|
|
116
|
+
result_for_model="Error: file_path is required",
|
|
117
|
+
is_error=True,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
path = Path(file_path)
|
|
121
|
+
if not path.is_absolute():
|
|
122
|
+
path = Path(context.cwd) / path
|
|
123
|
+
|
|
124
|
+
if not path.exists():
|
|
125
|
+
return ToolResult(
|
|
126
|
+
result_for_model=f"Error: file not found: {path}",
|
|
127
|
+
is_error=True,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
source = read_utf8_text(path)
|
|
132
|
+
content = source.text
|
|
133
|
+
except Exception as e:
|
|
134
|
+
return ToolResult(
|
|
135
|
+
result_for_model=f"Error reading file: {e}",
|
|
136
|
+
is_error=True,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
normalized_old = normalize_newlines(old_string, "\n")
|
|
140
|
+
pattern = re.compile(
|
|
141
|
+
r"(?:\r\n|\r|\n)".join(
|
|
142
|
+
re.escape(part) for part in normalized_old.split("\n")
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
matches = list(pattern.finditer(content))
|
|
146
|
+
count = len(matches)
|
|
147
|
+
|
|
148
|
+
if count == 0:
|
|
149
|
+
# Give the model a head start: show the first 120 chars of the
|
|
150
|
+
# old_string it provided so it can spot whitespace/encoding drift,
|
|
151
|
+
# and remind it to re-read before retrying.
|
|
152
|
+
snippet = repr(old_string[:120]) + ("..." if len(old_string) > 120 else "")
|
|
153
|
+
return ToolResult(
|
|
154
|
+
result_for_model=(
|
|
155
|
+
f"Error: old_string not found in {path}.\n"
|
|
156
|
+
f"The string you provided (first 120 chars): {snippet}\n"
|
|
157
|
+
"Common causes: wrong indentation (tabs vs spaces), "
|
|
158
|
+
"stale content (file was already modified), or "
|
|
159
|
+
"the string was never in this file.\n"
|
|
160
|
+
"Fix: use the Read tool to re-read the file and verify "
|
|
161
|
+
"the exact content before retrying the Edit."
|
|
162
|
+
),
|
|
163
|
+
is_error=True,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if count > 1 and not replace_all:
|
|
167
|
+
return ToolResult(
|
|
168
|
+
result_for_model=(
|
|
169
|
+
f"Error: old_string found {count} times in {path}. "
|
|
170
|
+
"Use replace_all=true to replace all occurrences, "
|
|
171
|
+
"or provide more context to make the match unique."
|
|
172
|
+
),
|
|
173
|
+
is_error=True,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def render_replacement(match: re.Match[str]) -> str:
|
|
177
|
+
matched = match.group(0)
|
|
178
|
+
replacement_newline = source.newline
|
|
179
|
+
if "\r\n" in matched:
|
|
180
|
+
replacement_newline = "\r\n"
|
|
181
|
+
elif "\n" in matched:
|
|
182
|
+
replacement_newline = "\n"
|
|
183
|
+
elif "\r" in matched:
|
|
184
|
+
replacement_newline = "\r"
|
|
185
|
+
return (
|
|
186
|
+
normalize_newlines(new_string, replacement_newline)
|
|
187
|
+
if replacement_newline is not None
|
|
188
|
+
else new_string
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if replace_all:
|
|
192
|
+
new_content = pattern.sub(render_replacement, content)
|
|
193
|
+
else:
|
|
194
|
+
match = matches[0]
|
|
195
|
+
new_content = (
|
|
196
|
+
content[:match.start()]
|
|
197
|
+
+ render_replacement(match)
|
|
198
|
+
+ content[match.end():]
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Track snapshot before writing
|
|
202
|
+
if context.session_id:
|
|
203
|
+
try:
|
|
204
|
+
from crabcode_core.snapshot.tracker import track_snapshot_for_file
|
|
205
|
+
track_snapshot_for_file(
|
|
206
|
+
cwd=context.cwd,
|
|
207
|
+
session_id=context.session_id,
|
|
208
|
+
file_path=str(path),
|
|
209
|
+
old_content=content,
|
|
210
|
+
action="modify",
|
|
211
|
+
)
|
|
212
|
+
except Exception:
|
|
213
|
+
logger.debug("Failed to track snapshot for edit", exc_info=True)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
write_utf8_text(path, new_content, has_bom=source.has_bom)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
return ToolResult(
|
|
219
|
+
result_for_model=f"Error writing file: {e}",
|
|
220
|
+
is_error=True,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
replaced = count if replace_all else 1
|
|
224
|
+
|
|
225
|
+
diff_info = compute_diff(content, new_content, str(path))
|
|
226
|
+
model_msg, display_msg = format_edit_summary(
|
|
227
|
+
str(path), diff_info, replacements=replaced
|
|
228
|
+
)
|
|
229
|
+
lsp_msg = await _get_lsp_diagnostics(path, context)
|
|
230
|
+
|
|
231
|
+
return ToolResult(
|
|
232
|
+
data={
|
|
233
|
+
"file_path": str(path),
|
|
234
|
+
"replacements": replaced,
|
|
235
|
+
"line_range": diff_info["line_range"],
|
|
236
|
+
"stats": diff_info["stats"],
|
|
237
|
+
},
|
|
238
|
+
result_for_model=model_msg + lsp_msg,
|
|
239
|
+
result_for_display=display_msg,
|
|
240
|
+
)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""FileReadTool — read file contents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from crabcode_core.logging_utils import get_logger
|
|
10
|
+
from crabcode_core.text_io import read_utf8_text
|
|
11
|
+
from crabcode_core.tools._input_helpers import first_non_empty_str
|
|
12
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
13
|
+
from crabcode_core.io_worker import run_file_tool
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FileReadTool(Tool):
|
|
19
|
+
name = "Read"
|
|
20
|
+
description = "Read a text file or view a local image, including a saved PSD composite preview."
|
|
21
|
+
is_read_only = True
|
|
22
|
+
is_concurrency_safe = True
|
|
23
|
+
input_schema = {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"file_path": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"minLength": 1,
|
|
29
|
+
"description": "The absolute or relative path of the file to read.",
|
|
30
|
+
},
|
|
31
|
+
"offset": {
|
|
32
|
+
"type": "integer",
|
|
33
|
+
"description": "Text only: line number to start reading from (1-based). Negative values count from end. Ignored for images.",
|
|
34
|
+
},
|
|
35
|
+
"limit": {
|
|
36
|
+
"type": "integer",
|
|
37
|
+
"description": "Text only: number of lines to read. Ignored for images.",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
"required": ["file_path"],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
44
|
+
return (
|
|
45
|
+
"Read text or visually inspect an image from the local filesystem. "
|
|
46
|
+
"For images, the actual image content is returned to you for visual analysis, "
|
|
47
|
+
"not just a filename or a UI preview. Supports PNG, JPEG, WebP, GIF, BMP, TIFF "
|
|
48
|
+
"and saved PSD composite previews. Non-native formats are converted to PNG; "
|
|
49
|
+
"large images may be resized. Animated/multipage images return the first frame/page. "
|
|
50
|
+
"PSD previews show the saved merged appearance, not editable layers. "
|
|
51
|
+
"offset and limit apply only to text and are ignored for images.\n\n"
|
|
52
|
+
"You can optionally specify a line offset and limit (especially "
|
|
53
|
+
"useful for large files), but it's recommended to read the whole "
|
|
54
|
+
"file by not providing these parameters.\n\n"
|
|
55
|
+
"Lines in the output are numbered starting at 1, in the format "
|
|
56
|
+
"LINE_NUMBER|LINE_CONTENT. The LINE_NUMBER prefix is metadata — "
|
|
57
|
+
"do NOT treat it as part of the actual code.\n\n"
|
|
58
|
+
"IMPORTANT: You MUST read a file at least once before editing it. "
|
|
59
|
+
"You can call Read on multiple files in a single response to read "
|
|
60
|
+
"them in parallel.\n\n"
|
|
61
|
+
"If the file exists but is empty, you will receive 'File is empty.'.\n"
|
|
62
|
+
"If the file does not exist, you will receive an error."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def call(
|
|
66
|
+
self, tool_input: dict[str, Any], context: ToolContext,
|
|
67
|
+
) -> ToolResult:
|
|
68
|
+
result = await run_file_tool(self.name, tool_input, context)
|
|
69
|
+
if (not result.is_error and result.data and not result.images
|
|
70
|
+
and context.lsp_manager is not None):
|
|
71
|
+
asyncio.create_task(_lsp_touch(result.data["file_path"], context.lsp_manager))
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
async def _call_local(
|
|
75
|
+
self,
|
|
76
|
+
tool_input: dict[str, Any],
|
|
77
|
+
context: ToolContext,
|
|
78
|
+
) -> ToolResult:
|
|
79
|
+
file_path = first_non_empty_str(
|
|
80
|
+
tool_input,
|
|
81
|
+
("file_path", "filePath", "path", "target_file", "filepath", "file"),
|
|
82
|
+
)
|
|
83
|
+
offset = tool_input.get("offset")
|
|
84
|
+
limit = tool_input.get("limit")
|
|
85
|
+
|
|
86
|
+
if not file_path:
|
|
87
|
+
return ToolResult(
|
|
88
|
+
result_for_model=(
|
|
89
|
+
"Error: file_path is required. Pass the file path as \"file_path\" "
|
|
90
|
+
"(aliases: filePath, path, target_file, filepath, file)."
|
|
91
|
+
),
|
|
92
|
+
is_error=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
path = Path(file_path).expanduser()
|
|
96
|
+
if not path.is_absolute():
|
|
97
|
+
path = Path(context.cwd) / path
|
|
98
|
+
|
|
99
|
+
if not path.exists():
|
|
100
|
+
return ToolResult(
|
|
101
|
+
result_for_model=f"Error: file not found: {path}",
|
|
102
|
+
is_error=True,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if not path.is_file():
|
|
106
|
+
return ToolResult(
|
|
107
|
+
result_for_model=f"Error: not a file: {path}",
|
|
108
|
+
is_error=True,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
from crabcode_core.image_io import is_image_file, load_image_preview
|
|
113
|
+
|
|
114
|
+
if is_image_file(path):
|
|
115
|
+
preview = load_image_preview(path)
|
|
116
|
+
context.emit_image(preview.data, preview.media_type)
|
|
117
|
+
message = f"Image read: {path} ({preview.summary()})"
|
|
118
|
+
if offset is not None or limit is not None:
|
|
119
|
+
message += "\nText offset/limit ignored for this image."
|
|
120
|
+
return ToolResult(
|
|
121
|
+
data={"file_path": str(path), "media_type": preview.media_type,
|
|
122
|
+
"width": preview.width, "height": preview.height,
|
|
123
|
+
"source_format": preview.source_format},
|
|
124
|
+
result_for_model=message,
|
|
125
|
+
result_for_display=message,
|
|
126
|
+
images=[context.emitted_images[-1]],
|
|
127
|
+
)
|
|
128
|
+
content = read_utf8_text(path).text
|
|
129
|
+
except Exception as e:
|
|
130
|
+
return ToolResult(
|
|
131
|
+
result_for_model=f"Error reading file: {e}",
|
|
132
|
+
is_error=True,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not content:
|
|
136
|
+
return ToolResult(
|
|
137
|
+
result_for_model="File is empty.",
|
|
138
|
+
data={"file_path": str(path), "content": ""},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
lines = content.splitlines(keepends=True)
|
|
142
|
+
total_lines = len(lines)
|
|
143
|
+
|
|
144
|
+
if offset is not None:
|
|
145
|
+
if offset < 0:
|
|
146
|
+
start = max(0, total_lines + offset)
|
|
147
|
+
else:
|
|
148
|
+
start = max(0, offset - 1)
|
|
149
|
+
else:
|
|
150
|
+
start = 0
|
|
151
|
+
|
|
152
|
+
if limit is not None:
|
|
153
|
+
end = min(total_lines, start + limit)
|
|
154
|
+
else:
|
|
155
|
+
end = total_lines
|
|
156
|
+
|
|
157
|
+
selected = lines[start:end]
|
|
158
|
+
|
|
159
|
+
numbered = []
|
|
160
|
+
for i, line in enumerate(selected, start=start + 1):
|
|
161
|
+
numbered.append(f"{i:6d}|{line.rstrip()}")
|
|
162
|
+
|
|
163
|
+
result_text = "\n".join(numbered)
|
|
164
|
+
|
|
165
|
+
# Background LSP touch — notifies language servers about this file
|
|
166
|
+
# so diagnostics are ready when the user writes later. Non-blocking.
|
|
167
|
+
lsp = context.lsp_manager
|
|
168
|
+
if lsp is not None:
|
|
169
|
+
try:
|
|
170
|
+
asyncio.get_event_loop().create_task(
|
|
171
|
+
_lsp_touch(str(path), lsp),
|
|
172
|
+
)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
return ToolResult(
|
|
177
|
+
data={"file_path": str(path), "content": content, "total_lines": total_lines},
|
|
178
|
+
result_for_model=result_text,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def _lsp_touch(file_path: str, lsp: Any) -> None:
|
|
183
|
+
"""Fire-and-forget LSP touch_file call."""
|
|
184
|
+
try:
|
|
185
|
+
await lsp.touch_file(file_path)
|
|
186
|
+
except Exception:
|
|
187
|
+
logger.debug("LSP touch_file failed for %s", file_path, exc_info=True)
|