closecode-ai 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.
- agent.py +74 -0
- closecode_ai-0.1.0.dist-info/METADATA +138 -0
- closecode_ai-0.1.0.dist-info/RECORD +19 -0
- closecode_ai-0.1.0.dist-info/WHEEL +5 -0
- closecode_ai-0.1.0.dist-info/entry_points.txt +2 -0
- closecode_ai-0.1.0.dist-info/top_level.txt +14 -0
- debug_response.py +25 -0
- guardrails.py +347 -0
- harness.py +200 -0
- llm.py +145 -0
- main.py +520 -0
- mcp_tools.py +27 -0
- modes.py +36 -0
- search.py +130 -0
- session.py +197 -0
- todos.py +99 -0
- token_tracker.py +42 -0
- tools.py +137 -0
- ui.py +422 -0
harness.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
|
|
2
|
+
import os
|
|
3
|
+
import signal
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
from guardrails import check_bash_command, check_write_content
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PermissionDenied(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Harness:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
workdir: str,
|
|
19
|
+
auto_approve: bool = False,
|
|
20
|
+
confirm_fn: Optional[Callable[[str], str]] = None,
|
|
21
|
+
):
|
|
22
|
+
self.workdir = Path(workdir).resolve()
|
|
23
|
+
self.workdir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
self.auto_approve = auto_approve
|
|
25
|
+
# Defaults to plain input() if no styled confirm function is given —
|
|
26
|
+
# keeps Harness usable standalone without depending on ui.py.
|
|
27
|
+
self._confirm_fn = confirm_fn or (
|
|
28
|
+
lambda action: "allow" if input(f"\n[permission] Allow agent to {action}? [y/N] ").strip().lower() in ("y", "yes") else "deny"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def confirm(self, action_description: str) -> bool:
|
|
32
|
+
if self.auto_approve:
|
|
33
|
+
return True
|
|
34
|
+
result = self._confirm_fn(action_description)
|
|
35
|
+
if result == "always":
|
|
36
|
+
self.auto_approve = True
|
|
37
|
+
return True
|
|
38
|
+
return result == "allow"
|
|
39
|
+
|
|
40
|
+
def resolve_path(self, relative_path: str) -> Path:
|
|
41
|
+
# Normalize before joining. Models very commonly pass absolute-looking
|
|
42
|
+
# paths (e.g. "/my_project/main.py" — they're thinking "project root",
|
|
43
|
+
# not "host filesystem root"). That's a real problem with pathlib:
|
|
44
|
+
# Path("/sandbox") / "/my_project/main.py" DISCARDS the left side
|
|
45
|
+
# entirely (joining an absolute path onto another replaces it), so
|
|
46
|
+
# the join silently points outside the sandbox instead of into it.
|
|
47
|
+
# This is exactly what caused write_file to appear to create files
|
|
48
|
+
# while bash (which correctly uses the same self.workdir) saw an
|
|
49
|
+
# empty directory — write_file's target had quietly become
|
|
50
|
+
# `/my_project/main.py` on the host, not `<workdir>/my_project/main.py`.
|
|
51
|
+
#
|
|
52
|
+
# Fix: strip any leading slash/backslash and any Windows drive
|
|
53
|
+
# prefix before joining, so every path is treated as relative to
|
|
54
|
+
# the sandbox no matter how the model phrased it. bash and every
|
|
55
|
+
# file tool then agree on the exact same root in every case.
|
|
56
|
+
cleaned = relative_path.replace("\\", "/").lstrip("/")
|
|
57
|
+
if len(cleaned) > 1 and cleaned[1] == ":": # e.g. "C:/foo" or "C:foo"
|
|
58
|
+
cleaned = cleaned[2:].lstrip("/")
|
|
59
|
+
|
|
60
|
+
target = (self.workdir / cleaned).resolve()
|
|
61
|
+
if self.workdir != target and self.workdir not in target.parents:
|
|
62
|
+
raise PermissionDenied(f"Path '{relative_path}' escapes the sandboxed working directory.")
|
|
63
|
+
return target
|
|
64
|
+
|
|
65
|
+
def run_bash(self, command: str, timeout: int = 30) -> str:
|
|
66
|
+
reason = check_bash_command(command)
|
|
67
|
+
if reason:
|
|
68
|
+
return (
|
|
69
|
+
f"Guardrail blocked this command: {reason}. It looks destructive or "
|
|
70
|
+
"malicious, so it cannot run \u2014 even if approval were granted. "
|
|
71
|
+
"Rephrase the command to do the same thing safely, or use a "
|
|
72
|
+
"different approach."
|
|
73
|
+
)
|
|
74
|
+
if not self.confirm(f"run: `{command}`"):
|
|
75
|
+
return "Permission denied by user."
|
|
76
|
+
proc = subprocess.Popen(
|
|
77
|
+
command,
|
|
78
|
+
shell=True,
|
|
79
|
+
cwd=self.workdir,
|
|
80
|
+
stdout=subprocess.PIPE,
|
|
81
|
+
stderr=subprocess.STDOUT,
|
|
82
|
+
text=True,
|
|
83
|
+
start_new_session=True,
|
|
84
|
+
)
|
|
85
|
+
try:
|
|
86
|
+
output, _ = proc.communicate(timeout=timeout)
|
|
87
|
+
output = (output or "").strip() or "(no output)"
|
|
88
|
+
return output[-4000:]
|
|
89
|
+
except subprocess.TimeoutExpired:
|
|
90
|
+
try:
|
|
91
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
92
|
+
except ProcessLookupError:
|
|
93
|
+
pass
|
|
94
|
+
proc.communicate()
|
|
95
|
+
return (
|
|
96
|
+
f"Command timed out after {timeout}s and was terminated (including any "
|
|
97
|
+
f"child processes it started). If this was meant to be a long-running "
|
|
98
|
+
f"process (a server, a watcher), run it in the background instead — see "
|
|
99
|
+
f"the system prompt for how."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def read_file(self, path: str) -> str:
|
|
103
|
+
try:
|
|
104
|
+
target = self.resolve_path(path)
|
|
105
|
+
except PermissionDenied as e:
|
|
106
|
+
return str(e)
|
|
107
|
+
if not target.exists():
|
|
108
|
+
return f"File not found: {path}"
|
|
109
|
+
if not target.is_file():
|
|
110
|
+
return f"Not a file: {path}"
|
|
111
|
+
return target.read_text(errors="replace")[:8000]
|
|
112
|
+
|
|
113
|
+
def write_file(self, path: str, content: str) -> str:
|
|
114
|
+
reason = check_write_content(content)
|
|
115
|
+
if reason:
|
|
116
|
+
return (
|
|
117
|
+
f"Guardrail refused to write this content: {reason} (malicious-code "
|
|
118
|
+
"indicator). Policy forbids creating malware or exploit code "
|
|
119
|
+
"\u2014 even inside the sandbox."
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
target = self.resolve_path(path)
|
|
123
|
+
except PermissionDenied as e:
|
|
124
|
+
return str(e)
|
|
125
|
+
if not self.confirm(f"write {len(content)} chars to `{path}`"):
|
|
126
|
+
return "Permission denied by user."
|
|
127
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
target.write_text(content)
|
|
129
|
+
return f"Wrote {len(content)} chars to {path}"
|
|
130
|
+
|
|
131
|
+
def list_dir(self, path: str = ".") -> str:
|
|
132
|
+
try:
|
|
133
|
+
target = self.resolve_path(path)
|
|
134
|
+
except PermissionDenied as e:
|
|
135
|
+
return str(e)
|
|
136
|
+
if not target.exists():
|
|
137
|
+
return f"Path not found: {path}"
|
|
138
|
+
if not target.is_dir():
|
|
139
|
+
return f"Not a directory: {path}"
|
|
140
|
+
entries = sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name))
|
|
141
|
+
lines = []
|
|
142
|
+
for entry in entries:
|
|
143
|
+
marker = "/" if entry.is_dir() else ""
|
|
144
|
+
lines.append(f"{entry.name}{marker}")
|
|
145
|
+
return "\n".join(lines) if lines else "(empty directory)"
|
|
146
|
+
|
|
147
|
+
def edit_file(self, path: str, old_text: str, new_text: str) -> str:
|
|
148
|
+
"""Targeted find-and-replace — far cheaper in tokens than rewriting
|
|
149
|
+
a whole file via write_file, and safer since it fails loudly if the
|
|
150
|
+
anchor text isn't found or isn't unique, instead of silently
|
|
151
|
+
clobbering unrelated content."""
|
|
152
|
+
reason = check_write_content(new_text)
|
|
153
|
+
if reason:
|
|
154
|
+
return (
|
|
155
|
+
f"Guardrail refused this edit: {reason} (malicious-code "
|
|
156
|
+
"indicator). Policy forbids creating malware or exploit code."
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
target = self.resolve_path(path)
|
|
160
|
+
except PermissionDenied as e:
|
|
161
|
+
return str(e)
|
|
162
|
+
if not target.exists():
|
|
163
|
+
return f"File not found: {path}"
|
|
164
|
+
content = target.read_text(errors="replace")
|
|
165
|
+
count = content.count(old_text)
|
|
166
|
+
if count == 0:
|
|
167
|
+
return f"'old_text' not found in {path}. No changes made — check for exact whitespace/formatting differences."
|
|
168
|
+
if count > 1:
|
|
169
|
+
return f"'old_text' appears {count} times in {path}. Make it more specific so the edit is unambiguous. No changes made."
|
|
170
|
+
if not self.confirm(f"replace one occurrence of text in `{path}`"):
|
|
171
|
+
return "Permission denied by user."
|
|
172
|
+
updated = content.replace(old_text, new_text, 1)
|
|
173
|
+
target.write_text(updated)
|
|
174
|
+
return f"Replaced 1 occurrence in {path}."
|
|
175
|
+
|
|
176
|
+
def run_tests(self, command: str = "pytest", timeout: int = 60) -> str:
|
|
177
|
+
"""Runs a test command (default: pytest) and returns its output.
|
|
178
|
+
Separate from run_bash mainly so the model has a clearly-named,
|
|
179
|
+
single-purpose action for 'verify my work' rather than free-form
|
|
180
|
+
shell access every time."""
|
|
181
|
+
reason = check_bash_command(command)
|
|
182
|
+
if reason:
|
|
183
|
+
return f"Guardrail blocked this test command: {reason}. It looks destructive or malicious and cannot run."
|
|
184
|
+
if not self.confirm(f"run tests with: `{command}`"):
|
|
185
|
+
return "Permission denied by user."
|
|
186
|
+
try:
|
|
187
|
+
result = subprocess.run(
|
|
188
|
+
command,
|
|
189
|
+
shell=True,
|
|
190
|
+
cwd=self.workdir,
|
|
191
|
+
capture_output=True,
|
|
192
|
+
text=True,
|
|
193
|
+
timeout=timeout,
|
|
194
|
+
)
|
|
195
|
+
output = (result.stdout or "") + (result.stderr or "")
|
|
196
|
+
output = output.strip() or "(no output)"
|
|
197
|
+
status = "PASSED" if result.returncode == 0 else f"FAILED (exit code {result.returncode})"
|
|
198
|
+
return f"{status}\n\n{output[-4000:]}"
|
|
199
|
+
except subprocess.TimeoutExpired:
|
|
200
|
+
return f"Test command timed out after {timeout}s."
|
llm.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from langchain_openrouter import ChatOpenRouter
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
# Exposed so main.py's banner can show the real model name instead of
|
|
10
|
+
# "unknown" — previously main.py only checked HF_MODEL_ID/OPENROUTER_MODEL
|
|
11
|
+
# env vars, but this file hardcodes the model directly, so neither existed.
|
|
12
|
+
DEFAULT_MODEL = "nvidia/nemotron-3.5-lightning:free"
|
|
13
|
+
|
|
14
|
+
# Curated starter list shown by the /models command. Each entry is
|
|
15
|
+
# (openrouter model id, short note). This is just shortcuts — the user can
|
|
16
|
+
# always switch to any other OpenRouter model id with `/model <id>`.
|
|
17
|
+
# Free-tier availability and pricing change over time; when in doubt check
|
|
18
|
+
# https://openrouter.ai/models before picking a paid one.
|
|
19
|
+
KNOWN_MODELS = [
|
|
20
|
+
("nvidia/nemotron-3.5-lightning:free", "default · free · fast, decent tool calling"),
|
|
21
|
+
("qwen/qwen-2.5-coder-32b-instruct:free", "free · code-specialized, solid tool use"),
|
|
22
|
+
("meta-llama/llama-3.1-8b-instruct:free", "free · fastest, weakest tool calling"),
|
|
23
|
+
("qwen/qwen-2.5-72b-instruct", "paid · strong, reliable tool calling"),
|
|
24
|
+
("meta-llama/llama-3.1-70b-instruct", "paid · strong, reliable tool calling"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_model_arg(arg: str, choices: list) -> str:
|
|
29
|
+
"""Turn a /model argument into a model id. A number picks from `choices`
|
|
30
|
+
(1-based, as shown by the last /models listing); anything else is
|
|
31
|
+
treated as a raw OpenRouter model id and passed through unchanged."""
|
|
32
|
+
arg = arg.strip()
|
|
33
|
+
if arg.isdigit():
|
|
34
|
+
idx = int(arg) - 1
|
|
35
|
+
if 0 <= idx < len(choices):
|
|
36
|
+
return choices[idx][0]
|
|
37
|
+
return arg
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Live model list from OpenRouter
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
_MODELS_ENDPOINT = "https://openrouter.ai/api/v1/models"
|
|
45
|
+
_MODELS_CACHE_PATH = os.path.join(
|
|
46
|
+
os.path.expanduser("~"), ".cache", "closecode", "openrouter_models.json"
|
|
47
|
+
)
|
|
48
|
+
_MODELS_CACHE_TTL = 24 * 3600 # seconds
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_cached_models():
|
|
52
|
+
"""Return the cached [(id, note)] list if it's still fresh, else None."""
|
|
53
|
+
try:
|
|
54
|
+
with open(_MODELS_CACHE_PATH) as f:
|
|
55
|
+
data = json.load(f)
|
|
56
|
+
if time.time() - data.get("fetched_at", 0) < _MODELS_CACHE_TTL:
|
|
57
|
+
models = data.get("models")
|
|
58
|
+
if models:
|
|
59
|
+
return [tuple(m) for m in models]
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _save_cached_models(models: list) -> None:
|
|
66
|
+
try:
|
|
67
|
+
os.makedirs(os.path.dirname(_MODELS_CACHE_PATH), exist_ok=True)
|
|
68
|
+
with open(_MODELS_CACHE_PATH, "w") as f:
|
|
69
|
+
json.dump({"fetched_at": time.time(), "models": models}, f)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _note_for(entry: dict) -> str:
|
|
75
|
+
"""Short human note: display name + free or $/M input pricing."""
|
|
76
|
+
name = entry.get("name") or entry.get("id", "")
|
|
77
|
+
pricing = entry.get("pricing") or {}
|
|
78
|
+
try:
|
|
79
|
+
prompt_per_token = float(pricing.get("prompt") or 0)
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
prompt_per_token = 0
|
|
82
|
+
if prompt_per_token == 0:
|
|
83
|
+
return f"{name} · free"
|
|
84
|
+
return f"{name} · ${prompt_per_token * 1e6:.2f}/M"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def fetch_openrouter_models(force_refresh: bool = False):
|
|
88
|
+
"""Return (models, source) where models is [(id, note)] and source is
|
|
89
|
+
one of "live", "cache", "fallback".
|
|
90
|
+
|
|
91
|
+
Tries the 24h disk cache first, then the OpenRouter API (no auth
|
|
92
|
+
needed for the public models endpoint). Anything failing — no network,
|
|
93
|
+
bad response — falls back to the hardcoded KNOWN_MODELS shortlist so
|
|
94
|
+
/models always shows something useful.
|
|
95
|
+
"""
|
|
96
|
+
if not force_refresh:
|
|
97
|
+
cached = _load_cached_models()
|
|
98
|
+
if cached:
|
|
99
|
+
return cached, "cache"
|
|
100
|
+
try:
|
|
101
|
+
resp = requests.get(_MODELS_ENDPOINT, timeout=15)
|
|
102
|
+
resp.raise_for_status()
|
|
103
|
+
items = resp.json().get("data") or []
|
|
104
|
+
models = []
|
|
105
|
+
for entry in items:
|
|
106
|
+
mid = entry.get("id") or ""
|
|
107
|
+
if not mid:
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
free = float((entry.get("pricing") or {}).get("prompt") or 0) == 0
|
|
111
|
+
except (TypeError, ValueError):
|
|
112
|
+
free = False
|
|
113
|
+
models.append((mid, _note_for(entry), free))
|
|
114
|
+
# Free models first (cheapest to try), then alphabetical — stable,
|
|
115
|
+
# predictable ordering so list numbers don't shuffle randomly.
|
|
116
|
+
models.sort(key=lambda t: (not t[2], t[0].lower()))
|
|
117
|
+
models = [(mid, note) for mid, note, _ in models]
|
|
118
|
+
if models:
|
|
119
|
+
_save_cached_models(models)
|
|
120
|
+
return models, "live"
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
return list(KNOWN_MODELS), "fallback"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_llm(model_override: str = None):
|
|
127
|
+
|
|
128
|
+
token = os.environ.get("OPENROUTER_API_KEY")
|
|
129
|
+
|
|
130
|
+
if not token:
|
|
131
|
+
raise EnvironmentError(
|
|
132
|
+
"OPENROUTER_API_KEY is not set. "
|
|
133
|
+
"Get a key at https://openrouter.ai/settings/keys"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
model = model_override or os.environ.get("OPENROUTER_MODEL", DEFAULT_MODEL)
|
|
137
|
+
|
|
138
|
+
llm = ChatOpenRouter(
|
|
139
|
+
model=model,
|
|
140
|
+
temperature=0.2,
|
|
141
|
+
max_tokens=4096,
|
|
142
|
+
api_key=token,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return llm
|