crabcode 0.1.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.
Files changed (64) hide show
  1. crabcode-0.1.0.dist-info/METADATA +23 -0
  2. crabcode-0.1.0.dist-info/RECORD +64 -0
  3. crabcode-0.1.0.dist-info/WHEEL +4 -0
  4. crabcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +3 -0
  6. crabcode_cli/app.py +142 -0
  7. crabcode_cli/banner.py +42 -0
  8. crabcode_cli/pipe.py +58 -0
  9. crabcode_cli/render/__init__.py +0 -0
  10. crabcode_cli/render/messages.py +19 -0
  11. crabcode_cli/repl.py +1263 -0
  12. crabcode_core/__init__.py +3 -0
  13. crabcode_core/api/__init__.py +6 -0
  14. crabcode_core/api/anthropic_adapter.py +250 -0
  15. crabcode_core/api/base.py +66 -0
  16. crabcode_core/api/codex_adapter.py +390 -0
  17. crabcode_core/api/openai_adapter.py +221 -0
  18. crabcode_core/api/registry.py +40 -0
  19. crabcode_core/compact/__init__.py +7 -0
  20. crabcode_core/compact/compact.py +150 -0
  21. crabcode_core/config/__init__.py +3 -0
  22. crabcode_core/config/claudemd.py +53 -0
  23. crabcode_core/config/manager.py +152 -0
  24. crabcode_core/events.py +392 -0
  25. crabcode_core/mcp/__init__.py +4 -0
  26. crabcode_core/mcp/client.py +163 -0
  27. crabcode_core/mcp/config.py +52 -0
  28. crabcode_core/permissions/__init__.py +3 -0
  29. crabcode_core/permissions/manager.py +106 -0
  30. crabcode_core/prompts/__init__.py +0 -0
  31. crabcode_core/prompts/context.py +209 -0
  32. crabcode_core/prompts/profile.py +66 -0
  33. crabcode_core/prompts/system.py +405 -0
  34. crabcode_core/prompts/templates.py +60 -0
  35. crabcode_core/query/__init__.py +0 -0
  36. crabcode_core/query/loop.py +442 -0
  37. crabcode_core/session/__init__.py +3 -0
  38. crabcode_core/session/storage.py +153 -0
  39. crabcode_core/skills/__init__.py +5 -0
  40. crabcode_core/skills/loader.py +147 -0
  41. crabcode_core/tools/__init__.py +29 -0
  42. crabcode_core/tools/agent.py +160 -0
  43. crabcode_core/tools/bash.py +127 -0
  44. crabcode_core/tools/diff_utils.py +120 -0
  45. crabcode_core/tools/file_edit.py +145 -0
  46. crabcode_core/tools/file_read.py +123 -0
  47. crabcode_core/tools/file_write.py +102 -0
  48. crabcode_core/tools/glob.py +109 -0
  49. crabcode_core/tools/grep.py +130 -0
  50. crabcode_core/tools/lint.py +368 -0
  51. crabcode_core/tools/memory.py +236 -0
  52. crabcode_core/tools/skill.py +112 -0
  53. crabcode_core/types/__init__.py +73 -0
  54. crabcode_core/types/config.py +103 -0
  55. crabcode_core/types/event.py +105 -0
  56. crabcode_core/types/message.py +177 -0
  57. crabcode_core/types/tool.py +138 -0
  58. crabcode_search/__init__.py +11 -0
  59. crabcode_search/background.py +371 -0
  60. crabcode_search/chunker.py +517 -0
  61. crabcode_search/embedder.py +447 -0
  62. crabcode_search/indexer.py +212 -0
  63. crabcode_search/store.py +222 -0
  64. crabcode_search/tool.py +232 -0
