llmcode-cli 1.0.0__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.
- llm_code/__init__.py +2 -0
- llm_code/analysis/__init__.py +6 -0
- llm_code/analysis/cache.py +33 -0
- llm_code/analysis/engine.py +256 -0
- llm_code/analysis/go_rules.py +114 -0
- llm_code/analysis/js_rules.py +84 -0
- llm_code/analysis/python_rules.py +311 -0
- llm_code/analysis/rules.py +140 -0
- llm_code/analysis/rust_rules.py +108 -0
- llm_code/analysis/universal_rules.py +111 -0
- llm_code/api/__init__.py +0 -0
- llm_code/api/client.py +90 -0
- llm_code/api/errors.py +73 -0
- llm_code/api/openai_compat.py +390 -0
- llm_code/api/provider.py +35 -0
- llm_code/api/sse.py +52 -0
- llm_code/api/types.py +140 -0
- llm_code/cli/__init__.py +0 -0
- llm_code/cli/commands.py +70 -0
- llm_code/cli/image.py +122 -0
- llm_code/cli/render.py +214 -0
- llm_code/cli/status_line.py +79 -0
- llm_code/cli/streaming.py +92 -0
- llm_code/cli/tui_main.py +220 -0
- llm_code/computer_use/__init__.py +11 -0
- llm_code/computer_use/app_detect.py +49 -0
- llm_code/computer_use/app_tier.py +57 -0
- llm_code/computer_use/coordinator.py +99 -0
- llm_code/computer_use/input_control.py +71 -0
- llm_code/computer_use/screenshot.py +93 -0
- llm_code/cron/__init__.py +13 -0
- llm_code/cron/parser.py +145 -0
- llm_code/cron/scheduler.py +135 -0
- llm_code/cron/storage.py +126 -0
- llm_code/enterprise/__init__.py +1 -0
- llm_code/enterprise/audit.py +59 -0
- llm_code/enterprise/auth.py +26 -0
- llm_code/enterprise/oidc.py +95 -0
- llm_code/enterprise/rbac.py +65 -0
- llm_code/harness/__init__.py +5 -0
- llm_code/harness/config.py +33 -0
- llm_code/harness/engine.py +129 -0
- llm_code/harness/guides.py +41 -0
- llm_code/harness/sensors.py +68 -0
- llm_code/harness/templates.py +84 -0
- llm_code/hida/__init__.py +1 -0
- llm_code/hida/classifier.py +187 -0
- llm_code/hida/engine.py +49 -0
- llm_code/hida/profiles.py +95 -0
- llm_code/hida/types.py +28 -0
- llm_code/ide/__init__.py +1 -0
- llm_code/ide/bridge.py +80 -0
- llm_code/ide/detector.py +76 -0
- llm_code/ide/server.py +169 -0
- llm_code/logging.py +29 -0
- llm_code/lsp/__init__.py +0 -0
- llm_code/lsp/client.py +298 -0
- llm_code/lsp/detector.py +42 -0
- llm_code/lsp/manager.py +56 -0
- llm_code/lsp/tools.py +288 -0
- llm_code/marketplace/__init__.py +0 -0
- llm_code/marketplace/builtin_registry.py +102 -0
- llm_code/marketplace/installer.py +162 -0
- llm_code/marketplace/plugin.py +78 -0
- llm_code/marketplace/registry.py +360 -0
- llm_code/mcp/__init__.py +0 -0
- llm_code/mcp/bridge.py +87 -0
- llm_code/mcp/client.py +117 -0
- llm_code/mcp/health.py +120 -0
- llm_code/mcp/manager.py +214 -0
- llm_code/mcp/oauth.py +219 -0
- llm_code/mcp/transport.py +254 -0
- llm_code/mcp/types.py +53 -0
- llm_code/remote/__init__.py +0 -0
- llm_code/remote/client.py +136 -0
- llm_code/remote/protocol.py +22 -0
- llm_code/remote/server.py +275 -0
- llm_code/remote/ssh_proxy.py +56 -0
- llm_code/runtime/__init__.py +0 -0
- llm_code/runtime/auto_commit.py +56 -0
- llm_code/runtime/auto_diagnose.py +62 -0
- llm_code/runtime/checkpoint.py +70 -0
- llm_code/runtime/checkpoint_recovery.py +142 -0
- llm_code/runtime/compaction.py +35 -0
- llm_code/runtime/compressor.py +415 -0
- llm_code/runtime/config.py +533 -0
- llm_code/runtime/context.py +49 -0
- llm_code/runtime/conversation.py +921 -0
- llm_code/runtime/cost_tracker.py +126 -0
- llm_code/runtime/dream.py +127 -0
- llm_code/runtime/file_protection.py +150 -0
- llm_code/runtime/hardware.py +85 -0
- llm_code/runtime/hooks.py +223 -0
- llm_code/runtime/indexer.py +230 -0
- llm_code/runtime/knowledge_compiler.py +232 -0
- llm_code/runtime/memory.py +132 -0
- llm_code/runtime/memory_layers.py +467 -0
- llm_code/runtime/memory_lint.py +252 -0
- llm_code/runtime/model_aliases.py +37 -0
- llm_code/runtime/ollama.py +93 -0
- llm_code/runtime/overlay.py +124 -0
- llm_code/runtime/permissions.py +200 -0
- llm_code/runtime/plan.py +45 -0
- llm_code/runtime/prompt.py +238 -0
- llm_code/runtime/repo_map.py +174 -0
- llm_code/runtime/sandbox.py +116 -0
- llm_code/runtime/session.py +268 -0
- llm_code/runtime/skill_resolver.py +61 -0
- llm_code/runtime/skills.py +133 -0
- llm_code/runtime/speculative.py +75 -0
- llm_code/runtime/streaming_executor.py +216 -0
- llm_code/runtime/telemetry.py +196 -0
- llm_code/runtime/token_budget.py +26 -0
- llm_code/runtime/vcr.py +142 -0
- llm_code/runtime/vision.py +102 -0
- llm_code/swarm/__init__.py +1 -0
- llm_code/swarm/backend_subprocess.py +108 -0
- llm_code/swarm/backend_tmux.py +103 -0
- llm_code/swarm/backend_worktree.py +306 -0
- llm_code/swarm/checkpoint.py +74 -0
- llm_code/swarm/coordinator.py +236 -0
- llm_code/swarm/mailbox.py +88 -0
- llm_code/swarm/manager.py +202 -0
- llm_code/swarm/memory_sync.py +80 -0
- llm_code/swarm/recovery.py +21 -0
- llm_code/swarm/team.py +67 -0
- llm_code/swarm/types.py +31 -0
- llm_code/task/__init__.py +16 -0
- llm_code/task/diagnostics.py +93 -0
- llm_code/task/manager.py +162 -0
- llm_code/task/types.py +112 -0
- llm_code/task/verifier.py +104 -0
- llm_code/tools/__init__.py +0 -0
- llm_code/tools/agent.py +145 -0
- llm_code/tools/agent_roles.py +82 -0
- llm_code/tools/base.py +94 -0
- llm_code/tools/bash.py +565 -0
- llm_code/tools/computer_use_tools.py +278 -0
- llm_code/tools/coordinator_tool.py +75 -0
- llm_code/tools/cron_create.py +90 -0
- llm_code/tools/cron_delete.py +49 -0
- llm_code/tools/cron_list.py +51 -0
- llm_code/tools/deferred.py +92 -0
- llm_code/tools/dump.py +116 -0
- llm_code/tools/edit_file.py +282 -0
- llm_code/tools/git_tools.py +531 -0
- llm_code/tools/glob_search.py +112 -0
- llm_code/tools/grep_search.py +144 -0
- llm_code/tools/ide_diagnostics.py +59 -0
- llm_code/tools/ide_open.py +58 -0
- llm_code/tools/ide_selection.py +52 -0
- llm_code/tools/memory_tools.py +138 -0
- llm_code/tools/multi_edit.py +143 -0
- llm_code/tools/notebook_edit.py +107 -0
- llm_code/tools/notebook_read.py +81 -0
- llm_code/tools/parsing.py +63 -0
- llm_code/tools/read_file.py +154 -0
- llm_code/tools/registry.py +58 -0
- llm_code/tools/search_backends/__init__.py +56 -0
- llm_code/tools/search_backends/brave.py +56 -0
- llm_code/tools/search_backends/duckduckgo.py +129 -0
- llm_code/tools/search_backends/searxng.py +71 -0
- llm_code/tools/search_backends/tavily.py +73 -0
- llm_code/tools/swarm_create.py +109 -0
- llm_code/tools/swarm_delete.py +95 -0
- llm_code/tools/swarm_list.py +44 -0
- llm_code/tools/swarm_message.py +109 -0
- llm_code/tools/task_close.py +79 -0
- llm_code/tools/task_plan.py +79 -0
- llm_code/tools/task_verify.py +90 -0
- llm_code/tools/tool_search.py +65 -0
- llm_code/tools/web_common.py +258 -0
- llm_code/tools/web_fetch.py +223 -0
- llm_code/tools/web_search.py +280 -0
- llm_code/tools/write_file.py +118 -0
- llm_code/tui/__init__.py +1 -0
- llm_code/tui/app.py +2432 -0
- llm_code/tui/chat_view.py +82 -0
- llm_code/tui/chat_widgets.py +309 -0
- llm_code/tui/header_bar.py +46 -0
- llm_code/tui/input_bar.py +349 -0
- llm_code/tui/keybindings.py +142 -0
- llm_code/tui/marketplace.py +210 -0
- llm_code/tui/status_bar.py +72 -0
- llm_code/tui/theme.py +96 -0
- llm_code/utils/__init__.py +0 -0
- llm_code/utils/diff.py +111 -0
- llm_code/utils/errors.py +70 -0
- llm_code/utils/hyperlink.py +73 -0
- llm_code/utils/notebook.py +179 -0
- llm_code/utils/search.py +69 -0
- llm_code/utils/text_normalize.py +28 -0
- llm_code/utils/version_check.py +62 -0
- llm_code/vim/__init__.py +4 -0
- llm_code/vim/engine.py +51 -0
- llm_code/vim/motions.py +172 -0
- llm_code/vim/operators.py +183 -0
- llm_code/vim/text_objects.py +139 -0
- llm_code/vim/transitions.py +279 -0
- llm_code/vim/types.py +68 -0
- llm_code/voice/__init__.py +1 -0
- llm_code/voice/languages.py +43 -0
- llm_code/voice/recorder.py +136 -0
- llm_code/voice/stt.py +36 -0
- llm_code/voice/stt_anthropic.py +66 -0
- llm_code/voice/stt_google.py +32 -0
- llm_code/voice/stt_whisper.py +52 -0
- llmcode_cli-1.0.0.dist-info/METADATA +524 -0
- llmcode_cli-1.0.0.dist-info/RECORD +212 -0
- llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
- llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
- llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
llm_code/tools/dump.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""DAFC Dump -- concatenate repo source files for external LLM consumption."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_SKIP_DIRS = frozenset({
|
|
8
|
+
".git", "__pycache__", "node_modules", ".venv", "venv",
|
|
9
|
+
".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
10
|
+
"dist", "build", ".eggs",
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
_SKIP_EXTENSIONS = frozenset({
|
|
14
|
+
".pyc", ".pyo", ".so", ".dll", ".dylib", ".exe",
|
|
15
|
+
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
|
|
16
|
+
".woff", ".woff2", ".ttf", ".eot",
|
|
17
|
+
".zip", ".tar", ".gz", ".bz2", ".xz",
|
|
18
|
+
".db", ".sqlite", ".sqlite3",
|
|
19
|
+
".bin", ".dat",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
_MAX_SINGLE_FILE_BYTES = 50_000 # 50KB
|
|
23
|
+
_MAX_TOTAL_BYTES = 500_000 # 500KB
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class DumpResult:
|
|
28
|
+
text: str
|
|
29
|
+
file_count: int
|
|
30
|
+
total_lines: int
|
|
31
|
+
estimated_tokens: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def dump_codebase(
|
|
35
|
+
cwd: Path,
|
|
36
|
+
max_files: int = 200,
|
|
37
|
+
max_file_size: int = _MAX_SINGLE_FILE_BYTES,
|
|
38
|
+
max_total_size: int = _MAX_TOTAL_BYTES,
|
|
39
|
+
) -> DumpResult:
|
|
40
|
+
"""Walk cwd, concatenate source files into a single text dump.
|
|
41
|
+
|
|
42
|
+
Skips binary files, large files, and common non-source directories.
|
|
43
|
+
"""
|
|
44
|
+
files: list[Path] = []
|
|
45
|
+
_collect_files(cwd, cwd, files, max_files, max_file_size)
|
|
46
|
+
files.sort(key=lambda p: str(p.relative_to(cwd)))
|
|
47
|
+
|
|
48
|
+
parts: list[str] = []
|
|
49
|
+
total_lines = 0
|
|
50
|
+
total_bytes = 0
|
|
51
|
+
|
|
52
|
+
for f in files:
|
|
53
|
+
if total_bytes >= max_total_size:
|
|
54
|
+
break
|
|
55
|
+
try:
|
|
56
|
+
content = f.read_text(encoding="utf-8", errors="strict")
|
|
57
|
+
except (UnicodeDecodeError, OSError):
|
|
58
|
+
continue # skip binary / unreadable
|
|
59
|
+
|
|
60
|
+
rel_path = str(f.relative_to(cwd))
|
|
61
|
+
total_lines += content.count("\n") + (1 if content and not content.endswith("\n") else 0)
|
|
62
|
+
total_bytes += len(content.encode("utf-8"))
|
|
63
|
+
parts.append(f"--- file: {rel_path} ---\n{content}\n")
|
|
64
|
+
|
|
65
|
+
text = "".join(parts)
|
|
66
|
+
file_count = len(parts)
|
|
67
|
+
|
|
68
|
+
return DumpResult(
|
|
69
|
+
text=text,
|
|
70
|
+
file_count=file_count,
|
|
71
|
+
total_lines=total_lines,
|
|
72
|
+
estimated_tokens=len(text) // 4,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _collect_files(
|
|
77
|
+
base: Path,
|
|
78
|
+
current: Path,
|
|
79
|
+
out: list[Path],
|
|
80
|
+
limit: int,
|
|
81
|
+
max_file_size: int,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Recursively collect files, respecting skip rules and limits."""
|
|
84
|
+
if len(out) >= limit:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
entries = sorted(current.iterdir(), key=lambda p: p.name)
|
|
89
|
+
except PermissionError:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
for entry in entries:
|
|
93
|
+
if len(out) >= limit:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if entry.is_dir():
|
|
97
|
+
# Skip known non-source dirs and hidden dirs
|
|
98
|
+
if entry.name in _SKIP_DIRS or entry.name.startswith("."):
|
|
99
|
+
continue
|
|
100
|
+
# Skip egg-info directories (*.egg-info pattern)
|
|
101
|
+
if entry.name.endswith(".egg-info"):
|
|
102
|
+
continue
|
|
103
|
+
_collect_files(base, entry, out, limit, max_file_size)
|
|
104
|
+
elif entry.is_file():
|
|
105
|
+
if entry.suffix.lower() in _SKIP_EXTENSIONS:
|
|
106
|
+
continue
|
|
107
|
+
if entry.stat().st_size > max_file_size:
|
|
108
|
+
continue
|
|
109
|
+
# Quick binary check: look for null bytes in first 512 bytes
|
|
110
|
+
try:
|
|
111
|
+
head = entry.read_bytes()[:512]
|
|
112
|
+
if b"\x00" in head:
|
|
113
|
+
continue # likely binary
|
|
114
|
+
except OSError:
|
|
115
|
+
continue
|
|
116
|
+
out.append(entry)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""EditFileTool — search-and-replace within an existing file."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pathlib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from llm_code.runtime.file_protection import check_write
|
|
11
|
+
from llm_code.tools.base import PermissionLevel, Tool, ToolResult
|
|
12
|
+
from llm_code.utils.errors import friendly_error
|
|
13
|
+
from llm_code.utils.text_normalize import normalize_for_match
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from llm_code.runtime.overlay import OverlayFS
|
|
17
|
+
|
|
18
|
+
_MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class EditApplyResult:
|
|
23
|
+
"""Result of applying a search-and-replace edit to content."""
|
|
24
|
+
|
|
25
|
+
success: bool
|
|
26
|
+
new_content: str
|
|
27
|
+
replaced: int = 0
|
|
28
|
+
fuzzy_match: bool = False
|
|
29
|
+
error: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _apply_edit(content: str, old: str, new: str, replace_all: bool = False) -> EditApplyResult:
|
|
33
|
+
"""Apply search-and-replace to content string. Returns EditApplyResult."""
|
|
34
|
+
# --- Exact match ---
|
|
35
|
+
count = content.count(old)
|
|
36
|
+
|
|
37
|
+
if count == 0:
|
|
38
|
+
# --- Fuzzy match: quote normalization + trailing whitespace ---
|
|
39
|
+
norm_content = normalize_for_match(content)
|
|
40
|
+
norm_old = normalize_for_match(old)
|
|
41
|
+
norm_count = norm_content.count(norm_old)
|
|
42
|
+
|
|
43
|
+
if norm_count == 0:
|
|
44
|
+
return EditApplyResult(success=False, new_content=content, error=f"Text not found: {old!r}")
|
|
45
|
+
|
|
46
|
+
if replace_all:
|
|
47
|
+
new_content = _fuzzy_replace_all(content, norm_content, norm_old, new)
|
|
48
|
+
replaced = norm_count
|
|
49
|
+
else:
|
|
50
|
+
new_content = _fuzzy_replace_first(content, norm_content, norm_old, new)
|
|
51
|
+
replaced = 1
|
|
52
|
+
|
|
53
|
+
return EditApplyResult(success=True, new_content=new_content, replaced=replaced, fuzzy_match=True)
|
|
54
|
+
|
|
55
|
+
if replace_all:
|
|
56
|
+
new_content = content.replace(old, new)
|
|
57
|
+
replaced = count
|
|
58
|
+
else:
|
|
59
|
+
new_content = content.replace(old, new, 1)
|
|
60
|
+
replaced = 1
|
|
61
|
+
|
|
62
|
+
return EditApplyResult(success=True, new_content=new_content, replaced=replaced)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class EditFileInput(BaseModel):
|
|
66
|
+
path: str
|
|
67
|
+
old: str
|
|
68
|
+
new: str
|
|
69
|
+
replace_all: bool = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class EditFileTool(Tool):
|
|
73
|
+
@property
|
|
74
|
+
def name(self) -> str:
|
|
75
|
+
return "edit_file"
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def description(self) -> str:
|
|
79
|
+
return "Search and replace text within a file."
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def input_schema(self) -> dict:
|
|
83
|
+
return {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {
|
|
86
|
+
"path": {"type": "string", "description": "Absolute path to the file"},
|
|
87
|
+
"old": {"type": "string", "description": "Text to search for"},
|
|
88
|
+
"new": {"type": "string", "description": "Replacement text"},
|
|
89
|
+
"replace_all": {
|
|
90
|
+
"type": "boolean",
|
|
91
|
+
"description": "Replace all occurrences (default false)",
|
|
92
|
+
"default": False,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
"required": ["path", "old", "new"],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def required_permission(self) -> PermissionLevel:
|
|
100
|
+
return PermissionLevel.WORKSPACE_WRITE
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def input_model(self) -> type[EditFileInput]:
|
|
104
|
+
return EditFileInput
|
|
105
|
+
|
|
106
|
+
def execute(self, args: dict, overlay: "OverlayFS | None" = None) -> ToolResult:
|
|
107
|
+
path = pathlib.Path(args["path"])
|
|
108
|
+
old: str = args["old"]
|
|
109
|
+
new: str = args["new"]
|
|
110
|
+
replace_all: bool = bool(args.get("replace_all", False))
|
|
111
|
+
|
|
112
|
+
protection = check_write(str(path))
|
|
113
|
+
if not protection.allowed:
|
|
114
|
+
return ToolResult(output=protection.reason, is_error=True)
|
|
115
|
+
warning_prefix = f"[WARNING] {protection.reason}\n" if protection.severity == "warn" else ""
|
|
116
|
+
|
|
117
|
+
# File size guard (real FS only — overlay has no on-disk size)
|
|
118
|
+
if overlay is None:
|
|
119
|
+
if not path.exists():
|
|
120
|
+
return ToolResult(output=f"File not found: {path}", is_error=True)
|
|
121
|
+
# Single stat call — capture both size and mtime together.
|
|
122
|
+
st = path.stat()
|
|
123
|
+
if st.st_size > _MAX_FILE_BYTES:
|
|
124
|
+
return ToolResult(
|
|
125
|
+
output=f"File too large ({st.st_size} bytes, limit {_MAX_FILE_BYTES}): {path}",
|
|
126
|
+
is_error=True,
|
|
127
|
+
)
|
|
128
|
+
# Record mtime before read for conflict detection
|
|
129
|
+
mtime_before = st.st_mtime
|
|
130
|
+
try:
|
|
131
|
+
content = path.read_text()
|
|
132
|
+
except (PermissionError, OSError) as exc:
|
|
133
|
+
return ToolResult(output=friendly_error(exc, str(path)), is_error=True)
|
|
134
|
+
else:
|
|
135
|
+
try:
|
|
136
|
+
content = overlay.read(path)
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
return ToolResult(output=f"File not found: {path}", is_error=True)
|
|
139
|
+
mtime_before = None
|
|
140
|
+
|
|
141
|
+
result = _apply_edit(content, old, new, replace_all)
|
|
142
|
+
if not result.success:
|
|
143
|
+
return ToolResult(
|
|
144
|
+
output=f"Text not found in {path}: {old!r}",
|
|
145
|
+
is_error=True,
|
|
146
|
+
)
|
|
147
|
+
new_content = result.new_content
|
|
148
|
+
replaced = result.replaced
|
|
149
|
+
fuzzy_match = result.fuzzy_match
|
|
150
|
+
|
|
151
|
+
# --- mtime conflict check (real FS only, before write) ---
|
|
152
|
+
if overlay is None and mtime_before is not None:
|
|
153
|
+
current_mtime = path.stat().st_mtime
|
|
154
|
+
if current_mtime != mtime_before:
|
|
155
|
+
return ToolResult(
|
|
156
|
+
output=f"File was modified externally since last read: {path}",
|
|
157
|
+
is_error=True,
|
|
158
|
+
)
|
|
159
|
+
path.write_text(new_content)
|
|
160
|
+
elif overlay is not None:
|
|
161
|
+
overlay.write(path, new_content)
|
|
162
|
+
|
|
163
|
+
# Generate structured diff
|
|
164
|
+
from llm_code.utils.diff import generate_diff, count_changes
|
|
165
|
+
|
|
166
|
+
hunks = generate_diff(content, new_content, path.name)
|
|
167
|
+
adds, dels = count_changes(hunks)
|
|
168
|
+
|
|
169
|
+
match_note = " (fuzzy match: quote normalization)" if fuzzy_match else ""
|
|
170
|
+
diff_parts = [warning_prefix + f"Replaced {replaced} occurrence(s) in {path}{match_note}"]
|
|
171
|
+
for line in old.splitlines()[:5]:
|
|
172
|
+
diff_parts.append(f"- {line}")
|
|
173
|
+
for line in new.splitlines()[:5]:
|
|
174
|
+
diff_parts.append(f"+ {line}")
|
|
175
|
+
|
|
176
|
+
return ToolResult(
|
|
177
|
+
output="\n".join(diff_parts),
|
|
178
|
+
metadata={
|
|
179
|
+
"diff": [h.to_dict() for h in hunks],
|
|
180
|
+
"additions": adds,
|
|
181
|
+
"deletions": dels,
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# Fuzzy replacement helpers
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def _build_norm_to_orig_map(original: str) -> list[int]:
|
|
191
|
+
"""Build a mapping from each normalised-string index to its original index.
|
|
192
|
+
|
|
193
|
+
normalize_for_match applies two transforms:
|
|
194
|
+
- normalize_quotes: length-preserving (1-to-1 character replacement)
|
|
195
|
+
- strip_trailing_whitespace: length-reducing (removes trailing spaces/tabs
|
|
196
|
+
per line, but keeps the newline)
|
|
197
|
+
|
|
198
|
+
We compute the map by stepping through the original character by character
|
|
199
|
+
and deciding whether each character survives into the normalised string.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
# First pass: quote normalisation is 1-to-1 in length, so positions match.
|
|
203
|
+
# Second pass: trailing whitespace removal — skip chars that are spaces/tabs
|
|
204
|
+
# which trail before a newline or end-of-string.
|
|
205
|
+
|
|
206
|
+
# Pre-compute which original positions are stripped (trailing whitespace).
|
|
207
|
+
n = len(original)
|
|
208
|
+
stripped: list[bool] = [False] * n
|
|
209
|
+
|
|
210
|
+
# Walk each line and mark trailing spaces/tabs for removal.
|
|
211
|
+
i = 0
|
|
212
|
+
while i < n:
|
|
213
|
+
# Find end of line (next \n or end of string).
|
|
214
|
+
j = i
|
|
215
|
+
while j < n and original[j] != "\n":
|
|
216
|
+
j += 1
|
|
217
|
+
# j is now the position of \n or n.
|
|
218
|
+
# Walk backwards from j-1 while space or tab.
|
|
219
|
+
k = j - 1
|
|
220
|
+
while k >= i and original[k] in (" ", "\t"):
|
|
221
|
+
stripped[k] = True
|
|
222
|
+
k -= 1
|
|
223
|
+
i = j + 1 # skip past the \n
|
|
224
|
+
|
|
225
|
+
# Build the map: norm_idx -> orig_idx for surviving characters.
|
|
226
|
+
norm_to_orig: list[int] = []
|
|
227
|
+
for orig_idx in range(n):
|
|
228
|
+
if not stripped[orig_idx]:
|
|
229
|
+
norm_to_orig.append(orig_idx)
|
|
230
|
+
|
|
231
|
+
return norm_to_orig
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _fuzzy_replace_first(original: str, norm_original: str, norm_old: str, new: str) -> str:
|
|
235
|
+
"""Replace the first occurrence of norm_old in the original string.
|
|
236
|
+
|
|
237
|
+
Uses the normalised strings to locate the span, then maps the normalised
|
|
238
|
+
positions back to the original content positions.
|
|
239
|
+
"""
|
|
240
|
+
norm_idx = norm_original.find(norm_old)
|
|
241
|
+
if norm_idx == -1:
|
|
242
|
+
return original
|
|
243
|
+
|
|
244
|
+
norm_end = norm_idx + len(norm_old)
|
|
245
|
+
norm_to_orig = _build_norm_to_orig_map(original)
|
|
246
|
+
|
|
247
|
+
# Map normalised span to original span.
|
|
248
|
+
orig_start = norm_to_orig[norm_idx]
|
|
249
|
+
# norm_end may equal len(norm_original) when the match is at the very end.
|
|
250
|
+
if norm_end < len(norm_to_orig):
|
|
251
|
+
orig_end = norm_to_orig[norm_end]
|
|
252
|
+
else:
|
|
253
|
+
orig_end = len(original)
|
|
254
|
+
|
|
255
|
+
return original[:orig_start] + new + original[orig_end:]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _fuzzy_replace_all(original: str, norm_original: str, norm_old: str, new: str) -> str:
|
|
259
|
+
"""Replace all occurrences of norm_old in the original string."""
|
|
260
|
+
norm_to_orig = _build_norm_to_orig_map(original)
|
|
261
|
+
old_len = len(norm_old)
|
|
262
|
+
|
|
263
|
+
result_parts: list[str] = []
|
|
264
|
+
search_start_norm = 0
|
|
265
|
+
search_start_orig = 0
|
|
266
|
+
|
|
267
|
+
while True:
|
|
268
|
+
idx = norm_original.find(norm_old, search_start_norm)
|
|
269
|
+
if idx == -1:
|
|
270
|
+
result_parts.append(original[search_start_orig:])
|
|
271
|
+
break
|
|
272
|
+
|
|
273
|
+
norm_end = idx + old_len
|
|
274
|
+
orig_start = norm_to_orig[idx]
|
|
275
|
+
orig_end = norm_to_orig[norm_end] if norm_end < len(norm_to_orig) else len(original)
|
|
276
|
+
|
|
277
|
+
result_parts.append(original[search_start_orig:orig_start])
|
|
278
|
+
result_parts.append(new)
|
|
279
|
+
search_start_norm = norm_end
|
|
280
|
+
search_start_orig = orig_end
|
|
281
|
+
|
|
282
|
+
return "".join(result_parts)
|