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.
Files changed (46) hide show
  1. {regcode-0.7.0 → regcode-0.8.1}/PKG-INFO +3 -1
  2. {regcode-0.7.0 → regcode-0.8.1}/pyproject.toml +3 -1
  3. {regcode-0.7.0 → regcode-0.8.1}/regcode/__init__.py +1 -1
  4. regcode-0.8.1/regcode/clipboard.py +378 -0
  5. {regcode-0.7.0 → regcode-0.8.1}/regcode/main.py +37 -19
  6. regcode-0.8.1/regcode/paste.py +188 -0
  7. {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/builtins.py +157 -45
  8. {regcode-0.7.0 → regcode-0.8.1}/regcode/tui.py +334 -21
  9. regcode-0.8.1/tests/test_clipboard.py +502 -0
  10. {regcode-0.7.0 → regcode-0.8.1}/uv.lock +63 -1
  11. {regcode-0.7.0 → regcode-0.8.1}/.gitignore +0 -0
  12. {regcode-0.7.0 → regcode-0.8.1}/.gitlab-ci.yml +0 -0
  13. {regcode-0.7.0 → regcode-0.8.1}/.python-version +0 -0
  14. {regcode-0.7.0 → regcode-0.8.1}/.qwen/settings.json +0 -0
  15. {regcode-0.7.0 → regcode-0.8.1}/AGENTS.md +0 -0
  16. {regcode-0.7.0 → regcode-0.8.1}/GOALS.md +0 -0
  17. {regcode-0.7.0 → regcode-0.8.1}/README.md +0 -0
  18. {regcode-0.7.0 → regcode-0.8.1}/config.yaml.template +0 -0
  19. {regcode-0.7.0 → regcode-0.8.1}/main.py +0 -0
  20. {regcode-0.7.0 → regcode-0.8.1}/regcode/cli.py +0 -0
  21. {regcode-0.7.0 → regcode-0.8.1}/regcode/config.py +0 -0
  22. {regcode-0.7.0 → regcode-0.8.1}/regcode/conversation_manager.py +0 -0
  23. {regcode-0.7.0 → regcode-0.8.1}/regcode/dependencies.py +0 -0
  24. {regcode-0.7.0 → regcode-0.8.1}/regcode/monty_sandbox.py +0 -0
  25. {regcode-0.7.0 → regcode-0.8.1}/regcode/permissions.py +0 -0
  26. {regcode-0.7.0 → regcode-0.8.1}/regcode/sandbox.py +0 -0
  27. {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/__init__.py +0 -0
  28. {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/base.py +0 -0
  29. {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/registry.py +0 -0
  30. {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/review_notes.py +0 -0
  31. {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/web_crawl.py +0 -0
  32. {regcode-0.7.0 → regcode-0.8.1}/regcode/tools/web_search.py +0 -0
  33. {regcode-0.7.0 → regcode-0.8.1}/regcode/tracing.py +0 -0
  34. {regcode-0.7.0 → regcode-0.8.1}/tests/__init__.py +0 -0
  35. {regcode-0.7.0 → regcode-0.8.1}/tests/conftest.py +0 -0
  36. {regcode-0.7.0 → regcode-0.8.1}/tests/test_api.py +0 -0
  37. {regcode-0.7.0 → regcode-0.8.1}/tests/test_cli.py +0 -0
  38. {regcode-0.7.0 → regcode-0.8.1}/tests/test_config.py +0 -0
  39. {regcode-0.7.0 → regcode-0.8.1}/tests/test_context_manager.py +0 -0
  40. {regcode-0.7.0 → regcode-0.8.1}/tests/test_dependencies.py +0 -0
  41. {regcode-0.7.0 → regcode-0.8.1}/tests/test_main.py +0 -0
  42. {regcode-0.7.0 → regcode-0.8.1}/tests/test_monty_sandbox.py +0 -0
  43. {regcode-0.7.0 → regcode-0.8.1}/tests/test_osv_cve.py +0 -0
  44. {regcode-0.7.0 → regcode-0.8.1}/tests/test_provider_extra.py +0 -0
  45. {regcode-0.7.0 → regcode-0.8.1}/tests/test_review_notes.py +0 -0
  46. {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.7.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.7.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]
@@ -2,4 +2,4 @@ from regcode.config import Config
2
2
  from regcode.main import Agent, AgentStatus, ToolCall
3
3
 
4
4
  __all__ = ["Agent", "Config", "AgentStatus", "ToolCall"]
5
- __version__ = "0.6.3"
5
+ __version__ = "0.8.1"
@@ -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 (required if 'messages' not provided).
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": message},
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
- (msg.get("role"), msg.get("content", ""))
460
- for msg in self._history
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
- if (msg.get("role"), msg.get("content", "")) not in existing_content_keys:
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
- review_instruction: str | None = None,
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: