regcode 0.7.0__tar.gz → 0.8.0__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.0}/PKG-INFO +2 -1
- {regcode-0.7.0 → regcode-0.8.0}/pyproject.toml +2 -1
- {regcode-0.7.0 → regcode-0.8.0}/regcode/__init__.py +1 -1
- regcode-0.8.0/regcode/clipboard.py +378 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/main.py +30 -8
- regcode-0.8.0/regcode/paste.py +188 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/builtins.py +157 -45
- {regcode-0.7.0 → regcode-0.8.0}/regcode/tui.py +334 -21
- regcode-0.8.0/tests/test_clipboard.py +502 -0
- {regcode-0.7.0 → regcode-0.8.0}/uv.lock +12 -1
- {regcode-0.7.0 → regcode-0.8.0}/.gitignore +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/.gitlab-ci.yml +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/.python-version +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/.qwen/settings.json +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/AGENTS.md +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/GOALS.md +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/README.md +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/config.yaml.template +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/main.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/cli.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/config.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/conversation_manager.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/dependencies.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/monty_sandbox.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/permissions.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/sandbox.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/__init__.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/base.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/registry.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/review_notes.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/web_crawl.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/web_search.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/regcode/tracing.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/__init__.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/conftest.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/test_api.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/test_cli.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/test_config.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/test_context_manager.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/test_dependencies.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/test_main.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/test_monty_sandbox.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/test_osv_cve.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/test_provider_extra.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/test_review_notes.py +0 -0
- {regcode-0.7.0 → regcode-0.8.0}/tests/test_web_search.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: regcode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Minimalistic coding agent with Python API and CLI
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: click>=8.0
|
|
@@ -10,6 +10,7 @@ Requires-Dist: mlflow>=3.14.0
|
|
|
10
10
|
Requires-Dist: pathspec>=1.1.1
|
|
11
11
|
Requires-Dist: pydantic-monty>=0.0.18
|
|
12
12
|
Requires-Dist: pydantic-settings>=2.14.2
|
|
13
|
+
Requires-Dist: pyperclip>=1.9.0
|
|
13
14
|
Requires-Dist: pytest>=8.0
|
|
14
15
|
Requires-Dist: pyyaml>=6.0
|
|
15
16
|
Requires-Dist: ruff>=0.8
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "regcode"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
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,7 @@ dependencies = [
|
|
|
16
16
|
"mlflow>=3.14.0",
|
|
17
17
|
"textual>=2.0.0",
|
|
18
18
|
"pathspec>=1.1.1",
|
|
19
|
+
"pyperclip>=1.9.0",
|
|
19
20
|
]
|
|
20
21
|
|
|
21
22
|
[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)
|
|
@@ -427,7 +427,7 @@ 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,
|
|
@@ -436,7 +436,9 @@ class Agent:
|
|
|
436
436
|
"""Send a message and get a response, with automatic tool use.
|
|
437
437
|
|
|
438
438
|
Args:
|
|
439
|
-
message: User message
|
|
439
|
+
message: User message string, or multimodal list of content parts.
|
|
440
|
+
Multimodal example: [{"type": "text", "text": "describe"},
|
|
441
|
+
{"type": "image_url", "image_url": {"url": "data:..."}}]
|
|
440
442
|
stream: Whether to stream the final response.
|
|
441
443
|
max_tool_calls: Maximum number of tool call turns.
|
|
442
444
|
messages: Pre-built message list (overrides 'message' parameter).
|
|
@@ -447,20 +449,40 @@ class Agent:
|
|
|
447
449
|
if messages is None:
|
|
448
450
|
if message is None:
|
|
449
451
|
raise ValueError("Either 'message' or 'messages' must be provided")
|
|
452
|
+
# Build user message content based on type
|
|
453
|
+
if isinstance(message, str):
|
|
454
|
+
user_content: str | list[dict[str, Any]] = message
|
|
455
|
+
elif isinstance(message, list):
|
|
456
|
+
# Multimodal message — already in litellm format
|
|
457
|
+
user_content = message
|
|
458
|
+
else:
|
|
459
|
+
raise ValueError(
|
|
460
|
+
f"message must be str or list[dict], got {type(message).__name__}"
|
|
461
|
+
)
|
|
450
462
|
messages = [
|
|
451
463
|
{"role": "system", "content": self._build_system_prompt()},
|
|
452
464
|
*self._history,
|
|
453
|
-
{"role": "user", "content":
|
|
465
|
+
{"role": "user", "content": user_content},
|
|
454
466
|
]
|
|
455
467
|
|
|
456
468
|
# Add messages to context manager for compaction tracking
|
|
457
469
|
# Only add messages not already in history to avoid double-counting
|
|
458
|
-
existing_content_keys =
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
470
|
+
existing_content_keys: set[tuple] = set()
|
|
471
|
+
for msg in self._history:
|
|
472
|
+
content = msg.get("content")
|
|
473
|
+
# Multimodal content (list) is unhashable — convert to string key
|
|
474
|
+
if isinstance(content, list):
|
|
475
|
+
content_key = json.dumps(content, sort_keys=True)
|
|
476
|
+
else:
|
|
477
|
+
content_key = content or ""
|
|
478
|
+
existing_content_keys.add((msg.get("role"), content_key))
|
|
462
479
|
for msg in messages:
|
|
463
|
-
|
|
480
|
+
content = msg.get("content")
|
|
481
|
+
if isinstance(content, list):
|
|
482
|
+
content_key = json.dumps(content, sort_keys=True)
|
|
483
|
+
else:
|
|
484
|
+
content_key = content or ""
|
|
485
|
+
if (msg.get("role"), content_key) not in existing_content_keys:
|
|
464
486
|
self.context_manager.add_message(msg)
|
|
465
487
|
|
|
466
488
|
# Check if compaction is needed
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Paste utility for file-based paste handling.
|
|
2
|
+
|
|
3
|
+
Intercepts paste events, saves content to temporary files, and generates
|
|
4
|
+
file references for the TUI. On message submission, file references are
|
|
5
|
+
detected and processed appropriately.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import mimetypes
|
|
12
|
+
import re
|
|
13
|
+
import tempfile
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import List
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class PasteEntry:
|
|
22
|
+
"""Represents a single paste entry saved to a temp file."""
|
|
23
|
+
|
|
24
|
+
file_path: Path
|
|
25
|
+
mime_type: str
|
|
26
|
+
is_image: bool
|
|
27
|
+
content: str # For images: base64 data URI; for text: raw text
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detect_mime_type(text: str) -> str:
|
|
31
|
+
"""Detect MIME type from text content.
|
|
32
|
+
|
|
33
|
+
Checks if text looks like base64-encoded image data.
|
|
34
|
+
"""
|
|
35
|
+
if _looks_like_base64_image(text):
|
|
36
|
+
try:
|
|
37
|
+
raw = base64.b64decode(text)
|
|
38
|
+
return _detect_image_from_bytes(raw)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
return "text/plain"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _looks_like_base64_image(text: str) -> bool:
|
|
46
|
+
"""Check if text looks like base64-encoded image data."""
|
|
47
|
+
if len(text) < 20:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
# Check if text is mostly base64 characters
|
|
51
|
+
b64_pattern = re.compile(r"^[A-Za-z0-9+/=\s]+$")
|
|
52
|
+
if not b64_pattern.match(text):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
# Check for common image prefixes
|
|
56
|
+
for prefix in [
|
|
57
|
+
"iVBORw", # PNG
|
|
58
|
+
"/9j/", # JPEG
|
|
59
|
+
"R0lGOD", # GIF
|
|
60
|
+
"RklGRVBI", # WebP (RIFF)
|
|
61
|
+
]:
|
|
62
|
+
if text.startswith(prefix):
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _detect_image_from_bytes(data: bytes) -> str:
|
|
69
|
+
"""Detect MIME type from image bytes."""
|
|
70
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
71
|
+
return "image/png"
|
|
72
|
+
if data[:2] == b"\xff\xd8":
|
|
73
|
+
return "image/jpeg"
|
|
74
|
+
if data[:4] in (b"GIF87a", b"GIF89a"):
|
|
75
|
+
return "image/gif"
|
|
76
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
77
|
+
return "image/webp"
|
|
78
|
+
if data[:3] == b"BM":
|
|
79
|
+
return "image/bmp"
|
|
80
|
+
return "image/unknown"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_paste_content(content: str) -> PasteEntry:
|
|
84
|
+
"""Save paste content to a temporary file.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
content: The raw text content from clipboard.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
PasteEntry with file path, MIME type, and metadata.
|
|
91
|
+
"""
|
|
92
|
+
file_id = uuid.uuid4().hex[:12]
|
|
93
|
+
mime_type = detect_mime_type(content)
|
|
94
|
+
is_image = mime_type.startswith("image/")
|
|
95
|
+
|
|
96
|
+
if is_image:
|
|
97
|
+
try:
|
|
98
|
+
raw_bytes = base64.b64decode(content)
|
|
99
|
+
# Use system temp dir with unique filename
|
|
100
|
+
ext = mimetypes.guess_extension(mime_type) or ".png"
|
|
101
|
+
if ext == ".jpe":
|
|
102
|
+
ext = ".jpg"
|
|
103
|
+
if ext == ".jfif":
|
|
104
|
+
ext = ".jpg"
|
|
105
|
+
file_path = Path(tempfile.gettempdir()) / f"regcode_paste_{file_id}{ext}"
|
|
106
|
+
file_path.write_bytes(raw_bytes)
|
|
107
|
+
except Exception:
|
|
108
|
+
file_path = Path(tempfile.gettempdir()) / f"regcode_paste_{file_id}.txt"
|
|
109
|
+
file_path.write_text(content, encoding="utf-8")
|
|
110
|
+
mime_type = "text/plain"
|
|
111
|
+
is_image = False
|
|
112
|
+
else:
|
|
113
|
+
file_path = Path(tempfile.gettempdir()) / f"regcode_paste_{file_id}.txt"
|
|
114
|
+
file_path.write_text(content, encoding="utf-8")
|
|
115
|
+
|
|
116
|
+
return PasteEntry(
|
|
117
|
+
file_path=file_path,
|
|
118
|
+
mime_type=mime_type,
|
|
119
|
+
is_image=is_image,
|
|
120
|
+
content=content,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def create_file_reference(paste_entry: PasteEntry) -> str:
|
|
125
|
+
"""Create a [FILE: ...] reference string for display in input box."""
|
|
126
|
+
return f"[FILE: {paste_entry.file_path}]"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_file_references(text: str) -> List[PasteEntry]:
|
|
130
|
+
"""Parse [FILE: ...] references from text.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
text: Text that may contain [FILE: ...] references.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of PasteEntry objects for each file reference found.
|
|
137
|
+
"""
|
|
138
|
+
pattern = r"\[FILE:\s*([^]]+?)\s*\]"
|
|
139
|
+
matches = re.finditer(pattern, text)
|
|
140
|
+
|
|
141
|
+
entries = []
|
|
142
|
+
for match in matches:
|
|
143
|
+
file_path_str = match.group(1).strip()
|
|
144
|
+
file_path = Path(file_path_str)
|
|
145
|
+
|
|
146
|
+
if not file_path.exists():
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
# Determine MIME type from extension
|
|
150
|
+
mime_type = mimetypes.guess_type(str(file_path))[0] or "text/plain"
|
|
151
|
+
is_image = mime_type.startswith("image/")
|
|
152
|
+
|
|
153
|
+
# Read content
|
|
154
|
+
try:
|
|
155
|
+
if is_image:
|
|
156
|
+
raw_bytes = file_path.read_bytes()
|
|
157
|
+
base64_data = base64.b64encode(raw_bytes).decode("utf-8")
|
|
158
|
+
content = f"data:{mime_type};base64,{base64_data}"
|
|
159
|
+
else:
|
|
160
|
+
content = file_path.read_text(encoding="utf-8")
|
|
161
|
+
except Exception:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
entries.append(
|
|
165
|
+
PasteEntry(
|
|
166
|
+
file_path=file_path,
|
|
167
|
+
mime_type=mime_type,
|
|
168
|
+
is_image=is_image,
|
|
169
|
+
content=content,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return entries
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def remove_file_references(text: str) -> str:
|
|
177
|
+
"""Remove [FILE: ...] references from text."""
|
|
178
|
+
return re.sub(r"\[FILE:\s*[^]]+?\s*\]", "", text).strip()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cleanup_paste_files(entries: List[PasteEntry]) -> None:
|
|
182
|
+
"""Remove paste files after processing."""
|
|
183
|
+
for entry in entries:
|
|
184
|
+
try:
|
|
185
|
+
if entry.file_path.exists():
|
|
186
|
+
entry.file_path.unlink()
|
|
187
|
+
except OSError:
|
|
188
|
+
pass
|