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.
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