regcode 0.7.0__tar.gz → 0.8.1__tar.gz
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.
- {regcode-0.7.0 → regcode-0.8.1}/PKG-INFO +3 -1
- {regcode-0.7.0 → regcode-0.8.1}/pyproject.toml +3 -1
- {regcode-0.7.0 → regcode-0.8.1}/regcode/__init__.py +1 -1
- regcode-0.8.1/regcode/clipboard.py +378 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/main.py +37 -19
- regcode-0.8.1/regcode/paste.py +188 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/builtins.py +157 -45
- {regcode-0.7.0 → regcode-0.8.1}/regcode/tui.py +334 -21
- regcode-0.8.1/tests/test_clipboard.py +502 -0
- {regcode-0.7.0 → regcode-0.8.1}/uv.lock +63 -1
- {regcode-0.7.0 → regcode-0.8.1}/.gitignore +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/.gitlab-ci.yml +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/.python-version +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/.qwen/settings.json +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/AGENTS.md +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/GOALS.md +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/README.md +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/config.yaml.template +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/main.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/cli.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/config.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/conversation_manager.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/dependencies.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/monty_sandbox.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/permissions.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/sandbox.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/__init__.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/base.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/registry.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/review_notes.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/web_crawl.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/web_search.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/regcode/tracing.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/__init__.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/conftest.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/test_api.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/test_cli.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/test_config.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/test_context_manager.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/test_dependencies.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/test_main.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/test_monty_sandbox.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/test_osv_cve.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/test_provider_extra.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/test_review_notes.py +0 -0
- {regcode-0.7.0 → regcode-0.8.1}/tests/test_web_search.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: regcode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Minimalistic coding agent with Python API and CLI
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: boto3>=1.43.40
|
|
6
7
|
Requires-Dist: click>=8.0
|
|
7
8
|
Requires-Dist: crawl4ai>=0.4.0
|
|
8
9
|
Requires-Dist: litellm>=1.83.17
|
|
@@ -10,6 +11,7 @@ Requires-Dist: mlflow>=3.14.0
|
|
|
10
11
|
Requires-Dist: pathspec>=1.1.1
|
|
11
12
|
Requires-Dist: pydantic-monty>=0.0.18
|
|
12
13
|
Requires-Dist: pydantic-settings>=2.14.2
|
|
14
|
+
Requires-Dist: pyperclip>=1.9.0
|
|
13
15
|
Requires-Dist: pytest>=8.0
|
|
14
16
|
Requires-Dist: pyyaml>=6.0
|
|
15
17
|
Requires-Dist: ruff>=0.8
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "regcode"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.1"
|
|
4
4
|
description = "Minimalistic coding agent with Python API and CLI"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -16,6 +16,8 @@ dependencies = [
|
|
|
16
16
|
"mlflow>=3.14.0",
|
|
17
17
|
"textual>=2.0.0",
|
|
18
18
|
"pathspec>=1.1.1",
|
|
19
|
+
"pyperclip>=1.9.0",
|
|
20
|
+
"boto3>=1.43.40",
|
|
19
21
|
]
|
|
20
22
|
|
|
21
23
|
[project.scripts]
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Clipboard utilities for image and text detection, with base64 encoding.
|
|
2
|
+
|
|
3
|
+
Supports Wayland (wl-paste) and X11 (xclip/xsel) and WSL clipboard backends.
|
|
4
|
+
Detects image vs text content and provides base64 encoding for images.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import logging
|
|
11
|
+
import mimetypes
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ClipboardContent:
|
|
21
|
+
"""Represents clipboard content with type information."""
|
|
22
|
+
|
|
23
|
+
content: str # Base64 for images, raw text for text
|
|
24
|
+
content_type: str # "image" or "text"
|
|
25
|
+
mime_type: str # e.g., "image/png", "text/plain"
|
|
26
|
+
is_image: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Image extensions to MIME types mapping
|
|
30
|
+
IMAGE_EXTENSIONS: dict[str, str] = {
|
|
31
|
+
".png": "image/png",
|
|
32
|
+
".jpg": "image/jpeg",
|
|
33
|
+
".jpeg": "image/jpeg",
|
|
34
|
+
".gif": "image/gif",
|
|
35
|
+
".webp": "image/webp",
|
|
36
|
+
".bmp": "image/bmp",
|
|
37
|
+
".tiff": "image/tiff",
|
|
38
|
+
".svg": "image/svg+xml",
|
|
39
|
+
".ico": "image/x-icon",
|
|
40
|
+
".avif": "image/avif",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def detect_image_mime_type(ext: str) -> str:
|
|
45
|
+
"""Detect MIME type from file extension."""
|
|
46
|
+
ext = ext.lower()
|
|
47
|
+
return IMAGE_EXTENSIONS.get(ext, "application/octet-stream")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_clipboard_backend() -> str:
|
|
51
|
+
"""Detect which clipboard backend is available.
|
|
52
|
+
|
|
53
|
+
Returns one of: 'wl-clipboard', 'xclip', 'xsel', 'wsl', or empty string.
|
|
54
|
+
"""
|
|
55
|
+
# Check Wayland first
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
["which", "wl-paste"],
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
)
|
|
62
|
+
if result.returncode == 0:
|
|
63
|
+
return "wl-clipboard"
|
|
64
|
+
except (FileNotFoundError, OSError):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
# Check X11
|
|
68
|
+
try:
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
["which", "xclip"],
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
)
|
|
74
|
+
if result.returncode == 0:
|
|
75
|
+
return "xclip"
|
|
76
|
+
except (FileNotFoundError, OSError):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
["which", "xsel"],
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
)
|
|
85
|
+
if result.returncode == 0:
|
|
86
|
+
return "xsel"
|
|
87
|
+
except (FileNotFoundError, OSError):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
# Check WSL clipboard integration (clip.exe + powershell.exe)
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["which", "clip.exe"],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
)
|
|
97
|
+
if result.returncode == 0:
|
|
98
|
+
result2 = subprocess.run(
|
|
99
|
+
["which", "powershell.exe"],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
)
|
|
103
|
+
if result2.returncode == 0:
|
|
104
|
+
return "wsl"
|
|
105
|
+
except (FileNotFoundError, OSError):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_clipboard_text(backend: str) -> str:
|
|
112
|
+
"""Read plain text from clipboard.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
backend: Clipboard backend to use ('wl-clipboard', 'xclip', 'xsel', 'wsl', '')
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Text content from clipboard, or empty string on failure.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
if backend == "wl-clipboard":
|
|
122
|
+
result = subprocess.run(
|
|
123
|
+
["wl-paste", "-t", "text/plain"],
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True,
|
|
126
|
+
timeout=5,
|
|
127
|
+
)
|
|
128
|
+
elif backend == "xclip":
|
|
129
|
+
result = subprocess.run(
|
|
130
|
+
["xclip", "-selection", "clipboard", "-o"],
|
|
131
|
+
capture_output=True,
|
|
132
|
+
text=True,
|
|
133
|
+
timeout=5,
|
|
134
|
+
)
|
|
135
|
+
elif backend == "xsel":
|
|
136
|
+
result = subprocess.run(
|
|
137
|
+
["xsel", "--clipboard", "--output"],
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True,
|
|
140
|
+
timeout=5,
|
|
141
|
+
)
|
|
142
|
+
elif backend == "wsl":
|
|
143
|
+
# WSL clipboard: use PowerShell to read clipboard
|
|
144
|
+
ps_script = (
|
|
145
|
+
"[Convert]::ToBase64String("
|
|
146
|
+
"[Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw)))"
|
|
147
|
+
)
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
[
|
|
150
|
+
"powershell.exe",
|
|
151
|
+
"-noprofile",
|
|
152
|
+
"-command",
|
|
153
|
+
ps_script,
|
|
154
|
+
],
|
|
155
|
+
capture_output=True,
|
|
156
|
+
text=True,
|
|
157
|
+
timeout=5,
|
|
158
|
+
)
|
|
159
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
160
|
+
try:
|
|
161
|
+
base64_data = result.stdout.strip()
|
|
162
|
+
decoded_bytes = base64.b64decode(base64_data)
|
|
163
|
+
text = decoded_bytes.decode("utf-8")
|
|
164
|
+
# Normalize Windows line endings to Unix for terminal display
|
|
165
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
166
|
+
return text.strip()
|
|
167
|
+
except Exception:
|
|
168
|
+
return ""
|
|
169
|
+
return ""
|
|
170
|
+
else:
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
if result.returncode == 0:
|
|
174
|
+
text = result.stdout
|
|
175
|
+
# Normalize Windows line endings to Unix for terminal display
|
|
176
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
177
|
+
return text.strip()
|
|
178
|
+
else:
|
|
179
|
+
logger.debug("Failed to read text from clipboard: %s", result.stderr)
|
|
180
|
+
return ""
|
|
181
|
+
except (subprocess.TimeoutExpired, OSError, subprocess.SubprocessError) as e:
|
|
182
|
+
logger.debug("Error reading clipboard text: %s", e)
|
|
183
|
+
return ""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def read_clipboard_image(backend: str) -> Optional[bytes]:
|
|
187
|
+
"""Read raw image bytes from clipboard.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
backend: Clipboard backend to use
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Raw image bytes, or None if clipboard doesn't contain an image.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
if backend == "wl-clipboard":
|
|
197
|
+
result = subprocess.run(
|
|
198
|
+
["wl-paste", "-t", "image/png"],
|
|
199
|
+
capture_output=True,
|
|
200
|
+
timeout=5,
|
|
201
|
+
)
|
|
202
|
+
if result.returncode == 0 and len(result.stdout) > 0:
|
|
203
|
+
return result.stdout
|
|
204
|
+
elif backend == "xclip":
|
|
205
|
+
# xclip doesn't support direct image output, need to check with xwd
|
|
206
|
+
# This is a fallback - primary Wayland support is through wl-clipboard
|
|
207
|
+
result = subprocess.run(
|
|
208
|
+
["wl-paste", "-t", "image/png"],
|
|
209
|
+
capture_output=True,
|
|
210
|
+
timeout=5,
|
|
211
|
+
)
|
|
212
|
+
if result.returncode == 0 and len(result.stdout) > 0:
|
|
213
|
+
return result.stdout
|
|
214
|
+
elif backend == "wsl":
|
|
215
|
+
# WSL: load Windows Forms assembly and read clipboard image
|
|
216
|
+
ps_script = (
|
|
217
|
+
"Add-Type -AssemblyName System.Windows.Forms; "
|
|
218
|
+
"$ms = New-Object IO.MemoryStream; "
|
|
219
|
+
"[System.Windows.Forms.Clipboard]::GetImage().Save($ms, "
|
|
220
|
+
"[System.Drawing.Imaging.ImageFormat]::Png); "
|
|
221
|
+
"[Convert]::ToBase64String($ms.ToArray())"
|
|
222
|
+
)
|
|
223
|
+
result = subprocess.run(
|
|
224
|
+
[
|
|
225
|
+
"powershell.exe",
|
|
226
|
+
"-NoProfile",
|
|
227
|
+
"-NonInteractive",
|
|
228
|
+
"-Command",
|
|
229
|
+
ps_script,
|
|
230
|
+
],
|
|
231
|
+
capture_output=True,
|
|
232
|
+
text=True,
|
|
233
|
+
timeout=10,
|
|
234
|
+
)
|
|
235
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
236
|
+
try:
|
|
237
|
+
# PowerShell wraps base64 with newlines, remove all whitespace
|
|
238
|
+
clean = result.stdout.replace("\n", "").replace("\r", "").strip()
|
|
239
|
+
return base64.b64decode(clean)
|
|
240
|
+
except Exception:
|
|
241
|
+
logger.debug("Failed to decode WSL clipboard image base64")
|
|
242
|
+
return None
|
|
243
|
+
except (subprocess.TimeoutExpired, OSError, subprocess.SubprocessError) as e:
|
|
244
|
+
logger.debug("Error reading clipboard image: %s", e)
|
|
245
|
+
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def clipboard_is_image(backend: str) -> bool:
|
|
250
|
+
"""Check if clipboard contains an image.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
backend: Clipboard backend to use
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
True if clipboard contains an image, False otherwise.
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
if backend == "wl-clipboard":
|
|
260
|
+
result = subprocess.run(
|
|
261
|
+
["wl-paste", "-t", "image/png"],
|
|
262
|
+
capture_output=True,
|
|
263
|
+
timeout=5,
|
|
264
|
+
)
|
|
265
|
+
if result.returncode == 0 and len(result.stdout) > 0:
|
|
266
|
+
# Verify it's actually a PNG
|
|
267
|
+
if result.stdout[:8] == b"\x89PNG\r\n\x1a\n":
|
|
268
|
+
return True
|
|
269
|
+
elif backend in ("xclip", "xsel"):
|
|
270
|
+
# For X11, check if we can extract image
|
|
271
|
+
return read_clipboard_image(backend) is not None
|
|
272
|
+
elif backend == "wsl":
|
|
273
|
+
# For WSL, try to get image via PowerShell
|
|
274
|
+
return read_clipboard_image(backend) is not None
|
|
275
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_image_mime_type(data: bytes) -> str:
|
|
282
|
+
"""Detect MIME type from image data bytes.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
data: Raw image bytes
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
MIME type string (e.g., "image/png")
|
|
289
|
+
"""
|
|
290
|
+
# Check PNG signature
|
|
291
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
292
|
+
return "image/png"
|
|
293
|
+
|
|
294
|
+
# Check JPEG signature
|
|
295
|
+
if data[:2] == b"\xff\xd8":
|
|
296
|
+
return "image/jpeg"
|
|
297
|
+
|
|
298
|
+
# Check GIF signature
|
|
299
|
+
if data[:4] in (b"GIF87a", b"GIF89a"):
|
|
300
|
+
return "image/gif"
|
|
301
|
+
|
|
302
|
+
# Check WebP signature
|
|
303
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
304
|
+
return "image/webp"
|
|
305
|
+
|
|
306
|
+
# Fallback: try to detect from magic bytes
|
|
307
|
+
mime_type = mimetypes.guess_type("image.tmp")
|
|
308
|
+
if mime_type and mime_type[0]:
|
|
309
|
+
return mime_type[0]
|
|
310
|
+
|
|
311
|
+
return "application/octet-stream"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def encode_image_to_base64(data: bytes, mime_type: str = "") -> str:
|
|
315
|
+
"""Encode image bytes to base64 data URL.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
data: Raw image bytes
|
|
319
|
+
mime_type: MIME type of the image
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Base64 data URL string (e.g., "data:image/png;base64,...")
|
|
323
|
+
"""
|
|
324
|
+
if not mime_type:
|
|
325
|
+
mime_type = get_image_mime_type(data)
|
|
326
|
+
|
|
327
|
+
base64_data = base64.b64encode(data).decode("utf-8")
|
|
328
|
+
return f"data:{mime_type};base64,{base64_data}"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def get_clipboard_content() -> ClipboardContent:
|
|
332
|
+
"""Get clipboard content, auto-detecting image vs text.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
ClipboardContent with detected type and base64-encoded data for images.
|
|
336
|
+
"""
|
|
337
|
+
backend = get_clipboard_backend()
|
|
338
|
+
if not backend:
|
|
339
|
+
return ClipboardContent(
|
|
340
|
+
content="",
|
|
341
|
+
content_type="text",
|
|
342
|
+
mime_type="text/plain",
|
|
343
|
+
is_image=False,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Check if clipboard contains an image
|
|
347
|
+
if clipboard_is_image(backend):
|
|
348
|
+
image_data = read_clipboard_image(backend)
|
|
349
|
+
if image_data is not None:
|
|
350
|
+
mime_type = get_image_mime_type(image_data)
|
|
351
|
+
base64_data = encode_image_to_base64(image_data, mime_type)
|
|
352
|
+
return ClipboardContent(
|
|
353
|
+
content=base64_data,
|
|
354
|
+
content_type="image",
|
|
355
|
+
mime_type=mime_type,
|
|
356
|
+
is_image=True,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Fall back to text
|
|
360
|
+
text_content = read_clipboard_text(backend)
|
|
361
|
+
return ClipboardContent(
|
|
362
|
+
content=text_content,
|
|
363
|
+
content_type="text",
|
|
364
|
+
mime_type="text/plain",
|
|
365
|
+
is_image=False,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def read_multiline_text(backend: str) -> str:
|
|
370
|
+
"""Read potentially multiline text from clipboard.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
backend: Clipboard backend to use
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Multiline text content from clipboard.
|
|
377
|
+
"""
|
|
378
|
+
return read_clipboard_text(backend)
|
|
@@ -5,13 +5,13 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
7
|
import sys
|
|
8
|
-
import warnings
|
|
9
8
|
from datetime import datetime, timezone
|
|
10
9
|
from enum import Enum
|
|
11
10
|
from pathlib import Path
|
|
12
11
|
from typing import Any, Callable
|
|
13
12
|
|
|
14
13
|
import litellm
|
|
14
|
+
from pydantic import BaseModel
|
|
15
15
|
|
|
16
16
|
from regcode.config import Config
|
|
17
17
|
from regcode.conversation_manager import ConversationManager
|
|
@@ -427,19 +427,23 @@ class Agent:
|
|
|
427
427
|
|
|
428
428
|
def chat(
|
|
429
429
|
self,
|
|
430
|
-
message: str | None = None,
|
|
430
|
+
message: str | list[dict[str, Any]] | None = None,
|
|
431
431
|
stream: bool = False,
|
|
432
432
|
max_tool_calls: int = 10,
|
|
433
433
|
messages: list[dict[str, Any]] | None = None,
|
|
434
434
|
on_text_chunk: Callable[[str], None] | None = None,
|
|
435
|
+
response_format: BaseModel | None = None,
|
|
435
436
|
) -> str:
|
|
436
437
|
"""Send a message and get a response, with automatic tool use.
|
|
437
438
|
|
|
438
439
|
Args:
|
|
439
|
-
message: User message
|
|
440
|
+
message: User message string, or multimodal list of content parts.
|
|
441
|
+
Multimodal example: [{"type": "text", "text": "describe"},
|
|
442
|
+
{"type": "image_url", "image_url": {"url": "data:..."}}]
|
|
440
443
|
stream: Whether to stream the final response.
|
|
441
444
|
max_tool_calls: Maximum number of tool call turns.
|
|
442
445
|
messages: Pre-built message list (overrides 'message' parameter).
|
|
446
|
+
response_format: Pydantic model for formatting the response.
|
|
443
447
|
|
|
444
448
|
Returns:
|
|
445
449
|
The agent's final response string.
|
|
@@ -447,20 +451,40 @@ class Agent:
|
|
|
447
451
|
if messages is None:
|
|
448
452
|
if message is None:
|
|
449
453
|
raise ValueError("Either 'message' or 'messages' must be provided")
|
|
454
|
+
# Build user message content based on type
|
|
455
|
+
if isinstance(message, str):
|
|
456
|
+
user_content: str | list[dict[str, Any]] = message
|
|
457
|
+
elif isinstance(message, list):
|
|
458
|
+
# Multimodal message — already in litellm format
|
|
459
|
+
user_content = message
|
|
460
|
+
else:
|
|
461
|
+
raise ValueError(
|
|
462
|
+
f"message must be str or list[dict], got {type(message).__name__}"
|
|
463
|
+
)
|
|
450
464
|
messages = [
|
|
451
465
|
{"role": "system", "content": self._build_system_prompt()},
|
|
452
466
|
*self._history,
|
|
453
|
-
{"role": "user", "content":
|
|
467
|
+
{"role": "user", "content": user_content},
|
|
454
468
|
]
|
|
455
469
|
|
|
456
470
|
# Add messages to context manager for compaction tracking
|
|
457
471
|
# Only add messages not already in history to avoid double-counting
|
|
458
|
-
existing_content_keys =
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
472
|
+
existing_content_keys: set[tuple] = set()
|
|
473
|
+
for msg in self._history:
|
|
474
|
+
content = msg.get("content")
|
|
475
|
+
# Multimodal content (list) is unhashable — convert to string key
|
|
476
|
+
if isinstance(content, list):
|
|
477
|
+
content_key = json.dumps(content, sort_keys=True)
|
|
478
|
+
else:
|
|
479
|
+
content_key = content or ""
|
|
480
|
+
existing_content_keys.add((msg.get("role"), content_key))
|
|
462
481
|
for msg in messages:
|
|
463
|
-
|
|
482
|
+
content = msg.get("content")
|
|
483
|
+
if isinstance(content, list):
|
|
484
|
+
content_key = json.dumps(content, sort_keys=True)
|
|
485
|
+
else:
|
|
486
|
+
content_key = content or ""
|
|
487
|
+
if (msg.get("role"), content_key) not in existing_content_keys:
|
|
464
488
|
self.context_manager.add_message(msg)
|
|
465
489
|
|
|
466
490
|
# Check if compaction is needed
|
|
@@ -511,6 +535,8 @@ class Agent:
|
|
|
511
535
|
elif self._tool_budget_exhausted:
|
|
512
536
|
# Explicitly tell the LLM no tools are available
|
|
513
537
|
completion_kwargs["tool_choice"] = "none"
|
|
538
|
+
if response_format is not None:
|
|
539
|
+
completion_kwargs["response_format"] = response_format
|
|
514
540
|
|
|
515
541
|
response = litellm.completion(**completion_kwargs)
|
|
516
542
|
|
|
@@ -1321,7 +1347,7 @@ class Agent:
|
|
|
1321
1347
|
max_tool_calls: int = 20,
|
|
1322
1348
|
stream: bool = False,
|
|
1323
1349
|
extra_instructions: str | None = None,
|
|
1324
|
-
|
|
1350
|
+
response_format: BaseModel | None = None,
|
|
1325
1351
|
) -> str:
|
|
1326
1352
|
"""Perform a code review on the given directory.
|
|
1327
1353
|
|
|
@@ -1344,15 +1370,6 @@ class Agent:
|
|
|
1344
1370
|
Returns:
|
|
1345
1371
|
The agent's review response string.
|
|
1346
1372
|
"""
|
|
1347
|
-
if review_instruction is not None:
|
|
1348
|
-
warnings.warn(
|
|
1349
|
-
"The 'review_instruction' parameter is deprecated; "
|
|
1350
|
-
"use 'extra_instructions' instead.",
|
|
1351
|
-
DeprecationWarning,
|
|
1352
|
-
stacklevel=2,
|
|
1353
|
-
)
|
|
1354
|
-
if extra_instructions is None:
|
|
1355
|
-
extra_instructions = review_instruction
|
|
1356
1373
|
# Scan the directory structure
|
|
1357
1374
|
dir_tree = self._scan_directory_tree(code_root_dir)
|
|
1358
1375
|
|
|
@@ -1376,6 +1393,7 @@ class Agent:
|
|
|
1376
1393
|
messages=messages,
|
|
1377
1394
|
max_tool_calls=max_tool_calls,
|
|
1378
1395
|
stream=stream,
|
|
1396
|
+
response_format=response_format
|
|
1379
1397
|
)
|
|
1380
1398
|
|
|
1381
1399
|
def cleanup(self) -> None:
|