@@ -0,0 +1,147 @@
1
+ """Skill loading — scans .crabcode/skills/ and .claude/skills/ directories.
2
+
3
+ Search order (later entries override earlier ones with the same name):
4
+ 1. ~/.claude/skills/<name>/SKILL.md (user global, Claude Code compat)
5
+ 2. ~/.crabcode/skills/<name>/SKILL.md (user global, crabcode native)
6
+ 3. .claude/skills/<name>/SKILL.md (project, walking up to home)
7
+ 4. .crabcode/skills/<name>/SKILL.md (project, walking up to home, highest priority)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+
17
+ @dataclass
18
+ class SkillDefinition:
19
+ name: str
20
+ description: str
21
+ content: str
22
+ source_path: str
23
+ when_to_use: str | None = None
24
+ paths: list[str] = field(default_factory=list)
25
+
26
+
27
+ def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
28
+ """Split YAML frontmatter from Markdown body.
29
+
30
+ Returns (frontmatter_dict, body). If no frontmatter block is found,
31
+ returns ({}, original text).
32
+ """
33
+ match = re.match(r"^---\s*\n(.*?)\n---\s*\n?", text, re.DOTALL)
34
+ if not match:
35
+ return {}, text
36
+
37
+ raw_yaml = match.group(1)
38
+ body = text[match.end():]
39
+
40
+ data: dict[str, str] = {}
41
+ for line in raw_yaml.splitlines():
42
+ kv = re.match(r'^([\w][\w_-]*):\s*"?(.*?)"?\s*$', line)
43
+ if kv:
44
+ data[kv.group(1)] = kv.group(2)
45
+
46
+ return data, body.lstrip("\n")
47
+
48
+
49
+ def _load_skill_from_file(skill_file: Path) -> SkillDefinition | None:
50
+ """Parse a single SKILL.md file and return a SkillDefinition, or None on error."""
51
+ try:
52
+ text = skill_file.read_text(encoding="utf-8")
53
+ except OSError:
54
+ return None
55
+
56
+ fm, body = _parse_frontmatter(text)
57
+ if not body.strip() and not fm:
58
+ return None
59
+
60
+ # Derive name: prefer frontmatter, fall back to parent dir name
61
+ name = fm.get("name") or skill_file.parent.name
62
+ description = fm.get("description", "")
63
+ when_to_use = fm.get("when_to_use") or None
64
+
65
+ raw_paths = fm.get("paths", "")
66
+ paths = [p.strip() for p in raw_paths.split(",") if p.strip()] if raw_paths else []
67
+
68
+ return SkillDefinition(
69
+ name=name,
70
+ description=description,
71
+ content=body.strip(),
72
+ source_path=str(skill_file),
73
+ when_to_use=when_to_use,
74
+ paths=paths,
75
+ )
76
+
77
+
78
+ def _scan_skills_dir(base: Path) -> dict[str, SkillDefinition]:
79
+ """Scan a skills directory for <skill-name>/SKILL.md entries."""
80
+ skills: dict[str, SkillDefinition] = {}
81
+ if not base.is_dir():
82
+ return skills
83
+
84
+ for entry in sorted(base.iterdir()):
85
+ if not (entry.is_dir() or entry.is_symlink()):
86
+ continue
87
+ skill_file = entry / "SKILL.md"
88
+ if not skill_file.is_file():
89
+ continue
90
+ skill = _load_skill_from_file(skill_file)
91
+ if skill:
92
+ skills[skill.name] = skill
93
+
94
+ return skills
95
+
96
+
97
+ def _dirs_up_to_home(cwd: str, config_dir: str) -> list[Path]:
98
+ """Return skill dirs from home down to cwd for a given config directory name.
99
+
100
+ Walking home → cwd means closer (more specific) directories override
101
+ farther ones when we merge.
102
+ """
103
+ home = Path.home()
104
+ current = Path(cwd).resolve()
105
+ dirs: list[Path] = []
106
+
107
+ path = current
108
+ while True:
109
+ # Skip the home directory itself — it's covered by the global scan
110
+ if path != home:
111
+ dirs.append(path / config_dir / "skills")
112
+ if path == home or path.parent == path:
113
+ break
114
+ path = path.parent
115
+
116
+ # Reverse so home-adjacent dirs come first (lower priority), cwd last (highest)
117
+ dirs.reverse()
118
+ return dirs
119
+
120
+
121
+ def load_skills(cwd: str) -> list[SkillDefinition]:
122
+ """Load all skills visible from *cwd*, merged by priority.
123
+
124
+ Skills with the same name loaded later override earlier ones.
125
+ """
126
+ merged: dict[str, SkillDefinition] = {}
127
+ home = Path.home()
128
+
129
+ # 1. ~/.claude/skills/ — lowest priority (Claude Code global compat)
130
+ for name, skill in _scan_skills_dir(home / ".claude" / "skills").items():
131
+ merged[name] = skill
132
+
133
+ # 2. ~/.crabcode/skills/ — overrides claude global
134
+ for name, skill in _scan_skills_dir(home / ".crabcode" / "skills").items():
135
+ merged[name] = skill
136
+
137
+ # 3. Project-level .claude/skills/ dirs (home-adjacent → cwd)
138
+ for skills_dir in _dirs_up_to_home(cwd, ".claude"):
139
+ for name, skill in _scan_skills_dir(skills_dir).items():
140
+ merged[name] = skill
141
+
142
+ # 4. Project-level .crabcode/skills/ dirs (highest priority)
143
+ for skills_dir in _dirs_up_to_home(cwd, ".crabcode"):
144
+ for name, skill in _scan_skills_dir(skills_dir).items():
145
+ merged[name] = skill
146
+
147
+ return list(merged.values())
@@ -0,0 +1,29 @@
1
+ """Tool system - built-in tools and tool registry."""
2
+
3
+ from crabcode_core.types.tool import Tool, ToolResult, ToolContext
4
+
5
+
6
+ def get_default_tools() -> list[Tool]:
7
+ """Return the default set of built-in tools."""
8
+ from crabcode_core.tools.bash import BashTool
9
+ from crabcode_core.tools.file_read import FileReadTool
10
+ from crabcode_core.tools.file_edit import FileEditTool
11
+ from crabcode_core.tools.file_write import FileWriteTool
12
+ from crabcode_core.tools.grep import GrepTool
13
+ from crabcode_core.tools.glob import GlobTool
14
+ from crabcode_core.tools.lint import LintTool
15
+ from crabcode_core.tools.memory import MemoryTool
16
+
17
+ return [
18
+ BashTool(),
19
+ FileReadTool(),
20
+ FileEditTool(),
21
+ FileWriteTool(),
22
+ GrepTool(),
23
+ GlobTool(),
24
+ LintTool(),
25
+ MemoryTool(),
26
+ ]
27
+
28
+
29
+ __all__ = ["Tool", "ToolResult", "ToolContext", "get_default_tools"]
@@ -0,0 +1,160 @@
1
+ """AgentTool — spawn sub-agents for parallel/isolated work."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from crabcode_core.types.tool import Tool, ToolContext, ToolResult
9
+
10
+
11
+ class AgentTool(Tool):
12
+ name = "Agent"
13
+ description = "Spawn a sub-agent for parallel or isolated tasks."
14
+ is_read_only = False
15
+ is_concurrency_safe = True
16
+ input_schema = {
17
+ "type": "object",
18
+ "properties": {
19
+ "prompt": {
20
+ "type": "string",
21
+ "description": "The task for the sub-agent to perform.",
22
+ },
23
+ "subagent_type": {
24
+ "type": "string",
25
+ "description": "Type of agent to spawn (e.g., 'explore', 'generalPurpose').",
26
+ "enum": ["explore", "generalPurpose"],
27
+ },
28
+ },
29
+ "required": ["prompt"],
30
+ }
31
+
32
+ def __init__(
33
+ self,
34
+ api_adapter: Any = None,
35
+ tools: list[Tool] | None = None,
36
+ prompt_profile: Any = None,
37
+ max_turns: int = 10,
38
+ timeout: int = 300,
39
+ max_output_chars: int = 12_000,
40
+ max_display_lines: int = 120,
41
+ ):
42
+ self._api_adapter = api_adapter
43
+ self._sub_tools = tools
44
+ self._prompt_profile = prompt_profile
45
+ self._max_turns = max_turns
46
+ self._timeout = timeout
47
+ self._max_output_chars = max_output_chars
48
+ self._max_display_lines = max_display_lines
49
+
50
+ async def get_prompt(self, **kwargs: Any) -> str:
51
+ return (
52
+ "Launch a sub-agent to handle complex, multi-step tasks "
53
+ "autonomously. Subagents are valuable for parallelizing "
54
+ "independent queries or for protecting the main context "
55
+ "window from excessive results.\n\n"
56
+ "Available types:\n"
57
+ "- 'explore': Fast agent for codebase exploration — finding "
58
+ "files, searching code, answering questions about the codebase.\n"
59
+ "- 'generalPurpose': For complex multi-step tasks that need "
60
+ "searching, reading, and executing commands.\n\n"
61
+ "When to use:\n"
62
+ "- Broadly exploring the codebase for context on a large task\n"
63
+ "- Parallelizing independent research queries\n"
64
+ "- Tasks that may produce large outputs\n\n"
65
+ "When NOT to use:\n"
66
+ "- Simple, single-step tasks — just call the tools directly\n"
67
+ "- Searching for a specific file or class — use Glob or Grep\n\n"
68
+ "IMPORTANT: Avoid duplicating work that subagents are already "
69
+ "doing. If you delegate research to a subagent, do not also "
70
+ "perform the same searches yourself."
71
+ )
72
+
73
+ async def call(
74
+ self,
75
+ tool_input: dict[str, Any],
76
+ context: ToolContext,
77
+ ) -> ToolResult:
78
+ prompt = tool_input.get("prompt", "")
79
+
80
+ if not prompt:
81
+ return ToolResult(
82
+ result_for_model="Error: prompt is required",
83
+ is_error=True,
84
+ )
85
+
86
+ if not self._api_adapter:
87
+ return ToolResult(
88
+ result_for_model="Error: AgentTool requires an API adapter",
89
+ is_error=True,
90
+ )
91
+
92
+ from crabcode_core.prompts.profile import resolve_agent_prompt
93
+ from crabcode_core.query.loop import QueryParams, query_loop
94
+ from crabcode_core.types.message import create_user_message
95
+
96
+ sub_messages = [create_user_message(content=prompt)]
97
+
98
+ sub_context = ToolContext(
99
+ cwd=context.cwd,
100
+ messages=[],
101
+ session_id=context.session_id,
102
+ env=context.env,
103
+ )
104
+
105
+ tools = self._sub_tools or []
106
+ agent_prompt = resolve_agent_prompt(self._prompt_profile)
107
+
108
+ params = QueryParams(
109
+ messages=sub_messages,
110
+ system_prompt=[agent_prompt],
111
+ user_context={},
112
+ system_context={},
113
+ tools=tools,
114
+ tool_context=sub_context,
115
+ api_adapter=self._api_adapter,
116
+ max_turns=self._max_turns,
117
+ )
118
+
119
+ result_parts: list[str] = []
120
+
121
+ async def _collect_results():
122
+ async for event in query_loop(params):
123
+ from crabcode_core.types.event import (
124
+ ErrorEvent,
125
+ StreamTextEvent,
126
+ ToolResultEvent,
127
+ )
128
+ if isinstance(event, StreamTextEvent):
129
+ result_parts.append(event.text)
130
+ elif isinstance(event, ToolResultEvent):
131
+ body = (event.result or "").strip()
132
+ if len(body) > self._max_output_chars:
133
+ body = body[:self._max_output_chars] + "\n… (truncated)"
134
+ result_parts.append(
135
+ f"\n\n[Tool {event.tool_name} →]\n{body or '(empty)'}\n"
136
+ )
137
+ elif isinstance(event, ErrorEvent):
138
+ result_parts.append(f"\n[Error: {event.message}]")
139
+
140
+ try:
141
+ await asyncio.wait_for(_collect_results(), timeout=self._timeout)
142
+ except asyncio.TimeoutError:
143
+ result_parts.append(f"\n[Sub-agent timed out after {self._timeout}s]")
144
+
145
+ result = "".join(result_parts)
146
+ if not result:
147
+ result = "Sub-agent completed but produced no text output."
148
+
149
+ # Build a display-friendly version: keep more content but cap by line count
150
+ display = result
151
+ lines = display.split("\n")
152
+ if len(lines) > self._max_display_lines:
153
+ kept = "\n".join(lines[:self._max_display_lines])
154
+ display = kept + f"\n… ({len(lines) - self._max_display_lines} more lines truncated)"
155
+
156
+ return ToolResult(
157
+ data={"agent_output": result},
158
+ result_for_model=result,
159
+ result_for_display=display,
160
+ )
@@ -0,0 +1,127 @@
1
+ """BashTool — execute shell commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from typing import Any
8
+
9
+ from crabcode_core.types.tool import Tool, ToolContext, ToolResult
10
+
11
+
12
+ class BashTool(Tool):
13
+ name = "Bash"
14
+ description = "Execute a bash command in the shell."
15
+ is_read_only = False
16
+ is_concurrency_safe = False
17
+ input_schema = {
18
+ "type": "object",
19
+ "properties": {
20
+ "command": {
21
+ "type": "string",
22
+ "description": "The bash command to execute.",
23
+ },
24
+ "timeout": {
25
+ "type": "integer",
26
+ "description": "Timeout in seconds (default: 120).",
27
+ },
28
+ },
29
+ "required": ["command"],
30
+ }
31
+
32
+ async def get_prompt(self, **kwargs: Any) -> str:
33
+ return (
34
+ "Execute a bash command in the shell. Use for system commands, "
35
+ "running scripts, git operations, and other terminal tasks. "
36
+ "Prefer dedicated tools (Read, Edit, Write, Glob, Grep) over "
37
+ "bash when they can accomplish the task.\n\n"
38
+ "Commands run in the user's shell with their environment. "
39
+ "The command is executed in the working directory of the "
40
+ "current session. The shell is stateful across calls — "
41
+ "environment variables and directory changes persist.\n\n"
42
+ "Guidelines:\n"
43
+ "- Always quote file paths that contain spaces with double "
44
+ 'quotes: cd "/path/with spaces" (correct) vs cd /path/with '
45
+ "spaces (incorrect, will fail).\n"
46
+ "- For long-running processes (dev servers, watchers), they "
47
+ "will be killed when the timeout expires. Warn the user if "
48
+ "a command is expected to run indefinitely.\n"
49
+ "- When issuing multiple independent commands, call Bash "
50
+ "multiple times in parallel rather than chaining with &&.\n"
51
+ "- If commands depend on each other and must run sequentially, "
52
+ "chain them with && in a single call.\n"
53
+ "- Do NOT use interactive commands (e.g., git rebase -i, "
54
+ "vim, nano) — they require user input that is not supported.\n"
55
+ "- If a command fails, read the error output carefully before "
56
+ "retrying. Do not blindly retry the same command."
57
+ )
58
+
59
+ async def call(
60
+ self,
61
+ tool_input: dict[str, Any],
62
+ context: ToolContext,
63
+ ) -> ToolResult:
64
+ command = tool_input.get("command", "")
65
+ timeout = tool_input.get("timeout", 120)
66
+
67
+ if not command.strip():
68
+ return ToolResult(
69
+ result_for_model="Error: empty command",
70
+ is_error=True,
71
+ )
72
+
73
+ env = {**os.environ, **context.env}
74
+
75
+ try:
76
+ proc = await asyncio.create_subprocess_shell(
77
+ command,
78
+ stdout=asyncio.subprocess.PIPE,
79
+ stderr=asyncio.subprocess.PIPE,
80
+ cwd=context.cwd,
81
+ env=env,
82
+ )
83
+
84
+ try:
85
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
86
+ proc.communicate(), timeout=timeout
87
+ )
88
+ except asyncio.TimeoutError:
89
+ proc.kill()
90
+ await proc.wait()
91
+ return ToolResult(
92
+ result_for_model=f"Command timed out after {timeout}s",
93
+ is_error=True,
94
+ )
95
+
96
+ stdout = stdout_bytes.decode("utf-8", errors="replace")
97
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
98
+ exit_code = proc.returncode or 0
99
+
100
+ max_chars = 100_000
101
+ if len(stdout) > max_chars:
102
+ stdout = stdout[:max_chars] + "\n... (truncated)"
103
+ if len(stderr) > max_chars:
104
+ stderr = stderr[:max_chars] + "\n... (truncated)"
105
+
106
+ parts: list[str] = []
107
+ if stdout:
108
+ parts.append(stdout)
109
+ if stderr:
110
+ parts.append(f"stderr:\n{stderr}")
111
+ if exit_code != 0:
112
+ parts.append(f"Exit code: {exit_code}")
113
+
114
+ output = "\n".join(parts) if parts else "(no output)"
115
+
116
+ return ToolResult(
117
+ data={"stdout": stdout, "stderr": stderr, "exit_code": exit_code},
118
+ result_for_model=output,
119
+ result_for_display=output,
120
+ is_error=exit_code != 0,
121
+ )
122
+
123
+ except Exception as e:
124
+ return ToolResult(
125
+ result_for_model=f"Error executing command: {e}",
126
+ is_error=True,
127
+ )
@@ -0,0 +1,120 @@
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
+ old_lines = old.splitlines(keepends=True)
23
+ new_lines = new.splitlines(keepends=True)
24
+
25
+ diff_lines = list(difflib.unified_diff(
26
+ old_lines,
27
+ new_lines,
28
+ fromfile=filepath,
29
+ tofile=filepath,
30
+ lineterm="",
31
+ n=context_lines,
32
+ ))
33
+
34
+ if not diff_lines:
35
+ return {
36
+ "line_range": None,
37
+ "diff_text": "",
38
+ "display": "",
39
+ "stats": {"added": 0, "removed": 0},
40
+ }
41
+
42
+ added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++"))
43
+ removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---"))
44
+
45
+ first_change_line = _find_first_change_line(old_lines, new_lines)
46
+ last_change_line = _find_last_change_line(old_lines, new_lines)
47
+
48
+ diff_text = "\n".join(diff_lines)
49
+
50
+ max_diff_chars = 10_000
51
+ if len(diff_text) > max_diff_chars:
52
+ diff_text = diff_text[:max_diff_chars] + "\n... (diff truncated)"
53
+
54
+ return {
55
+ "line_range": (first_change_line, last_change_line),
56
+ "diff_text": diff_text,
57
+ "display": diff_text,
58
+ "stats": {"added": added, "removed": removed},
59
+ }
60
+
61
+
62
+ def _find_first_change_line(old: list[str], new: list[str]) -> int:
63
+ """Find the 1-indexed line number of the first difference."""
64
+ for i, (a, b) in enumerate(zip(old, new)):
65
+ if a != b:
66
+ return i + 1
67
+ return min(len(old), len(new)) + 1
68
+
69
+
70
+ def _find_last_change_line(old: list[str], new: list[str]) -> int:
71
+ """Find the 1-indexed line number (in new file) of the last difference."""
72
+ old_rev = list(reversed(old))
73
+ new_rev = list(reversed(new))
74
+ tail_common = 0
75
+ for a, b in zip(old_rev, new_rev):
76
+ if a != b:
77
+ break
78
+ tail_common += 1
79
+ return max(1, len(new) - tail_common)
80
+
81
+
82
+ def format_edit_summary(
83
+ filepath: str,
84
+ diff_info: dict,
85
+ replacements: int = 1,
86
+ ) -> tuple[str, str]:
87
+ """Format diff info into (result_for_model, result_for_display).
88
+
89
+ Returns a pair of strings optimized for model consumption and
90
+ terminal display respectively.
91
+ """
92
+ lr = diff_info["line_range"]
93
+ stats = diff_info["stats"]
94
+
95
+ if lr:
96
+ line_desc = f"lines {lr[0]}-{lr[1]}" if lr[0] != lr[1] else f"line {lr[0]}"
97
+ else:
98
+ line_desc = ""
99
+
100
+ stat_parts = []
101
+ if stats["added"]:
102
+ stat_parts.append(f"+{stats['added']}")
103
+ if stats["removed"]:
104
+ stat_parts.append(f"-{stats['removed']}")
105
+ stat_str = ", ".join(stat_parts)
106
+
107
+ model_msg = f"Updated {filepath}"
108
+ if line_desc:
109
+ model_msg += f" ({line_desc})"
110
+ if stat_str:
111
+ model_msg += f" [{stat_str}]"
112
+ if replacements > 1:
113
+ model_msg += f" ({replacements} replacements)"
114
+
115
+ display_parts = [model_msg]
116
+ if diff_info["display"]:
117
+ display_parts.append(diff_info["display"])
118
+ display_msg = "\n".join(display_parts)
119
+
120
+ return model_msg, display_msg