xcoding 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.
- xcode/__init__.py +3 -0
- xcode/__main__.py +4 -0
- xcode/agent.py +341 -0
- xcode/backends.py +141 -0
- xcode/cli.py +600 -0
- xcode/config.py +72 -0
- xcode/hooks.py +74 -0
- xcode/input_bar.py +357 -0
- xcode/mcp.py +157 -0
- xcode/memory.py +34 -0
- xcode/permissions.py +80 -0
- xcode/session.py +67 -0
- xcode/tools.py +451 -0
- xcode/ui.py +349 -0
- xcoding-0.1.0.dist-info/METADATA +190 -0
- xcoding-0.1.0.dist-info/RECORD +20 -0
- xcoding-0.1.0.dist-info/WHEEL +5 -0
- xcoding-0.1.0.dist-info/entry_points.txt +2 -0
- xcoding-0.1.0.dist-info/licenses/LICENSE +21 -0
- xcoding-0.1.0.dist-info/top_level.txt +1 -0
xcode/permissions.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Persistent permission rules, stored per-project in .xcode/permissions.json.
|
|
2
|
+
|
|
3
|
+
The model proposes an action (write a file, run a command); the CLI asks the
|
|
4
|
+
user. If the user picks "always", we remember it here so we stop asking.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
STORE = Path(".xcode") / "permissions.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Permissions:
|
|
16
|
+
def __init__(self, store: Path = STORE):
|
|
17
|
+
self.store = store
|
|
18
|
+
self.tools: set[str] = set() # tool kinds always allowed wholesale
|
|
19
|
+
self.cmd_prefixes: set[str] = set() # command first-words always allowed
|
|
20
|
+
self._load()
|
|
21
|
+
|
|
22
|
+
# ---- queries -----------------------------------------------------------
|
|
23
|
+
def is_allowed(self, kind: str, target: str) -> bool:
|
|
24
|
+
if kind in self.tools:
|
|
25
|
+
return True
|
|
26
|
+
if kind == "run_command":
|
|
27
|
+
head = _first_word(target)
|
|
28
|
+
return head in self.cmd_prefixes
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
# ---- mutations ---------------------------------------------------------
|
|
32
|
+
def allow_kind(self, kind: str) -> None:
|
|
33
|
+
self.tools.add(kind)
|
|
34
|
+
self._save()
|
|
35
|
+
|
|
36
|
+
def allow_command(self, target: str) -> str:
|
|
37
|
+
head = _first_word(target)
|
|
38
|
+
self.cmd_prefixes.add(head)
|
|
39
|
+
self._save()
|
|
40
|
+
return head
|
|
41
|
+
|
|
42
|
+
def reset(self) -> None:
|
|
43
|
+
self.tools.clear()
|
|
44
|
+
self.cmd_prefixes.clear()
|
|
45
|
+
if self.store.exists():
|
|
46
|
+
self.store.unlink()
|
|
47
|
+
|
|
48
|
+
def summary(self) -> str:
|
|
49
|
+
parts = []
|
|
50
|
+
if self.tools:
|
|
51
|
+
parts.append("tools: " + ", ".join(sorted(self.tools)))
|
|
52
|
+
if self.cmd_prefixes:
|
|
53
|
+
parts.append("commands: " + ", ".join(sorted(self.cmd_prefixes)))
|
|
54
|
+
return " · ".join(parts) or "(none yet)"
|
|
55
|
+
|
|
56
|
+
# ---- persistence -------------------------------------------------------
|
|
57
|
+
def _load(self) -> None:
|
|
58
|
+
if not self.store.exists():
|
|
59
|
+
return
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(self.store.read_text(encoding="utf-8"))
|
|
62
|
+
self.tools = set(data.get("tools", []))
|
|
63
|
+
self.cmd_prefixes = set(data.get("cmd_prefixes", []))
|
|
64
|
+
except Exception:
|
|
65
|
+
pass # corrupt store: start clean, don't crash
|
|
66
|
+
|
|
67
|
+
def _save(self) -> None:
|
|
68
|
+
try:
|
|
69
|
+
self.store.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
self.store.write_text(json.dumps({
|
|
71
|
+
"tools": sorted(self.tools),
|
|
72
|
+
"cmd_prefixes": sorted(self.cmd_prefixes),
|
|
73
|
+
}, indent=2), encoding="utf-8")
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _first_word(command: str) -> str:
|
|
79
|
+
toks = command.strip().split()
|
|
80
|
+
return toks[0] if toks else ""
|
xcode/session.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Save / load / list conversation sessions under .xcode/sessions/.
|
|
2
|
+
|
|
3
|
+
A session is just the message list plus a little metadata, so you can quit and
|
|
4
|
+
pick up where you left off with `xcode --resume`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
DIR = Path(".xcode") / "sessions"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _slug() -> str:
|
|
17
|
+
return time.strftime("%Y%m%d-%H%M%S")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save(messages: list[dict], model: str, session_id: str | None) -> str:
|
|
21
|
+
DIR.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
sid = session_id or _slug()
|
|
23
|
+
path = DIR / f"{sid}.json"
|
|
24
|
+
payload = {
|
|
25
|
+
"id": sid,
|
|
26
|
+
"model": model,
|
|
27
|
+
"updated": time.time(),
|
|
28
|
+
"messages": messages,
|
|
29
|
+
}
|
|
30
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
31
|
+
return sid
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def latest() -> dict | None:
|
|
35
|
+
sessions = _all_paths()
|
|
36
|
+
return _read(sessions[0]) if sessions else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load(session_id: str) -> dict | None:
|
|
40
|
+
path = DIR / f"{session_id}.json"
|
|
41
|
+
return _read(path) if path.exists() else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def listing() -> list[dict]:
|
|
45
|
+
out = []
|
|
46
|
+
for p in _all_paths():
|
|
47
|
+
d = _read(p)
|
|
48
|
+
if not d:
|
|
49
|
+
continue
|
|
50
|
+
msgs = [m for m in d.get("messages", []) if m.get("role") == "user"]
|
|
51
|
+
first = msgs[0]["content"][:60] if msgs else "(empty)"
|
|
52
|
+
out.append({"id": d.get("id", p.stem), "model": d.get("model", "?"),
|
|
53
|
+
"turns": len(msgs), "first": first})
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _all_paths() -> list[Path]:
|
|
58
|
+
if not DIR.exists():
|
|
59
|
+
return []
|
|
60
|
+
return sorted(DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read(path: Path) -> dict | None:
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
except Exception:
|
|
67
|
+
return None
|
xcode/tools.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""The tools the agent can call, their JSON schemas, and a dispatcher.
|
|
2
|
+
|
|
3
|
+
Each tool returns a string (what the model sees as the result). Tools that
|
|
4
|
+
mutate the system (write_file, edit_file, run_command) go through a confirm()
|
|
5
|
+
callback: confirm(kind, target, detail) -> bool, where the CLI may consult
|
|
6
|
+
persistent permission rules and/or ask the user.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
import html
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable
|
|
17
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
# confirm(kind, target, detail) -> allow?
|
|
22
|
+
# kind = stable id of the action ("write_file", "run_command", ...)
|
|
23
|
+
# target = the thing acted on (a path, or the command string)
|
|
24
|
+
# detail = human-readable preview shown to the user
|
|
25
|
+
Confirm = Callable[[str, str, str], bool]
|
|
26
|
+
|
|
27
|
+
MAX_OUTPUT = 20_000 # clip giant tool results so we don't blow context
|
|
28
|
+
|
|
29
|
+
# Side channel: the last write/edit stashes its diff here so the CLI can render
|
|
30
|
+
# a Claude-Code-style numbered diff even in auto mode (where there's no confirm
|
|
31
|
+
# preview). Reset at the start of every dispatch().
|
|
32
|
+
RENDER: dict = {}
|
|
33
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
34
|
+
".xcode", "dist", "build", ".mypy_cache", ".pytest_cache"}
|
|
35
|
+
|
|
36
|
+
# Long-running commands started with background=True keep running here so the
|
|
37
|
+
# agent can fire off a server/build and carry on. background_count() prunes the
|
|
38
|
+
# finished ones and reports how many are still alive (shown in the spinner).
|
|
39
|
+
_BG: list = []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def background_count() -> int:
|
|
43
|
+
"""How many background shells are still running (finished ones pruned)."""
|
|
44
|
+
_BG[:] = [p for p in _BG if p.poll() is None]
|
|
45
|
+
return len(_BG)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _clip(text: str) -> str:
|
|
49
|
+
if len(text) > MAX_OUTPUT:
|
|
50
|
+
return text[:MAX_OUTPUT] + f"\n... [clipped {len(text) - MAX_OUTPUT} chars]"
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _skipped(p: Path) -> bool:
|
|
55
|
+
return any(part in SKIP_DIRS for part in p.parts)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _diff(old: str, new: str, path: str) -> str:
|
|
59
|
+
"""A compact unified diff for confirmation previews."""
|
|
60
|
+
lines = list(difflib.unified_diff(
|
|
61
|
+
old.splitlines(), new.splitlines(),
|
|
62
|
+
fromfile=path, tofile=path, lineterm="", n=2))
|
|
63
|
+
if not lines:
|
|
64
|
+
return "(no textual changes)"
|
|
65
|
+
if len(lines) > 80:
|
|
66
|
+
lines = lines[:80] + [f"... [{len(lines) - 80} more diff lines]"]
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------- tool impls
|
|
71
|
+
|
|
72
|
+
def read_file(path: str) -> str:
|
|
73
|
+
p = Path(path)
|
|
74
|
+
if not p.exists():
|
|
75
|
+
return f"ERROR: no such file: {path}"
|
|
76
|
+
if p.is_dir():
|
|
77
|
+
return f"ERROR: {path} is a directory (use list_dir)"
|
|
78
|
+
try:
|
|
79
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
80
|
+
numbered = "\n".join(f"{i:>5} {ln}"
|
|
81
|
+
for i, ln in enumerate(text.splitlines(), 1))
|
|
82
|
+
return _clip(numbered)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
return f"ERROR reading {path}: {e}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def write_file(path: str, content: str, confirm: Confirm) -> str:
|
|
88
|
+
p = Path(path)
|
|
89
|
+
old = p.read_text(encoding="utf-8", errors="replace") if p.exists() else ""
|
|
90
|
+
diff = _diff(old, content, path)
|
|
91
|
+
RENDER["diff"] = diff
|
|
92
|
+
detail = f"{'Overwrite' if p.exists() else 'Create'} {path}\n\n{diff}"
|
|
93
|
+
if not confirm("write_file", path, detail):
|
|
94
|
+
return "DENIED: user declined the write."
|
|
95
|
+
try:
|
|
96
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
p.write_text(content, encoding="utf-8")
|
|
98
|
+
return f"OK: wrote {len(content)} chars to {path}"
|
|
99
|
+
except Exception as e:
|
|
100
|
+
return f"ERROR writing {path}: {e}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def edit_file(path: str, old_string: str, new_string: str, confirm: Confirm) -> str:
|
|
104
|
+
p = Path(path)
|
|
105
|
+
if not p.exists():
|
|
106
|
+
return f"ERROR: no such file: {path}"
|
|
107
|
+
try:
|
|
108
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
109
|
+
except Exception as e:
|
|
110
|
+
return f"ERROR reading {path}: {e}"
|
|
111
|
+
|
|
112
|
+
count = text.count(old_string)
|
|
113
|
+
if count == 0:
|
|
114
|
+
return ("ERROR: old_string not found. It must match the file exactly "
|
|
115
|
+
"(including whitespace). Read the file first.")
|
|
116
|
+
if count > 1:
|
|
117
|
+
return (f"ERROR: old_string appears {count} times; add surrounding "
|
|
118
|
+
"context so it matches exactly once.")
|
|
119
|
+
|
|
120
|
+
new_text = text.replace(old_string, new_string, 1)
|
|
121
|
+
diff = _diff(text, new_text, path)
|
|
122
|
+
RENDER["diff"] = diff
|
|
123
|
+
detail = f"Edit {path}\n\n{diff}"
|
|
124
|
+
if not confirm("edit_file", path, detail):
|
|
125
|
+
return "DENIED: user declined the edit."
|
|
126
|
+
try:
|
|
127
|
+
p.write_text(new_text, encoding="utf-8")
|
|
128
|
+
return f"OK: edited {path}"
|
|
129
|
+
except Exception as e:
|
|
130
|
+
return f"ERROR writing {path}: {e}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def list_dir(path: str = ".") -> str:
|
|
134
|
+
p = Path(path)
|
|
135
|
+
if not p.exists():
|
|
136
|
+
return f"ERROR: no such path: {path}"
|
|
137
|
+
if p.is_file():
|
|
138
|
+
return f"{path} (file, {p.stat().st_size} bytes)"
|
|
139
|
+
try:
|
|
140
|
+
entries = sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name.lower()))
|
|
141
|
+
lines = [f"{'DIR ' if e.is_dir() else 'FILE'} {e.name}" for e in entries]
|
|
142
|
+
return "\n".join(lines) or "(empty directory)"
|
|
143
|
+
except Exception as e:
|
|
144
|
+
return f"ERROR listing {path}: {e}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def glob_files(pattern: str, path: str = ".") -> str:
|
|
148
|
+
base = Path(path)
|
|
149
|
+
if not base.exists():
|
|
150
|
+
return f"ERROR: no such path: {path}"
|
|
151
|
+
try:
|
|
152
|
+
matches = [str(m) for m in sorted(base.glob(pattern))
|
|
153
|
+
if m.is_file() and not _skipped(m)]
|
|
154
|
+
except Exception as e:
|
|
155
|
+
return f"ERROR in glob '{pattern}': {e}"
|
|
156
|
+
if not matches:
|
|
157
|
+
return f"(no files match {pattern})"
|
|
158
|
+
extra = ""
|
|
159
|
+
if len(matches) > 200:
|
|
160
|
+
extra = f"\n... [{len(matches) - 200} more]"
|
|
161
|
+
matches = matches[:200]
|
|
162
|
+
return "\n".join(matches) + extra
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def grep(pattern: str, path: str = ".", glob: str = "**/*") -> str:
|
|
166
|
+
try:
|
|
167
|
+
rx = re.compile(pattern)
|
|
168
|
+
except re.error as e:
|
|
169
|
+
return f"ERROR: bad regex: {e}"
|
|
170
|
+
base = Path(path)
|
|
171
|
+
if not base.exists():
|
|
172
|
+
return f"ERROR: no such path: {path}"
|
|
173
|
+
|
|
174
|
+
hits: list[str] = []
|
|
175
|
+
for f in base.glob(glob):
|
|
176
|
+
if not f.is_file() or _skipped(f):
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
for i, line in enumerate(f.read_text(encoding="utf-8",
|
|
180
|
+
errors="ignore").splitlines(), 1):
|
|
181
|
+
if rx.search(line):
|
|
182
|
+
hits.append(f"{f}:{i}: {line.strip()[:200]}")
|
|
183
|
+
if len(hits) >= 200:
|
|
184
|
+
return "\n".join(hits) + "\n... [stopped at 200 matches]"
|
|
185
|
+
except Exception:
|
|
186
|
+
continue
|
|
187
|
+
return "\n".join(hits) if hits else f"(no matches for /{pattern}/)"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def run_command(command: str, confirm: Confirm, background: bool = False,
|
|
191
|
+
on_output: Callable[[str], None] | None = None) -> str:
|
|
192
|
+
"""Run a shell command with real-time streaming output.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
command: Shell command to execute
|
|
196
|
+
confirm: Permission callback
|
|
197
|
+
background: Run detached without waiting
|
|
198
|
+
on_output: Optional callback for streaming output line-by-line
|
|
199
|
+
"""
|
|
200
|
+
if not confirm("run_command", command, command):
|
|
201
|
+
return "DENIED: user declined the command."
|
|
202
|
+
|
|
203
|
+
if background:
|
|
204
|
+
try:
|
|
205
|
+
proc = subprocess.Popen(command, shell=True,
|
|
206
|
+
stdout=subprocess.DEVNULL,
|
|
207
|
+
stderr=subprocess.DEVNULL)
|
|
208
|
+
_BG.append(proc)
|
|
209
|
+
return (f"OK: started in background (pid {proc.pid}); "
|
|
210
|
+
f"{background_count()} background shell(s) running.")
|
|
211
|
+
except Exception as e:
|
|
212
|
+
return f"ERROR starting background command: {e}"
|
|
213
|
+
|
|
214
|
+
# Stream output in real-time if callback provided
|
|
215
|
+
if on_output:
|
|
216
|
+
try:
|
|
217
|
+
# Use unbuffered output for real-time streaming
|
|
218
|
+
proc = subprocess.Popen(
|
|
219
|
+
command,
|
|
220
|
+
shell=True,
|
|
221
|
+
stdout=subprocess.PIPE,
|
|
222
|
+
stderr=subprocess.STDOUT, # Merge stderr into stdout
|
|
223
|
+
text=True,
|
|
224
|
+
bufsize=0, # Unbuffered
|
|
225
|
+
universal_newlines=True
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
output_lines = []
|
|
229
|
+
|
|
230
|
+
# Read output character by character for true real-time streaming
|
|
231
|
+
while True:
|
|
232
|
+
char = proc.stdout.read(1)
|
|
233
|
+
if not char:
|
|
234
|
+
# Process finished
|
|
235
|
+
if proc.poll() is not None:
|
|
236
|
+
break
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
# Stream each character immediately
|
|
240
|
+
on_output(char)
|
|
241
|
+
|
|
242
|
+
# Also collect for final result
|
|
243
|
+
if char == '\n':
|
|
244
|
+
output_lines.append('')
|
|
245
|
+
elif output_lines or char.strip():
|
|
246
|
+
if not output_lines:
|
|
247
|
+
output_lines.append('')
|
|
248
|
+
output_lines[-1] = output_lines[-1] + char
|
|
249
|
+
|
|
250
|
+
proc.wait(timeout=120)
|
|
251
|
+
|
|
252
|
+
# Build result
|
|
253
|
+
body = '\n'.join(output_lines).strip()
|
|
254
|
+
body += f"\n[exit {proc.returncode}]"
|
|
255
|
+
|
|
256
|
+
return _clip(body)
|
|
257
|
+
|
|
258
|
+
except subprocess.TimeoutExpired:
|
|
259
|
+
proc.kill()
|
|
260
|
+
return "ERROR: command timed out after 120s"
|
|
261
|
+
except Exception as e:
|
|
262
|
+
return f"ERROR running command: {e}"
|
|
263
|
+
|
|
264
|
+
# Fallback to non-streaming mode
|
|
265
|
+
try:
|
|
266
|
+
proc = subprocess.run(command, shell=True, capture_output=True,
|
|
267
|
+
text=True, timeout=120)
|
|
268
|
+
body = proc.stdout or ""
|
|
269
|
+
if proc.stderr:
|
|
270
|
+
body += "\n[stderr]\n" + proc.stderr
|
|
271
|
+
body += f"\n[exit {proc.returncode}]"
|
|
272
|
+
return _clip(body.strip())
|
|
273
|
+
except subprocess.TimeoutExpired:
|
|
274
|
+
return "ERROR: command timed out after 120s"
|
|
275
|
+
except Exception as e:
|
|
276
|
+
return f"ERROR running command: {e}"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _trim(s: str, n: int = 300) -> str:
|
|
280
|
+
s = s.replace("\n", "\\n")
|
|
281
|
+
return s if len(s) <= n else s[:n] + "…"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------- web
|
|
285
|
+
|
|
286
|
+
_TAG = re.compile(r"<[^>]+>")
|
|
287
|
+
_WS = re.compile(r"[ \t]*\n[ \t\n]*")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _html_to_text(raw: str) -> str:
|
|
291
|
+
raw = re.sub(r"(?is)<(script|style)[^>]*>.*?</\1>", " ", raw)
|
|
292
|
+
raw = re.sub(r"(?i)<br\s*/?>", "\n", raw)
|
|
293
|
+
raw = re.sub(r"(?i)</(p|div|li|h[1-6]|tr)>", "\n", raw)
|
|
294
|
+
text = html.unescape(_TAG.sub("", raw))
|
|
295
|
+
return _WS.sub("\n", text).strip()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def web_fetch(url: str) -> str:
|
|
299
|
+
if not url.startswith(("http://", "https://")):
|
|
300
|
+
url = "https://" + url
|
|
301
|
+
try:
|
|
302
|
+
r = httpx.get(url, timeout=15, follow_redirects=True,
|
|
303
|
+
headers={"User-Agent": "Mozilla/5.0 (xcode)"})
|
|
304
|
+
r.raise_for_status()
|
|
305
|
+
except Exception as e:
|
|
306
|
+
return f"ERROR fetching {url}: {e}"
|
|
307
|
+
ctype = r.headers.get("content-type", "")
|
|
308
|
+
body = _html_to_text(r.text) if "html" in ctype else r.text
|
|
309
|
+
return _clip(f"# {url}\n\n{body}")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _ddg_unwrap(href: str) -> str:
|
|
313
|
+
if "duckduckgo.com/l/" in href:
|
|
314
|
+
q = parse_qs(urlparse(href).query)
|
|
315
|
+
if "uddg" in q:
|
|
316
|
+
return unquote(q["uddg"][0])
|
|
317
|
+
return href
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def web_search(query: str) -> str:
|
|
321
|
+
try:
|
|
322
|
+
r = httpx.post("https://html.duckduckgo.com/html/",
|
|
323
|
+
data={"q": query}, timeout=15, follow_redirects=True,
|
|
324
|
+
headers={"User-Agent": "Mozilla/5.0 (xcode)"})
|
|
325
|
+
r.raise_for_status()
|
|
326
|
+
except Exception as e:
|
|
327
|
+
return f"ERROR searching: {e}"
|
|
328
|
+
|
|
329
|
+
results = []
|
|
330
|
+
pat = re.compile(
|
|
331
|
+
r'result__a"[^>]*href="(?P<url>[^"]+)"[^>]*>(?P<title>.*?)</a>'
|
|
332
|
+
r'(?:.*?result__snippet[^>]*>(?P<snip>.*?)</a>)?',
|
|
333
|
+
re.IGNORECASE | re.DOTALL)
|
|
334
|
+
for m in pat.finditer(r.text):
|
|
335
|
+
url = _ddg_unwrap(html.unescape(m.group("url")))
|
|
336
|
+
title = _html_to_text(m.group("title"))
|
|
337
|
+
snip = _html_to_text(m.group("snip") or "")
|
|
338
|
+
results.append(f"- {title}\n {url}\n {snip}".rstrip())
|
|
339
|
+
if len(results) >= 6:
|
|
340
|
+
break
|
|
341
|
+
if not results:
|
|
342
|
+
return f"(no results for '{query}')"
|
|
343
|
+
return f"Results for '{query}':\n\n" + "\n\n".join(results)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ------------------------------------------------------------------ schemas
|
|
347
|
+
|
|
348
|
+
def _fn(name, desc, props, required=()):
|
|
349
|
+
return {"type": "function", "function": {
|
|
350
|
+
"name": name, "description": desc,
|
|
351
|
+
"parameters": {"type": "object", "properties": props,
|
|
352
|
+
"required": list(required)}}}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
TOOL_SCHEMAS = [
|
|
356
|
+
_fn("read_file", "Read a file's contents (returned with line numbers).",
|
|
357
|
+
{"path": {"type": "string"}}, ["path"]),
|
|
358
|
+
_fn("write_file", "Create or overwrite a file with the given full content.",
|
|
359
|
+
{"path": {"type": "string"}, "content": {"type": "string"}},
|
|
360
|
+
["path", "content"]),
|
|
361
|
+
_fn("edit_file",
|
|
362
|
+
"Replace an exact substring in a file. old_string must match exactly "
|
|
363
|
+
"once. Prefer this over write_file for small changes.",
|
|
364
|
+
{"path": {"type": "string"},
|
|
365
|
+
"old_string": {"type": "string", "description": "exact text to replace"},
|
|
366
|
+
"new_string": {"type": "string", "description": "replacement text"}},
|
|
367
|
+
["path", "old_string", "new_string"]),
|
|
368
|
+
_fn("list_dir", "List files and folders at a path (default current dir).",
|
|
369
|
+
{"path": {"type": "string"}}),
|
|
370
|
+
_fn("glob_files", "Find files by glob pattern, e.g. '**/*.py'.",
|
|
371
|
+
{"pattern": {"type": "string"},
|
|
372
|
+
"path": {"type": "string", "description": "root to search, default '.'"}},
|
|
373
|
+
["pattern"]),
|
|
374
|
+
_fn("grep",
|
|
375
|
+
"Search file contents by regex. Returns path:line: text matches.",
|
|
376
|
+
{"pattern": {"type": "string", "description": "regular expression"},
|
|
377
|
+
"path": {"type": "string", "description": "root, default '.'"},
|
|
378
|
+
"glob": {"type": "string", "description": "file filter, default '**/*'"}},
|
|
379
|
+
["pattern"]),
|
|
380
|
+
_fn("run_command",
|
|
381
|
+
"Run a shell command; returns stdout/stderr/exit code. Set "
|
|
382
|
+
"background=true for long-running things (servers, watchers) to start "
|
|
383
|
+
"them detached and keep working.",
|
|
384
|
+
{"command": {"type": "string"},
|
|
385
|
+
"background": {"type": "boolean",
|
|
386
|
+
"description": "run detached, don't wait for it"}},
|
|
387
|
+
["command"]),
|
|
388
|
+
_fn("web_search", "Search the web (DuckDuckGo). Returns titles/urls/snippets.",
|
|
389
|
+
{"query": {"type": "string"}}, ["query"]),
|
|
390
|
+
_fn("web_fetch", "Fetch a URL and return its text content.",
|
|
391
|
+
{"url": {"type": "string"}}, ["url"]),
|
|
392
|
+
_fn("spawn_agent",
|
|
393
|
+
"Delegate a self-contained subtask to a fresh sub-agent with its own "
|
|
394
|
+
"context. Use for big searches or isolated chunks of work. Returns the "
|
|
395
|
+
"sub-agent's final report.",
|
|
396
|
+
{"task": {"type": "string", "description": "what the sub-agent should do"}},
|
|
397
|
+
["task"]),
|
|
398
|
+
_fn("ask_user",
|
|
399
|
+
"Ask the user a clarifying question and let them pick from a short list "
|
|
400
|
+
"(rendered as an arrow-key menu). Use this PROACTIVELY whenever the "
|
|
401
|
+
"request is open-ended or ambiguous — call it repeatedly, one question "
|
|
402
|
+
"at a time, to nail down scope, stack, hosting, naming, features, etc. "
|
|
403
|
+
"before building. Returns the chosen option.",
|
|
404
|
+
{"question": {"type": "string",
|
|
405
|
+
"description": "one focused question"},
|
|
406
|
+
"options": {"type": "array", "items": {"type": "string"},
|
|
407
|
+
"description": "2-5 concise, distinct choices; optionally "
|
|
408
|
+
"include a 'you decide' style option"}},
|
|
409
|
+
["question", "options"]),
|
|
410
|
+
_fn("update_todos",
|
|
411
|
+
"Record/update the task plan for a multi-step request.",
|
|
412
|
+
{"todos": {"type": "array", "items": {"type": "object", "properties": {
|
|
413
|
+
"content": {"type": "string"},
|
|
414
|
+
"status": {"type": "string",
|
|
415
|
+
"enum": ["pending", "in_progress", "completed"]}},
|
|
416
|
+
"required": ["content", "status"]}}},
|
|
417
|
+
["todos"]),
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def dispatch(name: str, args: dict, confirm: Confirm, on_output: Callable[[str], None] | None = None) -> str:
|
|
422
|
+
"""Route a tool call from the model to the right implementation."""
|
|
423
|
+
RENDER.clear()
|
|
424
|
+
try:
|
|
425
|
+
if name == "read_file":
|
|
426
|
+
return read_file(args["path"])
|
|
427
|
+
if name == "write_file":
|
|
428
|
+
return write_file(args["path"], args["content"], confirm)
|
|
429
|
+
if name == "edit_file":
|
|
430
|
+
return edit_file(args["path"], args["old_string"],
|
|
431
|
+
args["new_string"], confirm)
|
|
432
|
+
if name == "list_dir":
|
|
433
|
+
return list_dir(args.get("path", "."))
|
|
434
|
+
if name == "glob_files":
|
|
435
|
+
return glob_files(args["pattern"], args.get("path", "."))
|
|
436
|
+
if name == "grep":
|
|
437
|
+
return grep(args["pattern"], args.get("path", "."),
|
|
438
|
+
args.get("glob", "**/*"))
|
|
439
|
+
if name == "run_command":
|
|
440
|
+
return run_command(args["command"], confirm,
|
|
441
|
+
background=bool(args.get("background", False)),
|
|
442
|
+
on_output=on_output)
|
|
443
|
+
if name == "web_search":
|
|
444
|
+
return web_search(args["query"])
|
|
445
|
+
if name == "web_fetch":
|
|
446
|
+
return web_fetch(args["url"])
|
|
447
|
+
return f"ERROR: unknown tool '{name}'"
|
|
448
|
+
except KeyError as e:
|
|
449
|
+
return f"ERROR: tool '{name}' missing required argument {e}"
|
|
450
|
+
except Exception as e:
|
|
451
|
+
return f"ERROR in tool '{name}': {e}"
|