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/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
@@ -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}"
@@ -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
+ }
@@ -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
@@ -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}"