minimalcode 1.5.2__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.
Files changed (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. minimalcode-1.5.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,521 @@
1
+ """Search primitives for the grep and find tools.
2
+
3
+ The same ignore rules and regex semantics are applied by two engines: ripgrep
4
+ when it can be spawned, and a bounded Python fallback otherwise. This module
5
+ owns process launch and output parsing so the runtime only has to choose an
6
+ engine and shape the result.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import queue
14
+ import re
15
+ import signal
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from ..errors import ToolError
25
+ from ..sandbox import SandboxRunner
26
+
27
+ # Shared by the Python fallback walk and the ripgrep invocations so both
28
+ # implementations search the same set of files.
29
+ IGNORED_DIRECTORIES: tuple[str, ...] = (
30
+ ".git",
31
+ ".hg",
32
+ ".svn",
33
+ ".mypy_cache",
34
+ ".pytest_cache",
35
+ ".ruff_cache",
36
+ "__pycache__",
37
+ ".venv",
38
+ "build",
39
+ "dist",
40
+ "node_modules",
41
+ "venv",
42
+ )
43
+ IGNORED_GLOBS: tuple[str, ...] = tuple(
44
+ glob for name in IGNORED_DIRECTORIES for glob in (f"!{name}/**", f"!**/{name}/**")
45
+ )
46
+
47
+ # Match ripgrep's --max-columns output width and keep the Python fallback from
48
+ # returning a single minified line that swallows the whole context budget.
49
+ _MAX_GREP_LINE_CHARS = 300
50
+ _MAX_READ_LINE_CHARS = 1_000_000
51
+ _SEARCH_TIMEOUT_SECONDS = 30
52
+ _SEARCH_MAX_OUTPUT_CHARS = 1_000_000
53
+
54
+ # The fallback runs in a child interpreter so a pathological regex cannot block
55
+ # the agent process. It receives one JSON document on stdin and emits one JSON
56
+ # document on stdout; line counting deliberately ignores fragments of a line
57
+ # longer than the per-line cap.
58
+ _PYTHON_GREP_SCRIPT = r"""
59
+ import json
60
+ import os
61
+ import re
62
+ import sys
63
+
64
+ spec = json.load(sys.stdin)
65
+ try:
66
+ regex = re.compile(
67
+ spec["pattern"], re.IGNORECASE if spec["ignore_case"] else 0
68
+ )
69
+ except re.error as exc:
70
+ print(json.dumps({"error": f"invalid regular expression: {exc}"}))
71
+ raise SystemExit(0)
72
+
73
+ matches = []
74
+ for path in spec["files"]:
75
+ try:
76
+ if os.path.getsize(path) > spec["max_bytes"]:
77
+ continue
78
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
79
+ number = 0
80
+ while True:
81
+ line = handle.readline(spec["max_line_chars"])
82
+ if not line:
83
+ break
84
+ if line.endswith("\r") and len(line) >= spec["max_line_chars"]:
85
+ # The limit may land between CR and LF; consume the LF so
86
+ # it cannot be counted as an extra empty line.
87
+ position = handle.tell()
88
+ following = handle.read(1)
89
+ if following == "\n":
90
+ line += following
91
+ else:
92
+ handle.seek(position)
93
+ number += 1
94
+ cut = len(line) >= spec["max_line_chars"] and not line.endswith(
95
+ ("\n", "\r")
96
+ )
97
+ if cut:
98
+ # A line of exactly the limit may still be complete; look at
99
+ # the following character before dropping its tail.
100
+ position = handle.tell()
101
+ following = handle.read(1)
102
+ if not following:
103
+ cut = False
104
+ elif following == "\n":
105
+ line += following
106
+ cut = False
107
+ elif following == "\r":
108
+ line += following
109
+ peek = handle.read(1)
110
+ if peek == "\n":
111
+ line += peek
112
+ elif peek:
113
+ handle.seek(position + 1)
114
+ cut = False
115
+ else:
116
+ handle.seek(position)
117
+ if cut:
118
+ # Swallow the rest of this physical line so the next
119
+ # readline starts a new line and `number` stays aligned
120
+ # with the file instead of counting each fragment.
121
+ while True:
122
+ rest = handle.readline(spec["max_line_chars"])
123
+ if not rest:
124
+ break
125
+ if rest.endswith("\n"):
126
+ break
127
+ if rest.endswith("\r") and len(rest) >= spec["max_line_chars"]:
128
+ rest_position = handle.tell()
129
+ rest_following = handle.read(1)
130
+ if rest_following != "\n":
131
+ handle.seek(rest_position)
132
+ break
133
+ break
134
+ if rest.endswith("\r"):
135
+ break
136
+ if "\x00" in line:
137
+ continue
138
+ # Match the line content, not its terminator. `$` and `\s` would
139
+ # otherwise see the newline and report matches ripgrep never
140
+ # finds (`\s+$`, `[^0-9]$`), and a CRLF line would hide a real
141
+ # `c$` match.
142
+ text = line.rstrip("\r\n")
143
+ if not regex.search(text):
144
+ continue
145
+ if cut or len(text) > spec["max_columns"]:
146
+ text = text[: spec["max_columns"]] + " [... omitted end of long line]"
147
+ matches.append(f"{path}:{number}:{text}")
148
+ # One match past the limit proves truncation; an exact fit is a
149
+ # complete result, matching the ripgrep and inline engines.
150
+ if len(matches) > spec["limit"]:
151
+ print(
152
+ json.dumps(
153
+ {"matches": matches[: spec["limit"]], "truncated": True}
154
+ )
155
+ )
156
+ raise SystemExit(0)
157
+ except (OSError, UnicodeError):
158
+ continue
159
+
160
+ print(json.dumps({"matches": matches, "truncated": False}))
161
+ """
162
+
163
+
164
+ def smart_case_ignore(pattern: str) -> bool:
165
+ """Return True when ripgrep's --smart-case would ignore case."""
166
+ literal: list[str] = []
167
+ index = 0
168
+ while index < len(pattern):
169
+ if pattern[index] == "\\" and index + 1 < len(pattern):
170
+ # An escaped character carries no case of its own. Skipping the
171
+ # pair keeps `\\D` (an escaped backslash plus a literal `D`) visible
172
+ # to the check, which stripping every letter escape hid.
173
+ index += 2
174
+ continue
175
+ literal.append(pattern[index])
176
+ index += 1
177
+ return not any(char.isupper() for char in literal)
178
+
179
+
180
+ def drain_physical_line(handle: Any, limit: int) -> None:
181
+ """Consume the rest of a line that was longer than ``limit``."""
182
+ while True:
183
+ rest = handle.readline(limit)
184
+ if not rest:
185
+ return
186
+ if rest.endswith("\n"):
187
+ return
188
+ if rest.endswith("\r") and len(rest) >= limit:
189
+ # A CRLF split at the limit must consume the LF too; otherwise it
190
+ # becomes an empty line and shifts every later line number.
191
+ position = handle.tell()
192
+ following = handle.read(1)
193
+ if following != "\n":
194
+ handle.seek(position)
195
+ return
196
+ return
197
+ if rest.endswith("\r"):
198
+ return
199
+
200
+
201
+ def _kill_process_tree(process: subprocess.Popen[bytes]) -> None:
202
+ """Kill a search child and any descendant that inherited its stdout."""
203
+ if os.name == "nt":
204
+ try:
205
+ subprocess.run(
206
+ ["taskkill", "/F", "/T", "/PID", str(process.pid)],
207
+ stdin=subprocess.DEVNULL,
208
+ stdout=subprocess.DEVNULL,
209
+ stderr=subprocess.DEVNULL,
210
+ timeout=10,
211
+ check=False,
212
+ )
213
+ except (OSError, subprocess.TimeoutExpired):
214
+ pass
215
+ else:
216
+ try:
217
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
218
+ except OSError:
219
+ pass
220
+ try:
221
+ process.kill()
222
+ except OSError:
223
+ pass
224
+
225
+
226
+ def inline_guard_available() -> bool:
227
+ """Return True when the in-process regex fallback can enforce a deadline.
228
+
229
+ ``inline_python_grep`` uses ``SIGALRM``, which exists only on POSIX and is
230
+ delivered only to the main thread. The agent runs adjacent read-only tools
231
+ in worker threads, so callers must not pick that fallback when the guard is
232
+ unavailable.
233
+ """
234
+ return hasattr(signal, "SIGALRM") and (
235
+ threading.current_thread() is threading.main_thread()
236
+ )
237
+
238
+
239
+ def run_bounded_command(
240
+ command: list[str],
241
+ *,
242
+ cwd: Path,
243
+ limit: int,
244
+ timeout: float = _SEARCH_TIMEOUT_SECONDS,
245
+ sandbox: SandboxRunner | None = None,
246
+ ) -> tuple[int, list[str], bool, str]:
247
+ """Run a search process while keeping only ``limit`` output lines.
248
+
249
+ The result is ``(returncode, lines, truncated, stderr)``. Reading stops as
250
+ soon as one extra line proves the result is truncated, then the child is
251
+ killed so a repository-wide search cannot outrun the caller.
252
+ """
253
+ if sandbox is not None:
254
+ result = sandbox.run_argv(
255
+ command,
256
+ cwd=cwd,
257
+ timeout=timeout,
258
+ max_output_chars=_SEARCH_MAX_OUTPUT_CHARS,
259
+ )
260
+ if result.get("status") == "timeout":
261
+ raise ToolError(f"search timed out after {timeout:g} seconds")
262
+ if result.get("status") == "error" and result.get("exit_code") is None:
263
+ detail = str(
264
+ result.get("message") or result.get("stderr") or "search failed"
265
+ )
266
+ raise ToolError(detail)
267
+ # `str.splitlines()` also breaks on form feeds, U+2028, and a lone CR
268
+ # inside a matched line, so the tail of the line was dropped as if it
269
+ # were noise. Search output is newline-delimited.
270
+ lines = [
271
+ line.rstrip("\r")
272
+ for line in str(result.get("stdout") or "").split("\n")
273
+ if line
274
+ ]
275
+ truncated = bool(result.get("truncated")) or len(lines) > limit
276
+ stderr = str(result.get("stderr") or "").strip()
277
+ return int(result.get("exit_code", 0) or 0), lines[:limit], truncated, stderr
278
+
279
+ timed_out = threading.Event()
280
+ process: subprocess.Popen[bytes] | None = None
281
+ with tempfile.TemporaryFile() as err_file:
282
+ try:
283
+ process = subprocess.Popen(
284
+ command,
285
+ cwd=cwd,
286
+ stdin=subprocess.DEVNULL,
287
+ stdout=subprocess.PIPE,
288
+ stderr=err_file,
289
+ # A process group lets the timeout kill grandchildren that keep
290
+ # the stdout pipe open after the direct child exited.
291
+ start_new_session=os.name != "nt",
292
+ )
293
+ except (OSError, ValueError) as exc:
294
+ raise ToolError(f"search failed: {exc}") from exc
295
+
296
+ assert process.stdout is not None
297
+ # Reading in a daemon thread keeps a grandchild that inherited stdout
298
+ # from blocking the caller past the deadline. The direct child can exit
299
+ # first, and on Windows the surviving grandchild is no longer reachable
300
+ # through taskkill's process tree.
301
+ output: queue.Queue[bytes | None] = queue.Queue()
302
+ reader_done = threading.Event()
303
+
304
+ def read_output() -> None:
305
+ try:
306
+ for _ in range(limit + 2):
307
+ raw = process.stdout.readline()
308
+ if not raw:
309
+ break
310
+ output.put(raw)
311
+ except (OSError, ValueError):
312
+ pass
313
+ finally:
314
+ output.put(None)
315
+ reader_done.set()
316
+
317
+ reader = threading.Thread(target=read_output, daemon=True)
318
+ reader.start()
319
+ lines: list[str] = []
320
+ truncated = False
321
+ deadline = time.monotonic() + timeout
322
+ while len(lines) <= limit:
323
+ remaining = deadline - time.monotonic()
324
+ if remaining <= 0:
325
+ timed_out.set()
326
+ break
327
+ try:
328
+ raw = output.get(timeout=remaining)
329
+ except queue.Empty:
330
+ timed_out.set()
331
+ break
332
+ if raw is None:
333
+ break
334
+ lines.append(raw.decode("utf-8", errors="replace").rstrip("\r\n"))
335
+ if len(lines) > limit:
336
+ truncated = True
337
+ lines = lines[:limit]
338
+ _kill_process_tree(process)
339
+ elif timed_out.is_set():
340
+ _kill_process_tree(process)
341
+ try:
342
+ process.wait(timeout=5)
343
+ except subprocess.TimeoutExpired:
344
+ timed_out.set()
345
+ _kill_process_tree(process)
346
+ # Closing the pipe is safe only once the reader stopped using it; a
347
+ # blocked read is left to its daemon thread instead of deadlocking.
348
+ if reader_done.wait(1.0):
349
+ process.stdout.close()
350
+ err_file.seek(0)
351
+ stderr = err_file.read(4000).decode("utf-8", errors="replace").strip()
352
+ if timed_out.is_set():
353
+ raise ToolError(f"search timed out after {timeout:g} seconds")
354
+ return process.returncode, lines, truncated, stderr
355
+
356
+
357
+ def _parse_python_grep(stdout: str, limit: int) -> dict[str, Any]:
358
+ """Validate the fallback child's JSON and cap its match list."""
359
+ try:
360
+ parsed = json.loads(stdout)
361
+ except json.JSONDecodeError as exc:
362
+ raise ToolError("search returned an invalid result") from exc
363
+ if not isinstance(parsed, dict):
364
+ raise ToolError("search returned an invalid result")
365
+ if parsed.get("error"):
366
+ raise ToolError(str(parsed["error"]))
367
+ matches = parsed.get("matches")
368
+ if not isinstance(matches, list):
369
+ raise ToolError("search returned an invalid result")
370
+ return {
371
+ "status": "success",
372
+ "matches": [str(item) for item in matches[:limit]],
373
+ "truncated": bool(parsed.get("truncated")),
374
+ }
375
+
376
+
377
+ def run_python_grep(
378
+ *,
379
+ pattern: str,
380
+ files: list[Path],
381
+ limit: int,
382
+ max_bytes: int,
383
+ sandbox: SandboxRunner | None = None,
384
+ cwd: Path | None = None,
385
+ ) -> dict[str, Any]:
386
+ """Run the regex fallback in a child process so a bad pattern can time out."""
387
+ spec = {
388
+ "pattern": pattern,
389
+ "ignore_case": smart_case_ignore(pattern),
390
+ "files": [str(path) for path in files],
391
+ "limit": limit,
392
+ "max_bytes": max_bytes,
393
+ "max_line_chars": min(max_bytes + 1, _MAX_READ_LINE_CHARS),
394
+ "max_columns": _MAX_GREP_LINE_CHARS,
395
+ }
396
+ script = [sys.executable, "-c", _PYTHON_GREP_SCRIPT]
397
+ if sandbox is not None:
398
+ # The child emits one bounded line per match, so the JSON envelope is
399
+ # larger than the default search cap. A truncated envelope is invalid
400
+ # JSON and used to surface as a bogus "invalid result" error.
401
+ output_cap = max(
402
+ _SEARCH_MAX_OUTPUT_CHARS,
403
+ limit * (_MAX_GREP_LINE_CHARS + 4096) + 4096,
404
+ )
405
+ result = sandbox.run_argv(
406
+ script,
407
+ cwd=cwd or Path.cwd(),
408
+ timeout=_SEARCH_TIMEOUT_SECONDS,
409
+ max_output_chars=output_cap,
410
+ stdin_text=json.dumps(spec),
411
+ )
412
+ if result.get("status") == "timeout":
413
+ raise ToolError("search timed out")
414
+ if result.get("status") == "error" and result.get("exit_code") is None:
415
+ detail = str(
416
+ result.get("message") or result.get("stderr") or "search failed"
417
+ )
418
+ raise ToolError(detail)
419
+ returncode = int(result.get("exit_code", 0) or 0)
420
+ stdout = str(result.get("stdout") or "")
421
+ stderr = str(result.get("stderr") or "").strip()
422
+ else:
423
+ try:
424
+ completed = subprocess.run(
425
+ script,
426
+ input=json.dumps(spec),
427
+ capture_output=True,
428
+ text=True,
429
+ encoding="utf-8",
430
+ timeout=_SEARCH_TIMEOUT_SECONDS,
431
+ check=False,
432
+ )
433
+ except subprocess.TimeoutExpired as exc:
434
+ raise ToolError("search timed out") from exc
435
+ except OSError as exc:
436
+ raise ToolError(f"search failed: {exc}") from exc
437
+ returncode = completed.returncode
438
+ stdout = completed.stdout
439
+ stderr = completed.stderr.strip()
440
+ if returncode != 0:
441
+ detail = stderr or f"search exited {returncode}"
442
+ raise ToolError(detail)
443
+ return _parse_python_grep(stdout, limit)
444
+
445
+
446
+ def inline_python_grep(
447
+ *,
448
+ pattern: str,
449
+ files: list[Path],
450
+ limit: int,
451
+ max_bytes: int,
452
+ ) -> dict[str, Any]:
453
+ """Pure-Python fallback used when the backend cannot run argv children.
454
+
455
+ A backend without argv support runs the regular expression in this process,
456
+ so a bounded alarm is the only thing that keeps a catastrophic pattern from
457
+ hanging the agent. Windows has no SIGALRM; the docker backend that needs
458
+ this path is POSIX-only, so the guard is best-effort elsewhere.
459
+ """
460
+ flags = re.IGNORECASE if smart_case_ignore(pattern) else 0
461
+ try:
462
+ regex = re.compile(pattern, flags)
463
+ except re.error as exc:
464
+ raise ToolError(f"invalid regular expression: {exc}") from exc
465
+
466
+ use_alarm = (
467
+ hasattr(signal, "SIGALRM")
468
+ and threading.current_thread() is threading.main_thread()
469
+ )
470
+ previous_handler: Any = None
471
+ if use_alarm:
472
+
473
+ def _timeout(_signum: int, _frame: Any) -> None:
474
+ raise ToolError(
475
+ f"search timed out after {_SEARCH_TIMEOUT_SECONDS:g} seconds"
476
+ )
477
+
478
+ previous_handler = signal.signal(signal.SIGALRM, _timeout)
479
+ signal.setitimer(signal.ITIMER_REAL, _SEARCH_TIMEOUT_SECONDS)
480
+
481
+ matches: list[str] = []
482
+ try:
483
+ for path in files:
484
+ try:
485
+ if path.stat().st_size > max_bytes:
486
+ continue
487
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
488
+ for number, line in enumerate(handle, start=1):
489
+ if "\x00" in line:
490
+ continue
491
+ # Match the content without the terminator so `$` and
492
+ # `\s` behave like the ripgrep engine.
493
+ text = line.rstrip("\r\n")
494
+ if not regex.search(text):
495
+ continue
496
+ if len(text) > _MAX_GREP_LINE_CHARS:
497
+ text = (
498
+ text[:_MAX_GREP_LINE_CHARS]
499
+ + " [... omitted end of long line]"
500
+ )
501
+ matches.append(f"{path}:{number}:{text}")
502
+ if len(matches) > limit:
503
+ return {
504
+ "status": "success",
505
+ "matches": matches[:limit],
506
+ "truncated": True,
507
+ }
508
+ except (OSError, UnicodeError):
509
+ # An unreadable file must not abort a repository-wide search.
510
+ continue
511
+ finally:
512
+ if use_alarm:
513
+ signal.setitimer(signal.ITIMER_REAL, 0)
514
+ # `getsignal` returns None when the handler was installed outside
515
+ # Python; passing that back raises TypeError and would leave our
516
+ # timeout handler installed for the rest of the process.
517
+ signal.signal(
518
+ signal.SIGALRM,
519
+ previous_handler if previous_handler is not None else signal.SIG_DFL,
520
+ )
521
+ return {"status": "success", "matches": matches[:limit], "truncated": False}