corecoder 0.2.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.
- corecoder/__init__.py +10 -0
- corecoder/__main__.py +3 -0
- corecoder/agent.py +122 -0
- corecoder/cli.py +218 -0
- corecoder/config.py +55 -0
- corecoder/context.py +196 -0
- corecoder/llm.py +156 -0
- corecoder/prompt.py +33 -0
- corecoder/session.py +68 -0
- corecoder/tools/__init__.py +27 -0
- corecoder/tools/agent.py +58 -0
- corecoder/tools/base.py +27 -0
- corecoder/tools/bash.py +115 -0
- corecoder/tools/edit.py +85 -0
- corecoder/tools/glob_tool.py +47 -0
- corecoder/tools/grep.py +78 -0
- corecoder/tools/read.py +53 -0
- corecoder/tools/write.py +36 -0
- corecoder-0.2.0.dist-info/METADATA +198 -0
- corecoder-0.2.0.dist-info/RECORD +23 -0
- corecoder-0.2.0.dist-info/WHEEL +4 -0
- corecoder-0.2.0.dist-info/entry_points.txt +2 -0
- corecoder-0.2.0.dist-info/licenses/LICENSE +21 -0
corecoder/llm.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""LLM provider layer - thin wrapper over OpenAI-compatible APIs.
|
|
2
|
+
|
|
3
|
+
Since most providers (DeepSeek, Qwen, Kimi, GLM, Ollama, etc.) expose an
|
|
4
|
+
OpenAI-compatible endpoint, we just use the openai SDK directly. Switch
|
|
5
|
+
provider by changing OPENAI_BASE_URL + OPENAI_API_KEY. That's it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from openai import OpenAI, APIError, RateLimitError, APITimeoutError, APIConnectionError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ToolCall:
|
|
17
|
+
id: str
|
|
18
|
+
name: str
|
|
19
|
+
arguments: dict
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class LLMResponse:
|
|
24
|
+
content: str = ""
|
|
25
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
26
|
+
prompt_tokens: int = 0
|
|
27
|
+
completion_tokens: int = 0
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def message(self) -> dict:
|
|
31
|
+
"""Convert to OpenAI message format for appending to history."""
|
|
32
|
+
msg: dict = {"role": "assistant", "content": self.content or None}
|
|
33
|
+
if self.tool_calls:
|
|
34
|
+
msg["tool_calls"] = [
|
|
35
|
+
{
|
|
36
|
+
"id": tc.id,
|
|
37
|
+
"type": "function",
|
|
38
|
+
"function": {
|
|
39
|
+
"name": tc.name,
|
|
40
|
+
"arguments": json.dumps(tc.arguments),
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
for tc in self.tool_calls
|
|
44
|
+
]
|
|
45
|
+
return msg
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LLM:
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
model: str,
|
|
52
|
+
api_key: str,
|
|
53
|
+
base_url: str | None = None,
|
|
54
|
+
**kwargs,
|
|
55
|
+
):
|
|
56
|
+
self.model = model
|
|
57
|
+
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
|
58
|
+
self.extra = kwargs # temperature, max_tokens, etc.
|
|
59
|
+
self.total_prompt_tokens = 0
|
|
60
|
+
self.total_completion_tokens = 0
|
|
61
|
+
|
|
62
|
+
def chat(
|
|
63
|
+
self,
|
|
64
|
+
messages: list[dict],
|
|
65
|
+
tools: list[dict] | None = None,
|
|
66
|
+
on_token=None,
|
|
67
|
+
) -> LLMResponse:
|
|
68
|
+
"""Send messages, stream back response, handle tool calls."""
|
|
69
|
+
params: dict = {
|
|
70
|
+
"model": self.model,
|
|
71
|
+
"messages": messages,
|
|
72
|
+
"stream": True,
|
|
73
|
+
**self.extra,
|
|
74
|
+
}
|
|
75
|
+
if tools:
|
|
76
|
+
params["tools"] = tools
|
|
77
|
+
|
|
78
|
+
# stream_options is an OpenAI extension; not all providers support it
|
|
79
|
+
try:
|
|
80
|
+
params["stream_options"] = {"include_usage": True}
|
|
81
|
+
stream = self._call_with_retry(params)
|
|
82
|
+
except Exception:
|
|
83
|
+
params.pop("stream_options", None)
|
|
84
|
+
stream = self._call_with_retry(params)
|
|
85
|
+
|
|
86
|
+
content_parts: list[str] = []
|
|
87
|
+
tc_map: dict[int, dict] = {} # index -> {id, name, arguments_str}
|
|
88
|
+
prompt_tok = 0
|
|
89
|
+
completion_tok = 0
|
|
90
|
+
|
|
91
|
+
for chunk in stream:
|
|
92
|
+
# usage info comes in the final chunk
|
|
93
|
+
if chunk.usage:
|
|
94
|
+
prompt_tok = chunk.usage.prompt_tokens
|
|
95
|
+
completion_tok = chunk.usage.completion_tokens
|
|
96
|
+
|
|
97
|
+
if not chunk.choices:
|
|
98
|
+
continue
|
|
99
|
+
delta = chunk.choices[0].delta
|
|
100
|
+
|
|
101
|
+
# accumulate text
|
|
102
|
+
if delta.content:
|
|
103
|
+
content_parts.append(delta.content)
|
|
104
|
+
if on_token:
|
|
105
|
+
on_token(delta.content)
|
|
106
|
+
|
|
107
|
+
# accumulate tool calls across chunks
|
|
108
|
+
if delta.tool_calls:
|
|
109
|
+
for tc_delta in delta.tool_calls:
|
|
110
|
+
idx = tc_delta.index
|
|
111
|
+
if idx not in tc_map:
|
|
112
|
+
tc_map[idx] = {"id": "", "name": "", "args": ""}
|
|
113
|
+
if tc_delta.id:
|
|
114
|
+
tc_map[idx]["id"] = tc_delta.id
|
|
115
|
+
if tc_delta.function:
|
|
116
|
+
if tc_delta.function.name:
|
|
117
|
+
tc_map[idx]["name"] = tc_delta.function.name
|
|
118
|
+
if tc_delta.function.arguments:
|
|
119
|
+
tc_map[idx]["args"] += tc_delta.function.arguments
|
|
120
|
+
|
|
121
|
+
# parse accumulated tool calls
|
|
122
|
+
parsed: list[ToolCall] = []
|
|
123
|
+
for idx in sorted(tc_map):
|
|
124
|
+
raw = tc_map[idx]
|
|
125
|
+
try:
|
|
126
|
+
args = json.loads(raw["args"])
|
|
127
|
+
except (json.JSONDecodeError, KeyError):
|
|
128
|
+
args = {}
|
|
129
|
+
parsed.append(ToolCall(id=raw["id"], name=raw["name"], arguments=args))
|
|
130
|
+
|
|
131
|
+
self.total_prompt_tokens += prompt_tok
|
|
132
|
+
self.total_completion_tokens += completion_tok
|
|
133
|
+
|
|
134
|
+
return LLMResponse(
|
|
135
|
+
content="".join(content_parts),
|
|
136
|
+
tool_calls=parsed,
|
|
137
|
+
prompt_tokens=prompt_tok,
|
|
138
|
+
completion_tokens=completion_tok,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _call_with_retry(self, params: dict, max_retries: int = 3):
|
|
142
|
+
"""Retry on transient errors with exponential backoff."""
|
|
143
|
+
for attempt in range(max_retries):
|
|
144
|
+
try:
|
|
145
|
+
return self.client.chat.completions.create(**params)
|
|
146
|
+
except (RateLimitError, APITimeoutError, APIConnectionError) as e:
|
|
147
|
+
if attempt == max_retries - 1:
|
|
148
|
+
raise
|
|
149
|
+
wait = 2 ** attempt
|
|
150
|
+
time.sleep(wait)
|
|
151
|
+
except APIError as e:
|
|
152
|
+
# 5xx = server error, retry; 4xx = client error, don't
|
|
153
|
+
if e.status_code and e.status_code >= 500 and attempt < max_retries - 1:
|
|
154
|
+
time.sleep(2 ** attempt)
|
|
155
|
+
else:
|
|
156
|
+
raise
|
corecoder/prompt.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""System prompt - the instructions that turn an LLM into a coding agent."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def system_prompt(tools) -> str:
|
|
8
|
+
cwd = os.getcwd()
|
|
9
|
+
tool_list = "\n".join(f"- **{t.name}**: {t.description}" for t in tools)
|
|
10
|
+
uname = platform.uname()
|
|
11
|
+
|
|
12
|
+
return f"""\
|
|
13
|
+
You are CoreCoder, an AI coding assistant running in the user's terminal.
|
|
14
|
+
You help with software engineering: writing code, fixing bugs, refactoring, explaining code, running commands, and more.
|
|
15
|
+
|
|
16
|
+
# Environment
|
|
17
|
+
- Working directory: {cwd}
|
|
18
|
+
- OS: {uname.system} {uname.release} ({uname.machine})
|
|
19
|
+
- Python: {platform.python_version()}
|
|
20
|
+
|
|
21
|
+
# Tools
|
|
22
|
+
{tool_list}
|
|
23
|
+
|
|
24
|
+
# Rules
|
|
25
|
+
1. **Read before edit.** Always read a file before modifying it.
|
|
26
|
+
2. **edit_file for small changes.** Use edit_file for targeted edits; write_file only for new files or complete rewrites.
|
|
27
|
+
3. **Verify your work.** After making changes, run relevant tests or commands to confirm correctness.
|
|
28
|
+
4. **Be concise.** Show code over prose. Explain only what's necessary.
|
|
29
|
+
5. **One step at a time.** For multi-step tasks, execute them sequentially.
|
|
30
|
+
6. **edit_file uniqueness.** When using edit_file, include enough surrounding context in old_string to guarantee a unique match.
|
|
31
|
+
7. **Respect existing style.** Match the project's coding conventions.
|
|
32
|
+
8. **Ask when unsure.** If the request is ambiguous, ask for clarification rather than guessing.
|
|
33
|
+
"""
|
corecoder/session.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Session persistence - save and resume conversations.
|
|
2
|
+
|
|
3
|
+
Claude Code maintains session state via QueryEngine (1295 lines).
|
|
4
|
+
CoreCoder distills this to: JSON dump of messages + model config.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
SESSIONS_DIR = Path.home() / ".corecoder" / "sessions"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def save_session(messages: list[dict], model: str, session_id: str | None = None) -> str:
|
|
16
|
+
"""Save conversation to disk. Returns the session ID."""
|
|
17
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
if not session_id:
|
|
20
|
+
session_id = f"session_{int(time.time())}"
|
|
21
|
+
|
|
22
|
+
data = {
|
|
23
|
+
"id": session_id,
|
|
24
|
+
"model": model,
|
|
25
|
+
"saved_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
26
|
+
"messages": messages,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
path = SESSIONS_DIR / f"{session_id}.json"
|
|
30
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
|
31
|
+
return session_id
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_session(session_id: str) -> tuple[list[dict], str] | None:
|
|
35
|
+
"""Load a saved session. Returns (messages, model) or None."""
|
|
36
|
+
path = SESSIONS_DIR / f"{session_id}.json"
|
|
37
|
+
if not path.exists():
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
data = json.loads(path.read_text())
|
|
41
|
+
return data["messages"], data["model"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def list_sessions() -> list[dict]:
|
|
45
|
+
"""List available sessions, newest first."""
|
|
46
|
+
if not SESSIONS_DIR.exists():
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
sessions = []
|
|
50
|
+
for f in sorted(SESSIONS_DIR.glob("*.json"), reverse=True):
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(f.read_text())
|
|
53
|
+
# grab first user message as preview
|
|
54
|
+
preview = ""
|
|
55
|
+
for m in data.get("messages", []):
|
|
56
|
+
if m.get("role") == "user" and m.get("content"):
|
|
57
|
+
preview = m["content"][:80]
|
|
58
|
+
break
|
|
59
|
+
sessions.append({
|
|
60
|
+
"id": data.get("id", f.stem),
|
|
61
|
+
"model": data.get("model", "?"),
|
|
62
|
+
"saved_at": data.get("saved_at", "?"),
|
|
63
|
+
"preview": preview,
|
|
64
|
+
})
|
|
65
|
+
except (json.JSONDecodeError, KeyError):
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
return sessions[:20] # cap at 20
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Tool registry."""
|
|
2
|
+
|
|
3
|
+
from .bash import BashTool
|
|
4
|
+
from .read import ReadFileTool
|
|
5
|
+
from .write import WriteFileTool
|
|
6
|
+
from .edit import EditFileTool
|
|
7
|
+
from .glob_tool import GlobTool
|
|
8
|
+
from .grep import GrepTool
|
|
9
|
+
from .agent import AgentTool
|
|
10
|
+
|
|
11
|
+
ALL_TOOLS = [
|
|
12
|
+
BashTool(),
|
|
13
|
+
ReadFileTool(),
|
|
14
|
+
WriteFileTool(),
|
|
15
|
+
EditFileTool(),
|
|
16
|
+
GlobTool(),
|
|
17
|
+
GrepTool(),
|
|
18
|
+
AgentTool(),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_tool(name: str):
|
|
23
|
+
"""Look up a tool by name."""
|
|
24
|
+
for t in ALL_TOOLS:
|
|
25
|
+
if t.name == name:
|
|
26
|
+
return t
|
|
27
|
+
return None
|
corecoder/tools/agent.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Sub-agent spawning (inspired by Claude Code's AgentTool, 1397 lines).
|
|
2
|
+
|
|
3
|
+
The idea: for complex sub-tasks, spawn an independent agent with its own
|
|
4
|
+
conversation history and tool access. This lets the main agent delegate
|
|
5
|
+
work like "go research this codebase and report back" without polluting
|
|
6
|
+
its own context window.
|
|
7
|
+
|
|
8
|
+
The sub-agent runs to completion and returns a text summary.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .base import Tool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AgentTool(Tool):
|
|
15
|
+
name = "agent"
|
|
16
|
+
description = (
|
|
17
|
+
"Spawn a sub-agent to handle a complex sub-task independently. "
|
|
18
|
+
"The sub-agent has its own context and tool access. Use this for: "
|
|
19
|
+
"researching a codebase, implementing a multi-step change in isolation, "
|
|
20
|
+
"or any task that would benefit from a fresh context window."
|
|
21
|
+
)
|
|
22
|
+
parameters = {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"task": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "What the sub-agent should accomplish",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
"required": ["task"],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# set by Agent.__init__ after construction
|
|
34
|
+
_parent_agent = None
|
|
35
|
+
|
|
36
|
+
def execute(self, task: str) -> str:
|
|
37
|
+
if self._parent_agent is None:
|
|
38
|
+
return "Error: agent tool not initialized (no parent agent)"
|
|
39
|
+
|
|
40
|
+
# import here to avoid circular dep
|
|
41
|
+
from ..agent import Agent
|
|
42
|
+
|
|
43
|
+
parent = self._parent_agent
|
|
44
|
+
sub = Agent(
|
|
45
|
+
llm=parent.llm,
|
|
46
|
+
tools=[t for t in parent.tools if t.name != "agent"], # no recursive agents
|
|
47
|
+
max_context_tokens=parent.context.max_tokens,
|
|
48
|
+
max_rounds=20,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
result = sub.chat(task)
|
|
53
|
+
# trim long results to avoid blowing up parent's context
|
|
54
|
+
if len(result) > 5000:
|
|
55
|
+
result = result[:4500] + "\n... (sub-agent output truncated)"
|
|
56
|
+
return f"[Sub-agent completed]\n{result}"
|
|
57
|
+
except Exception as e:
|
|
58
|
+
return f"Sub-agent error: {e}"
|
corecoder/tools/base.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Base class for all tools."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Tool(ABC):
|
|
7
|
+
"""Minimal tool interface. Subclass this to add new capabilities."""
|
|
8
|
+
|
|
9
|
+
name: str
|
|
10
|
+
description: str
|
|
11
|
+
parameters: dict # JSON Schema for the function args
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def execute(self, **kwargs) -> str:
|
|
15
|
+
"""Run the tool and return a text result."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
def schema(self) -> dict:
|
|
19
|
+
"""OpenAI function-calling schema."""
|
|
20
|
+
return {
|
|
21
|
+
"type": "function",
|
|
22
|
+
"function": {
|
|
23
|
+
"name": self.name,
|
|
24
|
+
"description": self.description,
|
|
25
|
+
"parameters": self.parameters,
|
|
26
|
+
},
|
|
27
|
+
}
|
corecoder/tools/bash.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Shell command execution with safety checks.
|
|
2
|
+
|
|
3
|
+
Claude Code's BashTool is 1,143 lines. This is the distilled version:
|
|
4
|
+
- Output capture with truncation (head+tail preserved)
|
|
5
|
+
- Timeout support
|
|
6
|
+
- Dangerous command detection
|
|
7
|
+
- Working directory tracking (cd awareness)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
from .base import Tool
|
|
14
|
+
|
|
15
|
+
# track cwd across commands (Claude Code does this too)
|
|
16
|
+
_cwd: str | None = None
|
|
17
|
+
|
|
18
|
+
# patterns that could wreck the filesystem or leak secrets
|
|
19
|
+
_DANGEROUS_PATTERNS = [
|
|
20
|
+
(r"\brm\s+(-\w*)?-r\w*\s+(/|~|\$HOME)", "recursive delete on home/root"),
|
|
21
|
+
(r"\brm\s+(-\w*)?-rf\s", "force recursive delete"),
|
|
22
|
+
(r"\bmkfs\b", "format filesystem"),
|
|
23
|
+
(r"\bdd\s+.*of=/dev/", "raw disk write"),
|
|
24
|
+
(r">\s*/dev/sd[a-z]", "overwrite block device"),
|
|
25
|
+
(r"\bchmod\s+(-R\s+)?777\s+/", "chmod 777 on root"),
|
|
26
|
+
(r":\(\)\s*\{.*:\|:.*\}", "fork bomb"),
|
|
27
|
+
(r"\bcurl\b.*\|\s*(sudo\s+)?bash", "pipe curl to bash"),
|
|
28
|
+
(r"\bwget\b.*\|\s*(sudo\s+)?bash", "pipe wget to bash"),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BashTool(Tool):
|
|
33
|
+
name = "bash"
|
|
34
|
+
description = (
|
|
35
|
+
"Execute a shell command. Returns stdout, stderr, and exit code. "
|
|
36
|
+
"Use this for running tests, installing packages, git operations, etc."
|
|
37
|
+
)
|
|
38
|
+
parameters = {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"command": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "The shell command to run",
|
|
44
|
+
},
|
|
45
|
+
"timeout": {
|
|
46
|
+
"type": "integer",
|
|
47
|
+
"description": "Timeout in seconds (default 120)",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
"required": ["command"],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def execute(self, command: str, timeout: int = 120) -> str:
|
|
54
|
+
global _cwd
|
|
55
|
+
# safety check
|
|
56
|
+
warning = _check_dangerous(command)
|
|
57
|
+
if warning:
|
|
58
|
+
return f"⚠ Blocked: {warning}\nCommand: {command}\nIf intentional, modify the command to be more specific."
|
|
59
|
+
|
|
60
|
+
# use tracked working directory
|
|
61
|
+
cwd = _cwd or os.getcwd()
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
proc = subprocess.run(
|
|
65
|
+
command,
|
|
66
|
+
shell=True,
|
|
67
|
+
capture_output=True,
|
|
68
|
+
text=True,
|
|
69
|
+
timeout=timeout,
|
|
70
|
+
cwd=cwd,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# track cd commands so next command runs in the right place
|
|
74
|
+
if proc.returncode == 0:
|
|
75
|
+
_update_cwd(command, cwd)
|
|
76
|
+
out = proc.stdout
|
|
77
|
+
if proc.stderr:
|
|
78
|
+
out += f"\n[stderr]\n{proc.stderr}"
|
|
79
|
+
if proc.returncode != 0:
|
|
80
|
+
out += f"\n[exit code: {proc.returncode}]"
|
|
81
|
+
# keep head + tail to preserve the most useful info
|
|
82
|
+
if len(out) > 15_000:
|
|
83
|
+
out = (
|
|
84
|
+
out[:6000]
|
|
85
|
+
+ f"\n\n... truncated ({len(out)} chars total) ...\n\n"
|
|
86
|
+
+ out[-3000:]
|
|
87
|
+
)
|
|
88
|
+
return out.strip() or "(no output)"
|
|
89
|
+
except subprocess.TimeoutExpired:
|
|
90
|
+
return f"Error: timed out after {timeout}s"
|
|
91
|
+
except Exception as e:
|
|
92
|
+
return f"Error running command: {e}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _check_dangerous(cmd: str) -> str | None:
|
|
96
|
+
"""Return a warning string if the command looks destructive, else None."""
|
|
97
|
+
for pattern, reason in _DANGEROUS_PATTERNS:
|
|
98
|
+
if re.search(pattern, cmd):
|
|
99
|
+
return reason
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _update_cwd(command: str, current_cwd: str):
|
|
104
|
+
"""Track directory changes from cd commands."""
|
|
105
|
+
global _cwd
|
|
106
|
+
# simple heuristic: look for cd at the end of a && chain or standalone
|
|
107
|
+
parts = command.split("&&")
|
|
108
|
+
for part in parts:
|
|
109
|
+
part = part.strip()
|
|
110
|
+
if part.startswith("cd "):
|
|
111
|
+
target = part[3:].strip().strip("'\"")
|
|
112
|
+
if target:
|
|
113
|
+
new_dir = os.path.normpath(os.path.join(current_cwd, os.path.expanduser(target)))
|
|
114
|
+
if os.path.isdir(new_dir):
|
|
115
|
+
_cwd = new_dir
|
corecoder/tools/edit.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Search-and-replace file editing (Claude Code's key innovation).
|
|
2
|
+
|
|
3
|
+
The core idea: instead of sending whole-file rewrites or line-number patches,
|
|
4
|
+
the LLM specifies an *exact* substring to find and its replacement. The
|
|
5
|
+
substring must appear exactly once in the file, which eliminates ambiguity
|
|
6
|
+
and makes edits safe and reviewable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .base import Tool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EditFileTool(Tool):
|
|
16
|
+
name = "edit_file"
|
|
17
|
+
description = (
|
|
18
|
+
"Edit a file by replacing an exact string match. "
|
|
19
|
+
"old_string must appear exactly once in the file for safety. "
|
|
20
|
+
"Include enough surrounding context to ensure uniqueness."
|
|
21
|
+
)
|
|
22
|
+
parameters = {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"file_path": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Path to the file to edit",
|
|
28
|
+
},
|
|
29
|
+
"old_string": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "Exact text to find (must be unique in file)",
|
|
32
|
+
},
|
|
33
|
+
"new_string": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "Replacement text",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
"required": ["file_path", "old_string", "new_string"],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def execute(self, file_path: str, old_string: str, new_string: str) -> str:
|
|
42
|
+
try:
|
|
43
|
+
p = Path(file_path).expanduser().resolve()
|
|
44
|
+
if not p.exists():
|
|
45
|
+
return f"Error: {file_path} not found"
|
|
46
|
+
|
|
47
|
+
content = p.read_text()
|
|
48
|
+
occurrences = content.count(old_string)
|
|
49
|
+
|
|
50
|
+
if occurrences == 0:
|
|
51
|
+
preview = content[:500] + ("..." if len(content) > 500 else "")
|
|
52
|
+
return (
|
|
53
|
+
f"Error: old_string not found in {file_path}.\n"
|
|
54
|
+
f"File starts with:\n{preview}"
|
|
55
|
+
)
|
|
56
|
+
if occurrences > 1:
|
|
57
|
+
return (
|
|
58
|
+
f"Error: old_string appears {occurrences} times in {file_path}. "
|
|
59
|
+
f"Include more surrounding lines to make it unique."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
new_content = content.replace(old_string, new_string, 1)
|
|
63
|
+
p.write_text(new_content)
|
|
64
|
+
|
|
65
|
+
# generate a unified diff so the user/LLM can see exactly what changed
|
|
66
|
+
diff = _unified_diff(content, new_content, str(p))
|
|
67
|
+
return f"Edited {file_path}\n{diff}"
|
|
68
|
+
except Exception as e:
|
|
69
|
+
return f"Error: {e}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _unified_diff(old: str, new: str, filename: str, context: int = 3) -> str:
|
|
73
|
+
"""Generate a compact unified diff between old and new file content."""
|
|
74
|
+
old_lines = old.splitlines(keepends=True)
|
|
75
|
+
new_lines = new.splitlines(keepends=True)
|
|
76
|
+
diff = difflib.unified_diff(
|
|
77
|
+
old_lines, new_lines,
|
|
78
|
+
fromfile=f"a/{filename}", tofile=f"b/{filename}",
|
|
79
|
+
n=context,
|
|
80
|
+
)
|
|
81
|
+
result = "".join(diff)
|
|
82
|
+
# truncate enormous diffs
|
|
83
|
+
if len(result) > 3000:
|
|
84
|
+
result = result[:2500] + "\n... (diff truncated)\n"
|
|
85
|
+
return result
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""File pattern matching."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from .base import Tool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GlobTool(Tool):
|
|
8
|
+
name = "glob"
|
|
9
|
+
description = (
|
|
10
|
+
"Find files matching a glob pattern. "
|
|
11
|
+
"Supports ** for recursive matching (e.g. '**/*.py')."
|
|
12
|
+
)
|
|
13
|
+
parameters = {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"pattern": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "Glob pattern, e.g. '**/*.py' or 'src/**/*.ts'",
|
|
19
|
+
},
|
|
20
|
+
"path": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "Directory to search in (default: cwd)",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
"required": ["pattern"],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def execute(self, pattern: str, path: str = ".") -> str:
|
|
29
|
+
try:
|
|
30
|
+
base = Path(path).expanduser().resolve()
|
|
31
|
+
if not base.is_dir():
|
|
32
|
+
return f"Error: {path} is not a directory"
|
|
33
|
+
|
|
34
|
+
hits = list(base.glob(pattern))
|
|
35
|
+
# sort by mtime, newest first
|
|
36
|
+
hits.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
|
|
37
|
+
|
|
38
|
+
total = len(hits)
|
|
39
|
+
shown = hits[:100]
|
|
40
|
+
lines = [str(h) for h in shown]
|
|
41
|
+
result = "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
if total > 100:
|
|
44
|
+
result += f"\n... ({total} matches, showing first 100)"
|
|
45
|
+
return result or "No files matched."
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return f"Error: {e}"
|