wrencode 0.1.4__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.
wrencode.py ADDED
@@ -0,0 +1,1576 @@
1
+ #!/usr/bin/env python3
2
+ """WrenCode — a minimal agentic coding assistant inspired by Harold Wren.
3
+
4
+ A lightweight alternative to Claude Code in a single Python file.
5
+
6
+ Supports multiple inference backends: local Apple Silicon via MLX,
7
+ HuggingFace Transformers, Anthropic, OpenAI, OpenRouter, and local proxy.
8
+ Provides a tool-calling agent loop with file read/write/edit, glob, grep,
9
+ and bash — enough to autonomously navigate and modify a codebase.
10
+
11
+ Copyright 2026 Denis Burakov. All Rights Reserved.
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
26
+ THE SOFTWARE.
27
+ """
28
+
29
+ # flake8: noqa: E501, E203
30
+
31
+ import contextlib
32
+ import getpass
33
+ import glob as globlib
34
+ import json
35
+ import os
36
+ import pathlib
37
+ import platform
38
+ import re
39
+ import shutil
40
+ import subprocess
41
+ import sys
42
+ import threading
43
+ import time
44
+ import traceback
45
+ import urllib.error
46
+ import urllib.request
47
+ from typing import Any, Callable, Optional
48
+
49
+ # Load .env (next to this script, then the current directory); real env vars win.
50
+ for _dir in (os.path.dirname(os.path.abspath(__file__)), os.getcwd()):
51
+ _env_path = os.path.join(_dir, ".env")
52
+ if os.path.exists(_env_path):
53
+ with open(_env_path) as _f:
54
+ for _line in _f:
55
+ _line = _line.strip()
56
+ if not _line or _line.startswith("#") or "=" not in _line:
57
+ continue
58
+ if _line.startswith("export "):
59
+ _line = _line[len("export ") :]
60
+ _k, _v = _line.split("=", 1)
61
+ _v = _v.strip()
62
+ # Strip matching surrounding quotes, e.g. KEY="value" or KEY='value'.
63
+ if len(_v) >= 2 and _v[0] == _v[-1] and _v[0] in ("'", '"'):
64
+ _v = _v[1:-1]
65
+ os.environ.setdefault(_k.strip(), _v)
66
+
67
+ # -----------------------------------------------------------------------------------------------
68
+ # Backend Configuration
69
+ # -----------------------------------------------------------------------------------------------
70
+ WRENCODE_VERSION = "0.1.4"
71
+
72
+ # Per-backend defaults. "kind" controls how a backend is treated:
73
+ # api - hosted HTTP API, needs an API key
74
+ # local-proxy - Anthropic-compatible server already running on localhost
75
+ # local-ml - in-process model weights (mlx / transformers); source install only,
76
+ # since the standalone binary can't bundle the ML stack
77
+ BACKEND_SPECS: dict[str, dict[str, str]] = {
78
+ "anthropic": {
79
+ "kind": "api",
80
+ "model": "claude-haiku-4-5-20251001",
81
+ "key_env": "ANTHROPIC_API_KEY",
82
+ "api_base": "https://api.anthropic.com/v1/messages",
83
+ "label": "Anthropic Claude (API key)",
84
+ },
85
+ "openai": {
86
+ "kind": "api",
87
+ "model": "gpt-4o-mini",
88
+ "key_env": "OPENAI_API_KEY",
89
+ "api_base": "https://api.openai.com/v1/chat/completions",
90
+ "label": "OpenAI GPT (API key)",
91
+ },
92
+ "openrouter": {
93
+ "kind": "api",
94
+ "model": "anthropic/claude-3-haiku",
95
+ "key_env": "OPENROUTER_API_KEY",
96
+ "api_base": "https://openrouter.ai/api/v1/chat/completions",
97
+ "label": "OpenRouter — any model (API key)",
98
+ },
99
+ "local": {
100
+ "kind": "local-proxy",
101
+ "model": "gpt-oss-20b",
102
+ "key_env": "LOCAL_API_KEY",
103
+ "label": "Local proxy (Anthropic-compatible server on localhost)",
104
+ },
105
+ "ollama": {
106
+ "kind": "local-proxy",
107
+ "model": "llama3.2",
108
+ "label": "Ollama (local models via `ollama serve`)",
109
+ },
110
+ "transformers": {
111
+ "kind": "local-ml",
112
+ "model": "deburky/gpt-oss-claude-code",
113
+ "label": "HuggingFace Transformers (CPU/GPU, source install)",
114
+ },
115
+ "mlx": {
116
+ "kind": "local-ml",
117
+ "model": "mlx-community/Qwen2.5-3B-Instruct-4bit",
118
+ "label": "Apple Silicon via MLX (source install)",
119
+ },
120
+ }
121
+
122
+ # Backend capability groups, derived from the registry where possible.
123
+ API_BACKENDS: frozenset[str] = frozenset(
124
+ name for name, spec in BACKEND_SPECS.items() if spec["kind"] == "api"
125
+ )
126
+ LOCAL_ML_BACKENDS: frozenset[str] = frozenset(
127
+ name for name, spec in BACKEND_SPECS.items() if spec["kind"] == "local-ml"
128
+ )
129
+ NATIVE_TOOL_BACKENDS: frozenset[str] = frozenset({"anthropic", "openai"})
130
+
131
+ CONFIG_DIR = pathlib.Path(
132
+ os.environ.get("WRENCODE_CONFIG_DIR", "~/.wrencode")
133
+ ).expanduser()
134
+ CONFIG_FILE = CONFIG_DIR / "config.json"
135
+
136
+ # Populated by apply_backend() once configuration is resolved (see resolve_configuration).
137
+ BACKEND = ""
138
+ MODEL = ""
139
+ API_KEY = ""
140
+ API_BASE = ""
141
+ LOCAL_PORT = os.environ.get("LOCAL_PORT", "8082")
142
+
143
+ # Backend libraries are imported lazily inside load_model(); the rest of the
144
+ # module references them as globals. Declared here as Any so the module
145
+ # type-checks even when the heavy optional deps aren't installed.
146
+ load: Any = None # mlx_lm.load
147
+ stream_generate: Any = None # mlx_lm.generate.stream_generate
148
+ make_sampler: Any = None # mlx_lm.sample_utils.make_sampler
149
+ torch: Any = None # torch
150
+ AutoModelForCausalLM: Any = None # transformers.AutoModelForCausalLM
151
+ AutoTokenizer: Any = None # transformers.AutoTokenizer
152
+
153
+ # Subagent state: the loaded model (set in main) and a recursion-depth guard.
154
+ _MLX_STATE: Optional[tuple[Any, Any]] = None
155
+ _SUBAGENT_DEPTH = 0
156
+ MAX_SUBAGENT_DEPTH = int(os.environ.get("WRENCODE_MAX_SUBAGENT_DEPTH", "2"))
157
+
158
+
159
+ def apply_backend(backend: str, model: str = "", api_key: str = "") -> None:
160
+ """Set the module-level backend globals from a backend name plus overrides.
161
+
162
+ Precedence for each value: explicit environment variable > saved/chosen
163
+ value > built-in default. Heavy backend imports are deferred to load_model().
164
+ """
165
+ global BACKEND, MODEL, API_KEY, API_BASE, LOCAL_PORT
166
+ spec = BACKEND_SPECS[backend]
167
+ BACKEND = backend
168
+ MODEL = os.environ.get("MODEL") or model or spec["model"]
169
+ if spec["kind"] == "api":
170
+ API_KEY = os.environ.get(spec["key_env"]) or api_key or ""
171
+ API_BASE = spec["api_base"]
172
+ elif backend == "ollama":
173
+ base = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
174
+ API_KEY = "ollama" # Ollama ignores the key; kept non-empty for the loader
175
+ API_BASE = f"{base}/v1/chat/completions"
176
+ elif backend == "local":
177
+ LOCAL_PORT = os.environ.get("LOCAL_PORT", "8082")
178
+ API_KEY = os.environ.get("LOCAL_API_KEY") or api_key or "local"
179
+ API_BASE = f"http://localhost:{LOCAL_PORT}/v1/messages"
180
+ else: # local-ml (mlx / transformers): no key, weights loaded in-process
181
+ API_KEY = ""
182
+ API_BASE = ""
183
+
184
+
185
+ # -----------------------------------------------------------------------------------------------
186
+ # Constants & Environment Variables
187
+ # -----------------------------------------------------------------------------------------------
188
+ MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "4096"))
189
+ MAX_READ_BYTES = int(os.environ.get("MAX_READ_BYTES", str(4 * 1024 * 1024)))
190
+ MAX_READ_LINES = int(os.environ.get("MAX_READ_LINES", "800"))
191
+ GREP_MAX = int(os.environ.get("GREP_MAX_MATCHES", "80"))
192
+ BASH_TIMEOUT = int(os.environ.get("BASH_TIMEOUT", "120"))
193
+ MAX_OUT = int(os.environ.get("MAX_TOOL_OUTPUT_CHARS", "48000"))
194
+ _GLOB_SKIP: set[str] = {
195
+ s
196
+ for s in os.environ.get(
197
+ "GLOB_SKIP_DIRS",
198
+ ".git,node_modules,__pycache__,.venv,venv,dist,build,.mypy_cache,.pytest_cache,target",
199
+ ).split(",")
200
+ if s
201
+ }
202
+
203
+ # -----------------------------------------------------------------------------------------------
204
+ # Terminal Colors
205
+ # -----------------------------------------------------------------------------------------------
206
+ RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
207
+ BLUE, CYAN, GREEN, YELLOW, RED = (
208
+ "\033[34m",
209
+ "\033[36m",
210
+ "\033[32m",
211
+ "\033[33m",
212
+ "\033[31m",
213
+ )
214
+ BRIGHT_CYAN = "\033[96m"
215
+
216
+ WREN_BANNER = f"""{BRIGHT_CYAN}
217
+ \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588
218
+ \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588
219
+ \u2588\u2588 \u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588
220
+ \u2588\u2588 \u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588
221
+ \u2588\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588
222
+ {RESET}"""
223
+
224
+
225
+ # -----------------------------------------------------------------------------------------------
226
+ # Path Helpers
227
+ # -----------------------------------------------------------------------------------------------
228
+ def workspace_root() -> pathlib.Path:
229
+ """Return the resolved workspace root path from env or cwd."""
230
+ if w := os.environ.get("WRENCODE_WORKSPACE"):
231
+ return pathlib.Path(w).expanduser().resolve()
232
+ return pathlib.Path(os.getcwd()).resolve()
233
+
234
+
235
+ def paths_unrestricted() -> bool:
236
+ """Return True if WRENCODE_UNRESTRICTED_PATHS is set to a truthy value."""
237
+ return os.environ.get("WRENCODE_UNRESTRICTED_PATHS", "").lower() in (
238
+ "1",
239
+ "true",
240
+ "yes",
241
+ )
242
+
243
+
244
+ def resolve_tool_path(raw: Any) -> pathlib.Path:
245
+ """Resolve a raw path argument to an absolute Path within the workspace."""
246
+ if not raw or not str(raw).strip():
247
+ raise ValueError("path is required")
248
+ p = pathlib.Path(str(raw).strip()).expanduser()
249
+ root = workspace_root()
250
+ p = p.resolve() if p.is_absolute() else (root / p).resolve()
251
+ if not paths_unrestricted():
252
+ try:
253
+ p.relative_to(root)
254
+ except ValueError:
255
+ raise ValueError(
256
+ f"path {raw!r} resolves outside workspace {root} "
257
+ f"(set WRENCODE_UNRESTRICTED_PATHS=1)"
258
+ ) from None
259
+ return p
260
+
261
+
262
+ # -----------------------------------------------------------------------------------------------
263
+ # Input Validation
264
+ # -----------------------------------------------------------------------------------------------
265
+ def _require_str(args: dict[str, Any], key: str) -> str:
266
+ """Require a non-empty string value from args dict by key."""
267
+ val = args.get(key)
268
+ if not val or not str(val).strip():
269
+ raise ValueError(f"'{key}' is required and must be a non-empty string")
270
+ return str(val).strip()
271
+
272
+
273
+ def _optional_int(
274
+ args: dict[str, Any], key: str, default: Optional[int] = None
275
+ ) -> Optional[int]:
276
+ """Return an optional integer from args dict, or default if absent."""
277
+ val = args.get(key)
278
+ if val is None:
279
+ return default
280
+ try:
281
+ return int(val)
282
+ except (TypeError, ValueError) as e:
283
+ raise ValueError(f"'{key}' must be an integer, got {val!r}") from e
284
+
285
+
286
+ # -----------------------------------------------------------------------------------------------
287
+ # Tools
288
+ # -----------------------------------------------------------------------------------------------
289
+ def read(args: dict[str, Any]) -> str:
290
+ """Read a file with line numbers or list a directory."""
291
+ path = resolve_tool_path(_require_str(args, "path"))
292
+ if path.is_dir():
293
+ entries = sorted(path.iterdir(), key=lambda e: (e.is_file(), e.name))
294
+ return (
295
+ "\n".join(f" {e.name}{'/' if e.is_dir() else ''}" for e in entries)
296
+ or "(empty)"
297
+ )
298
+ if not path.is_file():
299
+ return f"error: not a file: {path}"
300
+ size = path.stat().st_size
301
+ if size > MAX_READ_BYTES:
302
+ return f"error: file too large ({size} bytes, max {MAX_READ_BYTES})"
303
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines(
304
+ keepends=True
305
+ )
306
+ offset = _optional_int(args, "offset", 0) or 0
307
+ if not (0 <= offset <= len(lines)):
308
+ return (
309
+ f"error: offset {offset} out of range (file has {len(lines)} lines)"
310
+ )
311
+ limit_val = _optional_int(args, "limit")
312
+ cap = min(
313
+ limit_val
314
+ if (args.get("limit") and limit_val is not None)
315
+ else len(lines) - offset,
316
+ MAX_READ_LINES,
317
+ )
318
+ out = "".join(
319
+ f"{offset + i + 1:4}| {line}"
320
+ for i, line in enumerate(lines[offset : offset + cap])
321
+ )
322
+ if offset + cap < len(lines):
323
+ out += f"\n... ({len(lines) - offset - cap} more lines; use offset/limit or raise MAX_READ_LINES)"
324
+ return out
325
+
326
+
327
+ def write(args: dict[str, Any]) -> str:
328
+ """Write content to a file, creating parent directories as needed."""
329
+ path = resolve_tool_path(_require_str(args, "path"))
330
+ content = args.get("content", "")
331
+ if not confirm(f"Write to {path!r}"):
332
+ return "cancelled"
333
+ path.parent.mkdir(parents=True, exist_ok=True)
334
+ path.write_text(str(content), encoding="utf-8")
335
+ return "ok"
336
+
337
+
338
+ def edit(args: dict[str, Any]) -> str:
339
+ """Replace a unique string in a file with a new string."""
340
+ path = resolve_tool_path(_require_str(args, "path"))
341
+ old = _require_str(args, "old")
342
+ new = args.get("new", "")
343
+ if not path.is_file():
344
+ return f"error: not a file: {path}"
345
+ if path.stat().st_size > MAX_READ_BYTES:
346
+ return f"error: file too large (max {MAX_READ_BYTES} bytes)"
347
+ text = path.read_text(encoding="utf-8", errors="replace")
348
+ if old not in text:
349
+ return "error: old_string not found"
350
+ count = text.count(old)
351
+ if not args.get("all") and count > 1:
352
+ return f"error: old_string appears {count} times (use all=true)"
353
+ if not confirm(f"Edit {path!r}"):
354
+ return "cancelled"
355
+ path.write_text(
356
+ text.replace(old, str(new))
357
+ if args.get("all")
358
+ else text.replace(old, str(new), 1),
359
+ encoding="utf-8",
360
+ )
361
+ return "ok"
362
+
363
+
364
+ def glob(args: dict[str, Any]) -> str:
365
+ """Find files matching a glob pattern, sorted by modification time."""
366
+ if "pattern" in args and "pat" not in args:
367
+ args["pat"] = args.pop("pattern")
368
+ pat = _require_str(args, "pat")
369
+ base = resolve_tool_path(args.get("path", "."))
370
+ if not base.is_dir():
371
+ return f"error: not a directory: {base}"
372
+ files = [
373
+ f
374
+ for f in globlib.glob(str(base / pat), recursive=True)
375
+ if os.path.isfile(f)
376
+ and all(p not in _GLOB_SKIP for p in pathlib.Path(f).parts)
377
+ ]
378
+ return (
379
+ "\n".join(sorted(files, key=os.path.getmtime, reverse=True)) or "none"
380
+ )
381
+
382
+
383
+ def grep(args: dict[str, Any]) -> str:
384
+ """Search files for a regex pattern using ripgrep."""
385
+ pat = _require_str(args, "pat")
386
+ root = resolve_tool_path(args.get("path", "."))
387
+ if not root.is_dir():
388
+ return f"error: grep path must be a directory: {root}"
389
+ rg = shutil.which("rg")
390
+ grep_bin = shutil.which("grep")
391
+ if not rg and not grep_bin:
392
+ return "error: neither ripgrep (rg) nor grep is installed"
393
+ tool = rg or grep_bin
394
+ assert tool is not None # guaranteed by the check above
395
+ cmd = (
396
+ [tool, "-n", "--color", "never", "--no-heading", "-e", pat, "."]
397
+ if rg
398
+ else [tool, "-R", "-n", "-I", "--", pat, "."]
399
+ )
400
+ try:
401
+ proc = subprocess.run(
402
+ cmd,
403
+ cwd=str(root),
404
+ capture_output=True,
405
+ text=True,
406
+ timeout=90,
407
+ check=False,
408
+ )
409
+ except subprocess.TimeoutExpired:
410
+ return "error: grep timed out (90s)"
411
+ if proc.returncode not in (0, 1):
412
+ return f"error: grep failed ({proc.returncode}): {(proc.stderr or '').strip()}"
413
+ raw = proc.stdout.splitlines()
414
+ body = "\n".join(raw[:GREP_MAX]) or "none"
415
+ if len(raw) > GREP_MAX:
416
+ body += f"\n... ({len(raw) - GREP_MAX} more; raise GREP_MAX_MATCHES)"
417
+ return body
418
+
419
+
420
+ def confirm(prompt: str) -> bool:
421
+ """Prompt for y/N confirmation; auto-approve if WRENCODE_AUTO_APPROVE is set.
422
+
423
+ Auto-approve enables headless/CI use and subagents (which can't field an
424
+ interactive prompt), at the cost of running writes and shell commands
425
+ without review — only use it in a sandboxed workspace you trust.
426
+ """
427
+ if os.environ.get("WRENCODE_AUTO_APPROVE", "").lower() in ("1", "true", "yes"):
428
+ print(f"{DIM}⚠ {prompt} [auto-approved]{RESET}")
429
+ return True
430
+ return input(f"\n{YELLOW}⚠ {prompt} [y/N]{RESET} ").strip().lower() in (
431
+ "y",
432
+ "yes",
433
+ )
434
+
435
+
436
+ def bash(args: dict[str, Any]) -> str:
437
+ """Run a shell command with a timeout, streaming output to the terminal."""
438
+ cmd = _require_str(args, "cmd")
439
+ if not confirm(f"Run: {cmd!r}"):
440
+ return "cancelled"
441
+ proc = subprocess.Popen(
442
+ cmd,
443
+ shell=True,
444
+ stdout=subprocess.PIPE,
445
+ stderr=subprocess.STDOUT,
446
+ text=True,
447
+ cwd=os.getcwd(),
448
+ )
449
+ output_lines: list[str] = []
450
+
451
+ def reader() -> None:
452
+ """Read subprocess stdout line by line and print to terminal."""
453
+ with contextlib.suppress(Exception):
454
+ assert proc.stdout is not None
455
+ for line in proc.stdout:
456
+ output_lines.append(line)
457
+ print(f"{DIM}│ {line.rstrip()}{RESET}", flush=True)
458
+
459
+ t = threading.Thread(target=reader, daemon=True)
460
+ t.start()
461
+ deadline = time.monotonic() + BASH_TIMEOUT
462
+ timed_out = False
463
+ while proc.poll() is None:
464
+ if time.monotonic() > deadline:
465
+ timed_out = True
466
+ proc.kill()
467
+ output_lines.append(f"\n(timed out after {BASH_TIMEOUT}s)\n")
468
+ break
469
+ time.sleep(0.05)
470
+ t.join(timeout=2.0)
471
+ if not timed_out:
472
+ with contextlib.suppress(subprocess.TimeoutExpired):
473
+ proc.wait(timeout=2.0)
474
+ return "".join(output_lines).strip() or "(empty)"
475
+
476
+
477
+ def task(args: dict[str, Any]) -> str:
478
+ """Run a subagent: a fresh agent loop over a self-contained subtask.
479
+
480
+ The subagent shares the workspace and tool set but has its own (empty)
481
+ message history, so the parent's context only grows by the returned result.
482
+ Recursion is capped by MAX_SUBAGENT_DEPTH. For autonomous use run with
483
+ --yes / WRENCODE_AUTO_APPROVE, else each sub-tool call still asks to confirm.
484
+ """
485
+ global _SUBAGENT_DEPTH
486
+ if _SUBAGENT_DEPTH >= MAX_SUBAGENT_DEPTH:
487
+ return f"error: max subagent depth ({MAX_SUBAGENT_DEPTH}) reached"
488
+ prompt = _require_str(args, "prompt")
489
+ _SUBAGENT_DEPTH += 1
490
+ print(f"{CYAN} ↳ subagent:{RESET}{DIM} {prompt[:70]}{RESET}")
491
+ sub: list[dict[str, Any]] = [{"role": "user", "content": prompt}]
492
+ try:
493
+ run_agent_turn(sub, build_system_prompt(), _MLX_STATE, max_iters=12)
494
+ finally:
495
+ _SUBAGENT_DEPTH -= 1
496
+ texts = [flatten_content(m["content"]) for m in sub if m["role"] == "assistant"]
497
+ print(f"{CYAN} ↳ subagent done{RESET}")
498
+ return (texts[-1] if texts else "") or "(subagent produced no text output)"
499
+
500
+
501
+ ToolFn = Callable[[dict[str, Any]], str]
502
+ ToolEntry = tuple[str, dict[str, str], ToolFn]
503
+
504
+ TOOLS: dict[str, ToolEntry] = {
505
+ "read": (
506
+ "Read file with line numbers, or list directory",
507
+ {"path": "string", "offset": "number?", "limit": "number?"},
508
+ read,
509
+ ),
510
+ "write": (
511
+ "Write content to file",
512
+ {"path": "string", "content": "string"},
513
+ write,
514
+ ),
515
+ "edit": (
516
+ "Replace old with new in file",
517
+ {"path": "string", "old": "string", "new": "string", "all": "boolean?"},
518
+ edit,
519
+ ),
520
+ "glob": (
521
+ "Find files by pattern, sorted by mtime",
522
+ {"pat": "string", "path": "string?"},
523
+ glob,
524
+ ),
525
+ "grep": (
526
+ "Search files for regex",
527
+ {"pat": "string", "path": "string?"},
528
+ grep,
529
+ ),
530
+ "bash": ("Run shell command", {"cmd": "string"}, bash),
531
+ "task": (
532
+ "Delegate a self-contained subtask to a fresh subagent (same tools, "
533
+ "own context); returns only its final result",
534
+ {"prompt": "string"},
535
+ task,
536
+ ),
537
+ }
538
+
539
+
540
+ def run_tool(name: str, args: dict[str, Any]) -> str:
541
+ """Execute a named tool with args, truncating output if it exceeds MAX_OUT."""
542
+ try:
543
+ result = TOOLS[name][2](args)
544
+ if len(result) > MAX_OUT:
545
+ result = (
546
+ result[:MAX_OUT]
547
+ + f"\n... [truncated {len(result) - MAX_OUT} chars; raise MAX_TOOL_OUTPUT_CHARS]"
548
+ )
549
+ return result
550
+ except Exception as e:
551
+ return f"error: {e}"
552
+
553
+
554
+ # -----------------------------------------------------------------------------------------------
555
+ # Message Formatting
556
+ # -----------------------------------------------------------------------------------------------
557
+ def flatten_content(content: Any) -> str:
558
+ """Flatten Anthropic-style content list to plain string."""
559
+ if content is None:
560
+ return ""
561
+ if isinstance(content, str):
562
+ return content
563
+ parts: list[str] = []
564
+ for block in content:
565
+ if not isinstance(block, dict):
566
+ continue
567
+ if block.get("type") == "text":
568
+ parts.append(block["text"])
569
+ elif block.get("type") == "tool_use":
570
+ parts.append(
571
+ f'<tool_call>{{"tool": "{block["name"]}", "args": {json.dumps(block["input"])}}}</tool_call>'
572
+ )
573
+ elif block.get("type") == "tool_result":
574
+ parts.append(f"Tool result: {block.get('content', '')}")
575
+ return "\n".join(parts)
576
+
577
+
578
+ def render_markdown(text: str) -> str:
579
+ """Render basic markdown bold syntax to terminal bold escape codes."""
580
+ return re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
581
+
582
+
583
+ # -----------------------------------------------------------------------------------------------
584
+ # Token & Output Cleaning
585
+ # -----------------------------------------------------------------------------------------------
586
+ def strip_gptoss_tokens(text: str) -> str:
587
+ """Strip GPT-OSS special tokens and channel markers from model output."""
588
+ if "<|channel|>final<|message|>" in text:
589
+ text = text.split("<|channel|>final<|message|>")[-1]
590
+ return re.sub(r"<\|[^>]+\|>", "", text).strip()
591
+
592
+
593
+ def truncate_at_turn_leak(text: str) -> str:
594
+ """Truncate text at the first sign of a leaked conversation turn marker."""
595
+ return next(
596
+ (
597
+ text.split(m)[0].strip()
598
+ for m in (
599
+ "\nUser:",
600
+ "\nSystem:",
601
+ "\nHuman:",
602
+ "\n\nUser:",
603
+ "\n\nSystem:",
604
+ )
605
+ if m in text
606
+ ),
607
+ text,
608
+ )
609
+
610
+
611
+ def _tool_call_complete(text: str) -> int:
612
+ """Return end index of first complete <tool_call> block, or -1."""
613
+ start = text.find("<tool_call>")
614
+ if start == -1:
615
+ return -1
616
+ end_tag = text.find("</tool_call>", start)
617
+ if end_tag != -1:
618
+ return end_tag + len("</tool_call>")
619
+ brace_start = text.find("{", start)
620
+ if brace_start == -1:
621
+ return -1
622
+ depth, last = 0, -1
623
+ for i, ch in enumerate(text[brace_start:], brace_start):
624
+ if ch == "{":
625
+ depth += 1
626
+ elif ch == "}":
627
+ depth -= 1
628
+ if depth == 0:
629
+ last = i + 1
630
+ break
631
+ return last
632
+
633
+
634
+ def parse_tool_calls(text: str) -> list[dict[str, Any]]:
635
+ """Parse all <tool_call> blocks from model output into structured dicts.
636
+
637
+ The JSON object is extracted by brace matching (handles nesting), and the
638
+ closing </tool_call> tag is optional: the mlx/transformers streaming loop
639
+ stops at the JSON's closing brace before the tag is emitted, so requiring
640
+ it would drop every local-model tool call.
641
+ """
642
+ calls: list[dict[str, Any]] = []
643
+ pos = 0
644
+ while (start := text.find("<tool_call>", pos)) != -1:
645
+ brace = text.find("{", start)
646
+ if brace == -1:
647
+ break
648
+ depth, end = 0, -1
649
+ for i, ch in enumerate(text[brace:], brace):
650
+ if ch == "{":
651
+ depth += 1
652
+ elif ch == "}":
653
+ depth -= 1
654
+ if depth == 0:
655
+ end = i + 1
656
+ break
657
+ if end == -1:
658
+ break # JSON not yet complete
659
+ with contextlib.suppress(Exception):
660
+ d = json.loads(text[brace:end])
661
+ if d.get("tool") in TOOLS:
662
+ calls.append(
663
+ {
664
+ "type": "tool_use",
665
+ "id": f"call_{len(calls)}",
666
+ "name": d["tool"],
667
+ "input": d.get("args", {}),
668
+ }
669
+ )
670
+ pos = end
671
+ return calls
672
+
673
+
674
+ # -----------------------------------------------------------------------------------------------
675
+ # Anthropic Native Tools
676
+ # -----------------------------------------------------------------------------------------------
677
+ _TYPE_MAP: dict[str, str] = {
678
+ "string": "string",
679
+ "string?": "string",
680
+ "number": "integer",
681
+ "number?": "integer",
682
+ "boolean": "boolean",
683
+ "boolean?": "boolean",
684
+ }
685
+
686
+
687
+ def _build_anthropic_tools() -> list[dict[str, Any]]:
688
+ """Build Anthropic-native tool definitions from the TOOLS registry."""
689
+ result = []
690
+ for name, (description, params, _) in TOOLS.items():
691
+ properties: dict[str, Any] = {}
692
+ required: list[str] = []
693
+ for param_name, param_type in params.items():
694
+ properties[param_name] = {
695
+ "type": _TYPE_MAP.get(param_type, "string")
696
+ }
697
+ if not param_type.endswith("?"):
698
+ required.append(param_name)
699
+ result.append(
700
+ {
701
+ "name": name,
702
+ "description": description,
703
+ "input_schema": {
704
+ "type": "object",
705
+ "properties": properties,
706
+ "required": required,
707
+ },
708
+ }
709
+ )
710
+ return result
711
+
712
+
713
+ def _parse_anthropic_response(
714
+ data: dict[str, Any],
715
+ ) -> tuple[str, list[dict[str, Any]]]:
716
+ """Parse Anthropic API response into display text and tool_use blocks."""
717
+ text_parts: list[str] = []
718
+ tool_calls: list[dict[str, Any]] = []
719
+ for block in data.get("content", []):
720
+ if block.get("type") == "text":
721
+ text_parts.append(block["text"])
722
+ elif block.get("type") == "tool_use":
723
+ tool_calls.append(
724
+ {
725
+ "type": "tool_use",
726
+ "id": block["id"],
727
+ "name": block["name"],
728
+ "input": block.get("input", {}),
729
+ }
730
+ )
731
+ return "\n".join(text_parts).strip(), tool_calls
732
+
733
+
734
+ def _build_openai_tools() -> list[dict[str, Any]]:
735
+ """Build OpenAI-native function definitions from the TOOLS registry."""
736
+ result = []
737
+ for name, (description, params, _) in TOOLS.items():
738
+ properties: dict[str, Any] = {}
739
+ required: list[str] = []
740
+ for param_name, param_type in params.items():
741
+ properties[param_name] = {
742
+ "type": _TYPE_MAP.get(param_type, "string")
743
+ }
744
+ if not param_type.endswith("?"):
745
+ required.append(param_name)
746
+ result.append(
747
+ {
748
+ "type": "function",
749
+ "function": {
750
+ "name": name,
751
+ "description": description,
752
+ "parameters": {
753
+ "type": "object",
754
+ "properties": properties,
755
+ "required": required,
756
+ },
757
+ },
758
+ }
759
+ )
760
+ return result
761
+
762
+
763
+ def _parse_openai_response(
764
+ data: dict[str, Any],
765
+ ) -> tuple[str, list[dict[str, Any]]]:
766
+ """Parse OpenAI API response into display text and tool_call blocks."""
767
+ message = data["choices"][0]["message"]
768
+ display_text = message.get("content") or ""
769
+ tool_calls: list[dict[str, Any]] = []
770
+ for tc in message.get("tool_calls") or []:
771
+ with contextlib.suppress(Exception):
772
+ tool_calls.append(
773
+ {
774
+ "type": "tool_use",
775
+ "id": tc["id"],
776
+ "name": tc["function"]["name"],
777
+ "input": json.loads(tc["function"]["arguments"]),
778
+ }
779
+ )
780
+ return display_text.strip(), tool_calls
781
+
782
+
783
+ # -----------------------------------------------------------------------------------------------
784
+ # HTTP Helper
785
+ # -----------------------------------------------------------------------------------------------
786
+ def _http_post(
787
+ url: str, payload: dict[str, Any], headers: dict[str, str]
788
+ ) -> Any:
789
+ """POST a JSON payload to a URL and return the parsed response."""
790
+ data = json.dumps(payload).encode()
791
+ req = urllib.request.Request(url, data=data, headers=headers)
792
+ try:
793
+ with urllib.request.urlopen(req, timeout=120) as resp:
794
+ return json.loads(resp.read())
795
+ except urllib.error.HTTPError as e:
796
+ raise Exception(f"HTTP {e.code}: {e.read().decode()}") from e
797
+
798
+
799
+ # -----------------------------------------------------------------------------------------------
800
+ # Inference
801
+ # -----------------------------------------------------------------------------------------------
802
+ def get_response(
803
+ messages: list[dict[str, Any]],
804
+ system_prompt: str,
805
+ mlx_state: Optional[tuple[Any, Any]],
806
+ ) -> str:
807
+ """Generate a response from the configured backend given the message history."""
808
+ flat = [
809
+ {"role": m["role"], "content": flatten_content(m["content"])}
810
+ for m in messages
811
+ ]
812
+
813
+ # OpenAI — native function calling
814
+ if BACKEND == "openai":
815
+ data = _http_post(
816
+ API_BASE,
817
+ {
818
+ "model": MODEL,
819
+ "messages": [{"role": "system", "content": system_prompt}]
820
+ + messages,
821
+ "max_tokens": MAX_TOKENS,
822
+ "temperature": 0.3,
823
+ "tools": _build_openai_tools(),
824
+ "tool_choice": "auto",
825
+ },
826
+ {
827
+ "Content-Type": "application/json",
828
+ "Authorization": f"Bearer {API_KEY}",
829
+ },
830
+ )
831
+ return json.dumps(data) # return raw for agent loop to parse natively
832
+
833
+ # OpenRouter / Ollama — OpenAI-compatible chat completions (no native tools)
834
+ if BACKEND in {"openrouter", "ollama"}:
835
+ data = _http_post(
836
+ API_BASE,
837
+ {
838
+ "model": MODEL,
839
+ "messages": [{"role": "system", "content": system_prompt}]
840
+ + flat,
841
+ "max_tokens": MAX_TOKENS,
842
+ "temperature": 0.3,
843
+ },
844
+ {
845
+ "Content-Type": "application/json",
846
+ "Authorization": f"Bearer {API_KEY}",
847
+ },
848
+ )
849
+ return str(data["choices"][0]["message"]["content"])
850
+
851
+ # Anthropic — native tool use API
852
+ if BACKEND == "anthropic":
853
+ headers = {
854
+ "Content-Type": "application/json",
855
+ "x-api-key": API_KEY,
856
+ "anthropic-version": "2023-06-01",
857
+ }
858
+ data = _http_post(
859
+ API_BASE,
860
+ {
861
+ "model": MODEL,
862
+ "system": system_prompt,
863
+ "messages": messages,
864
+ "max_tokens": MAX_TOKENS,
865
+ "tools": _build_anthropic_tools(),
866
+ },
867
+ headers,
868
+ )
869
+ return json.dumps(data) # return raw for agent loop to parse natively
870
+
871
+ # Local proxy — Anthropic messages API; tool calls returned as XML <tool_call> tags in text
872
+ if BACKEND == "local":
873
+ headers = {
874
+ "Content-Type": "application/json",
875
+ "x-api-key": API_KEY,
876
+ "anthropic-version": "2023-06-01",
877
+ }
878
+ data = _http_post(
879
+ API_BASE,
880
+ {
881
+ "model": MODEL,
882
+ "system": system_prompt,
883
+ "messages": flat,
884
+ "max_tokens": MAX_TOKENS,
885
+ },
886
+ headers,
887
+ )
888
+ text = "".join(
889
+ b["text"]
890
+ for b in data.get("content", [])
891
+ if b.get("type") == "text"
892
+ )
893
+ return strip_gptoss_tokens(text)
894
+
895
+ # Transformers (HuggingFace)
896
+ if BACKEND == "transformers":
897
+ model, tokenizer = mlx_state # type: ignore[misc]
898
+ inputs = tokenizer.apply_chat_template(
899
+ [{"role": "system", "content": system_prompt}] + flat,
900
+ add_generation_prompt=True,
901
+ return_tensors="pt",
902
+ return_dict=True,
903
+ ).to(model.device)
904
+ with torch.no_grad():
905
+ out_ids = model.generate(
906
+ **inputs,
907
+ max_new_tokens=MAX_TOKENS,
908
+ temperature=0.3,
909
+ do_sample=True,
910
+ )
911
+ raw = tokenizer.decode(
912
+ out_ids[0][inputs["input_ids"].shape[-1] :],
913
+ skip_special_tokens=False,
914
+ )
915
+ end = _tool_call_complete(raw)
916
+ if end != -1:
917
+ raw = raw[:end]
918
+ return truncate_at_turn_leak(strip_gptoss_tokens(raw))
919
+
920
+ # MLX (Apple Silicon)
921
+ model, tokenizer = mlx_state # type: ignore[misc]
922
+ chat: list[dict[str, str]] = [{"role": "system", "content": system_prompt}]
923
+ for m in messages:
924
+ if c := flatten_content(m["content"]):
925
+ chat.append({"role": m["role"], "content": c})
926
+ prompt = tokenizer.apply_chat_template(
927
+ chat, tokenize=False, add_generation_prompt=True
928
+ )
929
+ sampler = make_sampler(
930
+ temp=0.3, top_p=0.95, min_p=0.0, min_tokens_to_keep=1
931
+ )
932
+ out = ""
933
+ for chunk in stream_generate(
934
+ model, tokenizer, prompt=prompt, max_tokens=MAX_TOKENS, sampler=sampler
935
+ ):
936
+ out += chunk.text
937
+ end = _tool_call_complete(out)
938
+ if end != -1:
939
+ out = out[:end]
940
+ break
941
+ if out.startswith(prompt):
942
+ out = out[len(prompt) :].strip()
943
+ return truncate_at_turn_leak(strip_gptoss_tokens(out))
944
+
945
+
946
+ # -----------------------------------------------------------------------------------------------
947
+ # History Management
948
+ # -----------------------------------------------------------------------------------------------
949
+ def history_file_path() -> str:
950
+ """Return the history file path from env override or user-level default."""
951
+ if p := os.environ.get("WRENCODE_HISTORY_FILE"):
952
+ return str(pathlib.Path(p).expanduser())
953
+ return str(pathlib.Path.home() / ".wrencode" / "history.json")
954
+
955
+
956
+ def load_history() -> list[dict[str, Any]]:
957
+ """Load conversation history from the JSON history file."""
958
+ history_file = history_file_path()
959
+ if os.path.exists(history_file):
960
+ with contextlib.suppress(Exception):
961
+ with open(history_file) as f:
962
+ return list(json.load(f))
963
+ return []
964
+
965
+
966
+ def save_history(messages: list[dict[str, Any]]) -> None:
967
+ """Persist conversation history to the JSON history file."""
968
+ with contextlib.suppress(Exception):
969
+ history_file = pathlib.Path(history_file_path())
970
+ history_file.parent.mkdir(parents=True, exist_ok=True)
971
+ with open(history_file, "w") as f:
972
+ json.dump(messages, f)
973
+
974
+
975
+ def _compact_via_api(history_text: str) -> str:
976
+ """Call the active API backend to summarise history_text and return the summary."""
977
+ summarise_prompt = (
978
+ "Summarize this conversation in 3-5 concise bullet points, "
979
+ "preserving any file paths, code decisions, or unresolved tasks:\n\n"
980
+ + history_text
981
+ )
982
+ if BACKEND == "anthropic":
983
+ data = _http_post(
984
+ API_BASE,
985
+ {
986
+ "model": MODEL,
987
+ "system": "You are a helpful assistant.",
988
+ "messages": [{"role": "user", "content": summarise_prompt}],
989
+ "max_tokens": 512,
990
+ },
991
+ {
992
+ "Content-Type": "application/json",
993
+ "x-api-key": API_KEY,
994
+ "anthropic-version": "2023-06-01",
995
+ },
996
+ )
997
+ return "\n".join(
998
+ b["text"]
999
+ for b in data.get("content", [])
1000
+ if b.get("type") == "text"
1001
+ ).strip()
1002
+ if BACKEND in {"openai", "openrouter"}:
1003
+ data = _http_post(
1004
+ API_BASE,
1005
+ {
1006
+ "model": MODEL,
1007
+ "messages": [
1008
+ {
1009
+ "role": "system",
1010
+ "content": "You are a helpful assistant.",
1011
+ },
1012
+ {"role": "user", "content": summarise_prompt},
1013
+ ],
1014
+ "max_tokens": 512,
1015
+ "temperature": 0.3,
1016
+ },
1017
+ {
1018
+ "Content-Type": "application/json",
1019
+ "Authorization": f"Bearer {API_KEY}",
1020
+ },
1021
+ )
1022
+ return (data["choices"][0]["message"].get("content") or "").strip()
1023
+ return ""
1024
+
1025
+
1026
+ def compact_messages(
1027
+ messages: list[dict[str, Any]],
1028
+ model: Any,
1029
+ tokenizer: Any,
1030
+ ) -> list[dict[str, Any]]:
1031
+ """Summarize conversation history to reduce context length."""
1032
+ if not messages:
1033
+ return messages
1034
+ history_text = "".join(
1035
+ f"{m['role']}: {flatten_content(m['content'])}\n" for m in messages
1036
+ )
1037
+
1038
+ if BACKEND in API_BACKENDS:
1039
+ summary = _compact_via_api(history_text)
1040
+ else:
1041
+ # MLX / Transformers path
1042
+ prompt = tokenizer.apply_chat_template(
1043
+ [
1044
+ {"role": "system", "content": "You are a helpful assistant."},
1045
+ {
1046
+ "role": "user",
1047
+ "content": f"Summarize this conversation in 3-5 bullet points:\n\n{history_text}",
1048
+ },
1049
+ ],
1050
+ tokenize=False,
1051
+ add_generation_prompt=True,
1052
+ )
1053
+ sampler = make_sampler(
1054
+ temp=0.3, top_p=0.95, min_p=0.0, min_tokens_to_keep=1
1055
+ )
1056
+ summary = "".join(
1057
+ c.text
1058
+ for c in stream_generate(
1059
+ model, tokenizer, prompt=prompt, max_tokens=512, sampler=sampler
1060
+ )
1061
+ )
1062
+ if summary.startswith(prompt):
1063
+ summary = summary[len(prompt) :].strip()
1064
+
1065
+ return [
1066
+ {"role": "user", "content": f"[Conversation summary]\n{summary}"},
1067
+ {
1068
+ "role": "assistant",
1069
+ "content": "Understood, I have the context from the summary.",
1070
+ },
1071
+ ]
1072
+
1073
+
1074
+ # -----------------------------------------------------------------------------------------------
1075
+ # Workspace & System Prompt
1076
+ # -----------------------------------------------------------------------------------------------
1077
+ def git_context() -> str:
1078
+ """Return a formatted git status string if inside a git repository."""
1079
+ with contextlib.suppress(Exception):
1080
+ r = subprocess.run(
1081
+ ["git", "status", "--short", "--branch"],
1082
+ capture_output=True,
1083
+ text=True,
1084
+ timeout=3,
1085
+ )
1086
+ if r.returncode == 0 and r.stdout.strip():
1087
+ return f"\nGit status:\n{r.stdout.strip()}"
1088
+ return ""
1089
+
1090
+
1091
+ def build_system_prompt() -> str:
1092
+ """Build the system prompt with workspace context and tool definitions."""
1093
+ ws = workspace_root()
1094
+ path_rule = (
1095
+ "Paths are not restricted to the workspace."
1096
+ if paths_unrestricted()
1097
+ else "Relative paths resolve under the workspace. Absolute paths must stay inside it."
1098
+ )
1099
+ return f"""You are a helpful coding assistant with tools to interact with the file system.
1100
+ Workspace root: {ws}
1101
+ Process cwd: {os.getcwd()}
1102
+ {path_rule}{git_context()}
1103
+
1104
+ IMPORTANT: You MUST use tools by formatting them exactly as shown below.
1105
+
1106
+ Available tools:
1107
+ - read(path, offset, limit): Read a file or list a directory
1108
+ - write(path, content): Write to a file
1109
+ - edit(path, old, new): Replace text in a file (old must be unique unless all=true)
1110
+ - glob(pat): Find files matching pattern
1111
+ - grep(pat): Search for text in files
1112
+ - bash(cmd): Run a shell command
1113
+ - task(prompt): Delegate a self-contained subtask to a fresh subagent; returns only its result
1114
+
1115
+ To use a tool, format it EXACTLY like this:
1116
+ <tool_call>{{"tool": "name", "args": {{"key": "value"}}}}</tool_call>
1117
+
1118
+ Examples:
1119
+ <tool_call>{{"tool": "read", "args": {{"path": "file.py", "offset": 0, "limit": 20}}}}</tool_call>
1120
+ <tool_call>{{"tool": "glob", "args": {{"pat": "*.py"}}}}</tool_call>
1121
+
1122
+ When reading a file, always pass offset and limit. When you finish a task, summarize what you changed.
1123
+ CRITICAL: You MUST use tools for file operations. Never say you can't access files!"""
1124
+
1125
+
1126
+ # -----------------------------------------------------------------------------------------------
1127
+ # Agent Loop
1128
+ # -----------------------------------------------------------------------------------------------
1129
+ def run_agent_turn(
1130
+ messages: list[dict[str, Any]],
1131
+ system_prompt: str,
1132
+ mlx_state: Optional[tuple[Any, Any]],
1133
+ max_iters: int = 0,
1134
+ ) -> None:
1135
+ """Generate a response and execute any tool calls, repeating until no tools remain.
1136
+
1137
+ max_iters > 0 caps the tool-calling rounds (used to bound subagents);
1138
+ 0 means unlimited, preserving the interactive default.
1139
+ """
1140
+ iters = 0
1141
+ while True:
1142
+ if max_iters and iters >= max_iters:
1143
+ print(f"{YELLOW}(stopped after {max_iters} iterations){RESET}")
1144
+ break
1145
+ iters += 1
1146
+ print(f"{DIM}Generating...{RESET}", end="\r", flush=True)
1147
+ response_text = get_response(messages, system_prompt, mlx_state)
1148
+ print(" " * 20, end="\r")
1149
+
1150
+ # Anthropic & OpenAI native tool use path
1151
+ if BACKEND in NATIVE_TOOL_BACKENDS:
1152
+ data = json.loads(response_text)
1153
+ if BACKEND == "anthropic":
1154
+ display_text, tool_calls = _parse_anthropic_response(data)
1155
+ else:
1156
+ display_text, tool_calls = _parse_openai_response(data)
1157
+ if display_text:
1158
+ print(f"\n{CYAN}>{RESET} {render_markdown(display_text)}")
1159
+ if BACKEND == "anthropic":
1160
+ messages.append(
1161
+ {"role": "assistant", "content": data.get("content", [])}
1162
+ )
1163
+ else:
1164
+ messages.append(
1165
+ data["choices"][0]["message"]
1166
+ ) # preserve tool_calls exactly
1167
+ if not tool_calls:
1168
+ break
1169
+ tool_results: list[dict[str, Any]] = []
1170
+ for tc in tool_calls:
1171
+ arg_preview = (
1172
+ str(list(tc["input"].values())[0])[:50]
1173
+ if tc["input"]
1174
+ else ""
1175
+ )
1176
+ print(
1177
+ f"\n{GREEN}{tc['name'].capitalize()}{RESET}({DIM}{arg_preview}{RESET})"
1178
+ )
1179
+ result = run_tool(tc["name"], tc["input"])
1180
+ lines = result.split("\n")
1181
+ preview = lines[0][:60] + (
1182
+ f" ... +{len(lines) - 1} lines"
1183
+ if len(lines) > 1
1184
+ else ("..." if len(lines[0]) > 60 else "")
1185
+ )
1186
+ print(f"{DIM}⎿ {preview}{RESET}")
1187
+ if BACKEND == "anthropic":
1188
+ tool_results.append(
1189
+ {
1190
+ "type": "tool_result",
1191
+ "tool_use_id": tc["id"],
1192
+ "content": result,
1193
+ }
1194
+ )
1195
+ else:
1196
+ tool_results.append(
1197
+ {
1198
+ "role": "tool",
1199
+ "tool_call_id": tc["id"],
1200
+ "content": result,
1201
+ }
1202
+ )
1203
+ if BACKEND == "anthropic":
1204
+ messages.append({"role": "user", "content": tool_results})
1205
+ else:
1206
+ messages.extend(tool_results)
1207
+ continue
1208
+
1209
+ # XML tool call path (mlx, transformers, openrouter, local)
1210
+ xml_tool_calls = parse_tool_calls(response_text)
1211
+ display_text = re.sub(
1212
+ r"<tool_call>.*?</tool_call>", "", response_text, flags=re.DOTALL
1213
+ ).strip()
1214
+
1215
+ if display_text:
1216
+ print(f"\n{CYAN}>{RESET} {render_markdown(display_text)}")
1217
+
1218
+ content_blocks: list[dict[str, Any]] = (
1219
+ [{"type": "text", "text": display_text}] if display_text else []
1220
+ )
1221
+ xml_tool_results: list[dict[str, Any]] = []
1222
+ for tc in xml_tool_calls:
1223
+ arg_preview = (
1224
+ str(list(tc["input"].values())[0])[:50] if tc["input"] else ""
1225
+ )
1226
+ print(
1227
+ f"\n{GREEN}{tc['name'].capitalize()}{RESET}({DIM}{arg_preview}{RESET})"
1228
+ )
1229
+ result = run_tool(tc["name"], tc["input"])
1230
+ lines = result.split("\n")
1231
+ preview = lines[0][:60] + (
1232
+ f" ... +{len(lines) - 1} lines"
1233
+ if len(lines) > 1
1234
+ else ("..." if len(lines[0]) > 60 else "")
1235
+ )
1236
+ print(f"{DIM}⎿ {preview}{RESET}")
1237
+ xml_tool_results.append(
1238
+ {
1239
+ "type": "tool_result",
1240
+ "tool_use_id": tc["id"],
1241
+ "content": result,
1242
+ }
1243
+ )
1244
+ content_blocks.append(tc)
1245
+
1246
+ messages.append({"role": "assistant", "content": content_blocks})
1247
+ if not xml_tool_results:
1248
+ break
1249
+ messages.append({"role": "user", "content": xml_tool_results})
1250
+
1251
+
1252
+ # -----------------------------------------------------------------------------------------------
1253
+ # Slash Commands
1254
+ # -----------------------------------------------------------------------------------------------
1255
+ def handle_slash_command(
1256
+ cmd: str,
1257
+ messages: list[dict[str, Any]],
1258
+ mlx_state: Optional[tuple[Any, Any]],
1259
+ ) -> Optional[str]:
1260
+ """Handle a slash command. Returns 'quit', 'handled', or None if not a command."""
1261
+ if cmd in {"/q", "exit"}:
1262
+ save_history(messages)
1263
+ return "quit"
1264
+ if cmd == "/c":
1265
+ messages.clear()
1266
+ save_history(messages)
1267
+ print(f"{GREEN}Cleared{RESET}")
1268
+ return "handled"
1269
+ if cmd == "/compact":
1270
+ if BACKEND in API_BACKENDS or (BACKEND in LOCAL_ML_BACKENDS and mlx_state):
1271
+ print(f"{DIM}Compacting history...{RESET}")
1272
+ model, tokenizer = mlx_state or (None, None)
1273
+ before = len(messages)
1274
+ messages[:] = compact_messages(messages, model, tokenizer)
1275
+ save_history(messages)
1276
+ print(
1277
+ f"{GREEN}Compacted {before} → {len(messages)} messages{RESET}"
1278
+ )
1279
+ else:
1280
+ print(
1281
+ f"{YELLOW}/compact not available for backend '{BACKEND}'{RESET}"
1282
+ )
1283
+ return "handled"
1284
+ if cmd == "/help":
1285
+ print(
1286
+ f"{DIM}/c — clear /compact — summarize history /q — quit{RESET}\n"
1287
+ f"{DIM}Backends: mlx | transformers | openrouter | openai | anthropic | local{RESET}"
1288
+ )
1289
+ return "handled"
1290
+ return None
1291
+
1292
+
1293
+ # -----------------------------------------------------------------------------------------------
1294
+ # Backend Selection & Config Persistence
1295
+ # -----------------------------------------------------------------------------------------------
1296
+ def is_frozen() -> bool:
1297
+ """Set true when running as a PyInstaller standalone binary."""
1298
+ return bool(getattr(sys, "frozen", False))
1299
+
1300
+
1301
+ def load_config() -> dict[str, str]:
1302
+ """Load saved backend config from CONFIG_FILE, or {} if absent/unreadable."""
1303
+ try:
1304
+ with open(CONFIG_FILE) as f:
1305
+ data = json.load(f)
1306
+ return data if isinstance(data, dict) else {}
1307
+ except (OSError, json.JSONDecodeError):
1308
+ return {}
1309
+
1310
+
1311
+ def save_config(cfg: dict[str, str]) -> None:
1312
+ """Persist backend config to CONFIG_FILE with owner-only (0600) permissions."""
1313
+ try:
1314
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
1315
+ tmp = CONFIG_FILE.with_suffix(".json.tmp")
1316
+ with open(tmp, "w") as f:
1317
+ json.dump(cfg, f, indent=2)
1318
+ os.chmod(tmp, 0o600)
1319
+ tmp.replace(CONFIG_FILE)
1320
+ except OSError as err:
1321
+ print(f"{YELLOW}Could not save config to {CONFIG_FILE}: {err}{RESET}")
1322
+
1323
+
1324
+ def available_backends() -> list[str]:
1325
+ """Backends offerable in the current runtime.
1326
+
1327
+ The standalone binary can't bundle the ML stack, so local-ml backends
1328
+ (mlx/transformers) are only offered from a source install. MLX is further
1329
+ limited to Apple Silicon.
1330
+ """
1331
+ out: list[str] = []
1332
+ for name, spec in BACKEND_SPECS.items():
1333
+ if spec["kind"] == "local-ml":
1334
+ if is_frozen():
1335
+ continue
1336
+ if name == "mlx" and not (
1337
+ platform.system() == "Darwin" and platform.machine() == "arm64"
1338
+ ):
1339
+ continue
1340
+ out.append(name)
1341
+ return out
1342
+
1343
+
1344
+ def choose_backend_interactive() -> None:
1345
+ """Prompt the user to pick a backend, persist the choice, and apply it."""
1346
+ names = available_backends()
1347
+ print(f"{BOLD}Choose a backend:{RESET}\n")
1348
+ for i, name in enumerate(names, 1):
1349
+ spec = BACKEND_SPECS[name]
1350
+ print(
1351
+ f" {BOLD}{i}{RESET}. {spec['label']} {DIM}[{spec['model']}]{RESET}"
1352
+ )
1353
+ print()
1354
+ if not is_frozen():
1355
+ print(
1356
+ f"{DIM}Local model backends need their deps installed "
1357
+ f"(e.g. pip install mlx-lm, or transformers torch).{RESET}\n"
1358
+ )
1359
+
1360
+ while True:
1361
+ raw = input(f"{BLUE}❯{RESET} number [1]: ").strip() or "1"
1362
+ if raw.isdigit() and 1 <= int(raw) <= len(names):
1363
+ choice = names[int(raw) - 1]
1364
+ break
1365
+ print(f"{RED}Enter a number between 1 and {len(names)}.{RESET}")
1366
+
1367
+ spec = BACKEND_SPECS[choice]
1368
+ cfg: dict[str, str] = {"backend": choice}
1369
+
1370
+ model = input(f"{BLUE}❯{RESET} model [{spec['model']}]: ").strip()
1371
+ if model:
1372
+ cfg["model"] = model
1373
+
1374
+ if spec["kind"] == "api":
1375
+ key_env = spec["key_env"]
1376
+ if os.environ.get(key_env):
1377
+ print(f"{GREEN}✓ Using {key_env} from environment{RESET}")
1378
+ else:
1379
+ key = getpass.getpass(
1380
+ f"{BLUE}❯{RESET} {key_env} (input hidden): "
1381
+ ).strip()
1382
+ if key:
1383
+ cfg["api_key"] = key
1384
+ else:
1385
+ print(
1386
+ f"{YELLOW}No key entered — set {key_env} before running, "
1387
+ f"or re-run `wrencode --configure`.{RESET}"
1388
+ )
1389
+
1390
+ save_config(cfg)
1391
+ print(f"{GREEN}✓ Saved backend choice to {CONFIG_FILE}{RESET}\n")
1392
+ apply_backend(choice, cfg.get("model", ""), cfg.get("api_key", ""))
1393
+
1394
+
1395
+ def resolve_configuration(force_chooser: bool = False) -> None:
1396
+ """Decide which backend to use: env override > saved config > interactive > error."""
1397
+ if force_chooser:
1398
+ if not sys.stdin.isatty():
1399
+ print(f"{RED}--configure needs an interactive terminal.{RESET}")
1400
+ raise SystemExit(1)
1401
+ choose_backend_interactive()
1402
+ return
1403
+
1404
+ # 1. Explicit BACKEND env var — power users / CI. Unchanged from prior behaviour.
1405
+ env_backend = os.environ.get("BACKEND")
1406
+ if env_backend:
1407
+ if env_backend not in BACKEND_SPECS:
1408
+ valid = ", ".join(BACKEND_SPECS)
1409
+ print(
1410
+ f"{RED}Unknown BACKEND '{env_backend}'.{RESET} Valid: {valid}"
1411
+ )
1412
+ raise SystemExit(1)
1413
+ apply_backend(env_backend)
1414
+ return
1415
+
1416
+ # 2. A choice saved from a previous run.
1417
+ cfg = load_config()
1418
+ if cfg.get("backend") in BACKEND_SPECS:
1419
+ apply_backend(
1420
+ cfg["backend"], cfg.get("model", ""), cfg.get("api_key", "")
1421
+ )
1422
+ return
1423
+
1424
+ # 3. First run with a real terminal — ask the user.
1425
+ if sys.stdin.isatty():
1426
+ choose_backend_interactive()
1427
+ return
1428
+
1429
+ # 4. Non-interactive with nothing configured — fail with guidance.
1430
+ print(f"{RED}No backend configured.{RESET}")
1431
+ print(
1432
+ "Set BACKEND=<name> (plus the matching API key), "
1433
+ "or run `wrencode --configure` in a terminal."
1434
+ )
1435
+ raise SystemExit(1)
1436
+
1437
+
1438
+ # -----------------------------------------------------------------------------------------------
1439
+ # Model Loading
1440
+ # -----------------------------------------------------------------------------------------------
1441
+ def load_model() -> Optional[tuple[Any, Any]]:
1442
+ """Load model for the current backend and return mlx_state (or None for API backends)."""
1443
+ if BACKEND == "mlx":
1444
+ try:
1445
+ global load, stream_generate, make_sampler
1446
+ from mlx_lm import load # type: ignore[import-not-found]
1447
+ from mlx_lm.generate import stream_generate # type: ignore[import-not-found]
1448
+ from mlx_lm.sample_utils import make_sampler # type: ignore[import-not-found]
1449
+ except ImportError:
1450
+ print(f"{RED}MLX backend needs mlx-lm:{RESET} pip install mlx-lm")
1451
+ print(
1452
+ f"{DIM}Or run `wrencode --configure` to pick a hosted backend.{RESET}"
1453
+ )
1454
+ raise SystemExit(1)
1455
+ print(f"{YELLOW}Loading model...{RESET}")
1456
+ model, tokenizer = load(MODEL)
1457
+ print(f"{GREEN}✓ Loaded: {getattr(model, 'name', MODEL)}{RESET}\n")
1458
+ return (model, tokenizer)
1459
+ if BACKEND == "transformers":
1460
+ try:
1461
+ global torch, AutoModelForCausalLM, AutoTokenizer
1462
+ import torch # type: ignore[import-not-found]
1463
+ from transformers import ( # type: ignore[import-not-found]
1464
+ AutoModelForCausalLM,
1465
+ AutoTokenizer,
1466
+ )
1467
+ except ImportError:
1468
+ print(
1469
+ f"{RED}transformers backend needs:{RESET} "
1470
+ "pip install transformers torch"
1471
+ )
1472
+ print(
1473
+ f"{DIM}Or run `wrencode --configure` to pick a hosted backend.{RESET}"
1474
+ )
1475
+ raise SystemExit(1)
1476
+ print(f"{YELLOW}Loading model via transformers...{RESET}")
1477
+ _device = "mps" if torch.backends.mps.is_available() else "cpu"
1478
+ _tok = AutoTokenizer.from_pretrained(MODEL)
1479
+ # Load then move to the device. device_map= is for multi-device sharding
1480
+ # (needs accelerate, rejects a plain "mps"/"cpu" string in current transformers).
1481
+ _mdl = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.bfloat16).to(
1482
+ _device
1483
+ )
1484
+ print(f"{GREEN}✓ Loaded on {_device}: {MODEL}{RESET}\n")
1485
+ return (_mdl, _tok)
1486
+ if BACKEND == "local":
1487
+ print(f"{DIM}Local proxy at {API_BASE}{RESET}\n")
1488
+ return None
1489
+ # Hosted API backends — all require a key.
1490
+ if not API_KEY:
1491
+ key_env = BACKEND_SPECS[BACKEND]["key_env"]
1492
+ print(f"{RED}{key_env} not set.{RESET}")
1493
+ print(
1494
+ f"{DIM}Set {key_env}, or run `wrencode --configure` to re-enter it.{RESET}"
1495
+ )
1496
+ raise SystemExit(1)
1497
+ print(f"{DIM}{BACKEND} ({MODEL}){RESET}\n")
1498
+ return None
1499
+
1500
+
1501
+ # -----------------------------------------------------------------------------------------------
1502
+ # Entry Point
1503
+ # -----------------------------------------------------------------------------------------------
1504
+ def print_help() -> None:
1505
+ """Print CLI usage."""
1506
+ print("wrencode — a minimal agentic coding assistant\n")
1507
+ print("Usage: wrencode [options]\n")
1508
+ print("Options:")
1509
+ print(" --configure (re)choose and save the inference backend")
1510
+ print(" --yes auto-approve all writes/commands (WRENCODE_AUTO_APPROVE)")
1511
+ print(" --version, -V print version and exit")
1512
+ print(" --help, -h show this help\n")
1513
+ print(
1514
+ "Environment overrides: BACKEND, MODEL, and the backend's API key "
1515
+ "(e.g. ANTHROPIC_API_KEY) take precedence over saved config."
1516
+ )
1517
+ print(
1518
+ "Warning: --yes runs writes and shell commands without confirmation; "
1519
+ "use it only in a sandboxed workspace."
1520
+ )
1521
+
1522
+
1523
+ def main() -> None:
1524
+ """Entry point — initialize the agent and run the interactive loop."""
1525
+ global _MLX_STATE
1526
+ args = sys.argv[1:]
1527
+ if "--help" in args or "-h" in args:
1528
+ print_help()
1529
+ return
1530
+ if "--version" in args or "-V" in args:
1531
+ print(f"wrencode {WRENCODE_VERSION}")
1532
+ return
1533
+ force = "--configure" in args or (bool(args) and args[0] == "configure")
1534
+ if "--yes" in args or "--auto-approve" in args:
1535
+ os.environ["WRENCODE_AUTO_APPROVE"] = "1"
1536
+
1537
+ os.environ.setdefault(
1538
+ "WRENCODE_WORKSPACE", str(pathlib.Path.cwd().resolve())
1539
+ )
1540
+ resolve_configuration(force_chooser=force)
1541
+
1542
+ print(WREN_BANNER)
1543
+ print(f"{BOLD}wrencode{RESET} 🐦 | {DIM}{BACKEND}:{MODEL}{RESET}\n")
1544
+ mlx_state = load_model()
1545
+ _MLX_STATE = mlx_state # expose to the task() subagent tool
1546
+ system_prompt = build_system_prompt()
1547
+ messages = load_history()
1548
+ if messages:
1549
+ print(f"{DIM}Restored {len(messages)} messages{RESET}\n")
1550
+
1551
+ while True:
1552
+ try:
1553
+ user_input = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
1554
+ if not user_input:
1555
+ continue
1556
+ action = handle_slash_command(user_input, messages, mlx_state)
1557
+ if action == "quit":
1558
+ break
1559
+ if action == "handled":
1560
+ continue
1561
+ messages.append({"role": "user", "content": user_input})
1562
+ run_agent_turn(messages, system_prompt, mlx_state)
1563
+ save_history(messages)
1564
+ except KeyboardInterrupt:
1565
+ save_history(messages)
1566
+ print(f"\n{YELLOW}Interrupted{RESET}")
1567
+ break
1568
+ except EOFError:
1569
+ break
1570
+ except Exception as err:
1571
+ print(f"{RED}Error: {err}{RESET}")
1572
+ traceback.print_exc()
1573
+
1574
+
1575
+ if __name__ == "__main__":
1576
+ main()