axio-repl 0.2.3__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.
axio_repl/__init__.py ADDED
@@ -0,0 +1,800 @@
1
+ """Interactive REPL coding assistant powered by axio agent framework.
2
+
3
+ Auto-detects transport from available API keys (OPENAI_API_KEY, NEBIUS_API_KEY,
4
+ OPENROUTER_API_KEY), or use --transport to pick explicitly.
5
+
6
+ Run:
7
+ axio-repl
8
+ axio-repl "your prompt here"
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import atexit
15
+ import os
16
+ import re
17
+ import signal
18
+ import sys
19
+ from collections.abc import Callable, Iterator
20
+ from importlib.metadata import entry_points
21
+ from pathlib import Path
22
+ from typing import Any, NamedTuple
23
+
24
+ import aiohttp
25
+ from axio.agent import Agent
26
+ from axio.context import MemoryContextStore
27
+ from axio.events import (
28
+ AudioOutput,
29
+ Error,
30
+ ImageOutput,
31
+ IterationEnd,
32
+ ReasoningDelta,
33
+ SessionEndEvent,
34
+ TextDelta,
35
+ ToolFieldDelta,
36
+ ToolFieldEnd,
37
+ ToolFieldStart,
38
+ ToolInputDelta,
39
+ ToolOutputDelta,
40
+ ToolResult,
41
+ ToolUseStart,
42
+ VideoOutput,
43
+ )
44
+ from axio.field import StrictStr
45
+ from axio.models import Capability, ModelSpec
46
+ from axio.tool import Tool
47
+ from axio.tool_args import ToolArgStream
48
+ from axio_tools_local.list_files import list_files
49
+ from axio_tools_local.patch_file import patch_file
50
+ from axio_tools_local.read_file import read_file
51
+ from axio_tools_local.shell import shell
52
+ from axio_tools_local.write_file import write_file
53
+
54
+ _readline: Any
55
+ try:
56
+ import readline as _readline
57
+ except ImportError:
58
+ _readline = None
59
+
60
+ readline: Any = _readline
61
+
62
+ AGENT_NAME = "axio-repl"
63
+ AGENT_VERSION = "0.2.3"
64
+
65
+ # ── ANSI helpers ─────────────────────────────────────────────────────
66
+
67
+ DIM = "\033[2m"
68
+ BOLD = "\033[1m"
69
+ CYAN = "\033[36m"
70
+ GREEN = "\033[32m"
71
+ YELLOW = "\033[33m"
72
+ RED = "\033[31m"
73
+ RESET = "\033[0m"
74
+
75
+
76
+ # ── Custom search tool ───────────────────────────────────────────────
77
+
78
+
79
+ async def search_files(
80
+ query: StrictStr,
81
+ path: StrictStr = ".",
82
+ regex: bool = False,
83
+ max_results: int = 100,
84
+ ) -> str:
85
+ """Search for text or regex patterns in files under a directory.
86
+ Returns matching lines with file paths and line numbers."""
87
+
88
+ def _search() -> str:
89
+ base = Path(path).resolve()
90
+ if not base.exists():
91
+ return f"error: path not found: {path}"
92
+
93
+ try:
94
+ pattern = re.compile(query) if regex else None
95
+ except re.error as exc:
96
+ return f"error: invalid regex: {exc}"
97
+
98
+ skip = {".git", ".venv", "__pycache__", "node_modules"}
99
+ matches: list[str] = []
100
+
101
+ files = [base] if base.is_file() else list(_iter_files(base, skip))
102
+ for file_path in files:
103
+ if len(matches) >= max_results:
104
+ break
105
+ try:
106
+ text = file_path.read_text(encoding="utf-8", errors="replace")
107
+ except OSError:
108
+ continue
109
+ for idx, line in enumerate(text.splitlines(), start=1):
110
+ found = pattern.search(line) if pattern else (query in line)
111
+ if found:
112
+ matches.append(f"{file_path}:{idx}: {line}")
113
+ if len(matches) >= max_results:
114
+ break
115
+
116
+ if not matches:
117
+ return f"No matches for {query!r}"
118
+ return "\n".join(matches)
119
+
120
+ return await asyncio.to_thread(_search)
121
+
122
+
123
+ def _iter_files(base: Path, skip: set[str]) -> Iterator[Path]:
124
+ for current_dir, dirs, files in os.walk(base):
125
+ dirs[:] = [d for d in dirs if d not in skip and not d.startswith(".")]
126
+ for name in sorted(files):
127
+ if not name.startswith("."):
128
+ yield Path(current_dir) / name
129
+
130
+
131
+ # ── Tools ────────────────────────────────────────────────────────────
132
+
133
+ TOOLS: list[Tool[Any]] = [
134
+ Tool(name="read_file", handler=read_file),
135
+ Tool(name="write_file", handler=write_file),
136
+ Tool(name="patch_file", handler=patch_file),
137
+ Tool(name="list_files", handler=list_files),
138
+ Tool(name="search_files", handler=search_files),
139
+ Tool(name="shell", handler=shell),
140
+ ]
141
+
142
+
143
+ # ── Transport auto-detection ─────────────────────────────────────────
144
+
145
+
146
+ def _discover_transports() -> dict[str, Callable[..., Any]]:
147
+ result: dict[str, Callable[..., Any]] = {}
148
+ for ep in entry_points(group="axio.transport"):
149
+ try:
150
+ result[ep.name] = ep.load()
151
+ except Exception:
152
+ pass
153
+ return result
154
+
155
+
156
+ _TRANSPORT_ENV_VARS: dict[str, list[str]] = {
157
+ "google": ["GEMINI_API_KEY"],
158
+ "google-vertex": ["GOOGLE_GENAI_USE_VERTEXAI"],
159
+ "openai": ["OPENAI_API_KEY"],
160
+ "anthropic": ["ANTHROPIC_API_KEY"],
161
+ "nebius": ["NEBIUS_API_KEY"],
162
+ "openrouter": ["OPENROUTER_API_KEY"],
163
+ }
164
+
165
+
166
+ def _transport_has_credentials(name: str) -> bool:
167
+ env_vars = _TRANSPORT_ENV_VARS.get(name, [])
168
+ return any(os.environ.get(v, "") for v in env_vars)
169
+
170
+
171
+ def _select_transport(name: str | None) -> tuple[Callable[..., Any], str]:
172
+ available = _discover_transports()
173
+ if name:
174
+ if name not in available:
175
+ print(
176
+ f"Unknown transport {name!r}. Available: {', '.join(sorted(available))}",
177
+ file=sys.stderr,
178
+ )
179
+ sys.exit(1)
180
+ return available[name], ""
181
+
182
+ for transport_name, cls in available.items():
183
+ if _transport_has_credentials(transport_name):
184
+ return cls, ""
185
+
186
+ print("No API key found. Set one of:", file=sys.stderr)
187
+ for transport_name in available:
188
+ env_vars = _TRANSPORT_ENV_VARS.get(transport_name, [])
189
+ if env_vars:
190
+ print(f" {', '.join(env_vars)} ({transport_name})", file=sys.stderr)
191
+ sys.exit(1)
192
+
193
+
194
+ # ── AGENTS.md & system prompt ────────────────────────────────────────
195
+
196
+
197
+ def load_agents_instructions(root: Path) -> str:
198
+ agents_file = root / "AGENTS.md"
199
+ if not agents_file.exists():
200
+ return ""
201
+ try:
202
+ return agents_file.read_text(encoding="utf-8", errors="replace").strip()
203
+ except OSError:
204
+ return ""
205
+
206
+
207
+ def build_system_prompt(
208
+ root: Path,
209
+ model: ModelSpec,
210
+ tools: list[Tool[Any]],
211
+ agents_text: str = "",
212
+ ) -> str:
213
+ caps = model.capabilities
214
+ ctx_k = model.context_window // 1000
215
+ out_k = model.max_output_tokens // 1000
216
+ tool_names = ", ".join(t.name for t in tools)
217
+
218
+ has_tools = Capability.tool_use in caps
219
+
220
+ lines = [
221
+ f"You are {AGENT_NAME} (v{AGENT_VERSION}) — a terminal coding assistant.",
222
+ f"Model: {model.id} ({ctx_k}K context, {out_k}K max output)",
223
+ f"Current directory: {root} (perform actions here unless specified otherwise)",
224
+ ]
225
+ if has_tools:
226
+ lines.append(f"Tools: {tool_names}")
227
+ lines.append("")
228
+
229
+ # Capability-aware guidance
230
+ cap_notes: list[str] = []
231
+ if Capability.vision in caps:
232
+ cap_notes.append("You can see images via read_file (screenshots, diagrams, photos).")
233
+ if Capability.audio in caps:
234
+ cap_notes.append("You can listen to audio files via read_file (speech, music, podcasts).")
235
+ if Capability.video in caps:
236
+ cap_notes.append("You can see video files via read_file.")
237
+ if Capability.image_generation in caps:
238
+ cap_notes.append("You can generate images inline — describe what to draw in your response.")
239
+ if Capability.reasoning in caps:
240
+ cap_notes.append("Extended thinking is available for complex reasoning.")
241
+ if cap_notes:
242
+ lines += cap_notes + [""]
243
+
244
+ lines.append("Rules:")
245
+ if has_tools:
246
+ lines += [
247
+ "- Start every task by listing the current directory to understand the project.",
248
+ "- Read files before editing. Use line_numbers=True before patch_file.",
249
+ "- Keep edits minimal and targeted — don't reformat surrounding code.",
250
+ "- Ground answers on project context gathered through tools.",
251
+ ]
252
+ lines += [
253
+ "- Write idiomatic code — follow the conventions and best practices of the "
254
+ "languages and frameworks used in the project.",
255
+ "- When the user asks about a file they provided or you read, base your answer "
256
+ "strictly on the actual file contents. Do not guess, assume, or fill in details "
257
+ "from general knowledge — only state what the file actually contains.",
258
+ f"- Your max output is {out_k}K tokens. Use as many as the task requires — "
259
+ "do not stop early. If the user asks for a full transcript, detailed analysis, "
260
+ f"or comprehensive review, produce the complete output up to the {out_k}K limit.",
261
+ "- Never refuse safe requests or claim inability.",
262
+ ]
263
+ if has_tools:
264
+ lines += [
265
+ "- If a tool call fails, analyze the error and try a different approach. "
266
+ "If stuck after 3 attempts at the same sub-problem, "
267
+ "explain what you tried and ask for guidance.",
268
+ "- Do not return a final answer until all necessary work is done or you are stuck.",
269
+ "- For compound requests, build a checklist of all items and verify each is addressed before finishing.",
270
+ "- Don't narrate your tool calls — the user sees their full output.",
271
+ "- After completing work, summarize what changed briefly.",
272
+ "- Not tested — not done. Always run tests or builds to verify your changes. "
273
+ "Re-read edited files, observe actual results — don't assume success "
274
+ "from exit codes alone.",
275
+ "- After any test or build that produces images or video, you MUST read_file "
276
+ "every output file to actually see the results. Never describe visual output "
277
+ "you haven't viewed. 'Tests passed' is not the same as 'I looked at the "
278
+ "screenshots and they look correct'.",
279
+ "- To verify UI, use browser automation (Playwright, Puppeteer) to capture "
280
+ "real screenshots at multiple viewport sizes (desktop 1280×800, tablet 768×1024, "
281
+ "mobile 375×667), then read_file every screenshot.",
282
+ "- When you read a screenshot, you MUST critically analyze it. List every "
283
+ "visual defect you notice: broken layout, text overflow, misaligned elements, "
284
+ "poor contrast, missing images, clipped content, wrong spacing, responsive "
285
+ "issues. Do NOT say 'looks good' unless you can specifically confirm each "
286
+ "aspect is correct.",
287
+ "- UI review is iterative: screenshot → list issues → fix code → re-screenshot "
288
+ "→ verify fixes. Repeat until zero defects. Never declare UI done after a "
289
+ "single screenshot pass.",
290
+ "- Never use generate_image as a substitute for real UI testing.",
291
+ "- Never run destructive shell commands (rm -rf, git reset --hard) without user confirmation.",
292
+ "- For large files, read specific line ranges instead of the entire file.",
293
+ ]
294
+ lines.append("")
295
+
296
+ if agents_text:
297
+ lines += ["AGENTS.md instructions:", agents_text, ""]
298
+
299
+ return "\n".join(lines)
300
+
301
+
302
+ # ── Readline history ─────────────────────────────────────────────────
303
+
304
+
305
+ def setup_history() -> None:
306
+ if readline is None:
307
+ return
308
+ history_path = Path.home() / ".axio_repl_history"
309
+ if history_path.exists():
310
+ try:
311
+ readline.read_history_file(str(history_path))
312
+ except (OSError, RuntimeError):
313
+ pass
314
+ try:
315
+ readline.set_history_length(5000)
316
+ except (AttributeError, ValueError):
317
+ pass
318
+
319
+ def _save() -> None:
320
+ try:
321
+ readline.write_history_file(str(history_path))
322
+ except (OSError, RuntimeError):
323
+ pass
324
+
325
+ atexit.register(_save)
326
+
327
+
328
+ # ── Event rendering ──────────────────────────────────────────────────
329
+
330
+
331
+ async def run_prompt(agent: Agent, ctx: MemoryContextStore, prompt: str) -> None:
332
+ in_text = False
333
+ arg_streams: dict[str, ToolArgStream] = {}
334
+ streamed_tool_ids: set[str] = set()
335
+
336
+ async for event in agent.run_stream(prompt, ctx):
337
+ match event:
338
+ case ReasoningDelta(delta=delta):
339
+ if in_text:
340
+ print()
341
+ in_text = False
342
+ sys.stdout.write(f"{DIM}> {delta}{RESET}")
343
+ sys.stdout.flush()
344
+
345
+ case TextDelta(delta=delta):
346
+ if not in_text:
347
+ in_text = True
348
+ if "[Output truncated:" in delta:
349
+ sys.stdout.write(f"\n{RED}{delta.strip()}{RESET}\n")
350
+ in_text = False
351
+ else:
352
+ sys.stdout.write(delta)
353
+ sys.stdout.flush()
354
+
355
+ case ImageOutput(data=data, media_type=mt):
356
+ if in_text:
357
+ print()
358
+ in_text = False
359
+ path = _save_media(data, mt)
360
+ print(f"{GREEN}[image saved: {path}]{RESET}")
361
+
362
+ case AudioOutput(data=data, media_type=mt):
363
+ if in_text:
364
+ print()
365
+ in_text = False
366
+ path = _save_media(data, mt)
367
+ print(f"{GREEN}[audio saved: {path}]{RESET}")
368
+
369
+ case VideoOutput(data=data, media_type=mt):
370
+ if in_text:
371
+ print()
372
+ in_text = False
373
+ path = _save_media(data, mt)
374
+ print(f"{GREEN}[video saved: {path}]{RESET}")
375
+
376
+ case ToolUseStart(index=index, tool_use_id=tid, name=name):
377
+ if in_text:
378
+ print()
379
+ in_text = False
380
+ sys.stdout.write(f"\n{BOLD}{CYAN}\u25b6 {name}{RESET}")
381
+ sys.stdout.flush()
382
+ arg_streams[tid] = ToolArgStream(tid, index)
383
+
384
+ case ToolInputDelta(tool_use_id=tid, partial_json=pj):
385
+ stream = arg_streams.get(tid)
386
+ if stream:
387
+ for fe in stream.feed(pj):
388
+ _render_field_event(fe)
389
+ if stream.done:
390
+ sys.stdout.write("\n")
391
+ sys.stdout.flush()
392
+ del arg_streams[tid]
393
+
394
+ case ToolOutputDelta(tool_use_id=tid, key=key, delta=delta):
395
+ if tid not in streamed_tool_ids:
396
+ sys.stdout.write("\n")
397
+ streamed_tool_ids.add(tid)
398
+ color = RED if key == "stderr" else DIM
399
+ sys.stdout.write(f"{color}{delta}{RESET}")
400
+ sys.stdout.flush()
401
+
402
+ case ToolResult(tool_use_id=tid, is_error=is_error, content=content):
403
+ if is_error:
404
+ sys.stdout.write(f"{RESET}\n{RED}{content}{RESET}\n")
405
+ elif tid in streamed_tool_ids:
406
+ sys.stdout.write(f"{RESET}\n")
407
+ else:
408
+ sys.stdout.write(f"{RESET}\n{GREEN}{content}{RESET}\n")
409
+ sys.stdout.flush()
410
+
411
+ case IterationEnd():
412
+ pass
413
+
414
+ case Error(exception=exc):
415
+ print(f"\n{RED}Error: {exc}{RESET}", file=sys.stderr)
416
+
417
+ case SessionEndEvent(total_usage=usage):
418
+ if in_text:
419
+ print()
420
+ print(f"{DIM}[{usage.input_tokens}in/{usage.output_tokens}out tokens]{RESET}")
421
+
422
+
423
+ _media_counter = 0
424
+
425
+
426
+ def _save_media(data: bytes, media_type: str) -> str:
427
+ """Save media bytes to a temp file, return the path."""
428
+ import tempfile
429
+
430
+ global _media_counter
431
+ _media_counter += 1
432
+ ext = media_type.split("/")[-1].split(";")[0]
433
+ fd, path = tempfile.mkstemp(suffix=f".{ext}", prefix=f"axio_{_media_counter:03d}_")
434
+ os.write(fd, data)
435
+ os.close(fd)
436
+ return path
437
+
438
+
439
+ _field_first_delta = True
440
+
441
+
442
+ def _render_field_event(event: ToolFieldStart | ToolFieldDelta | ToolFieldEnd) -> None:
443
+ global _field_first_delta
444
+ match event:
445
+ case ToolFieldStart(key=key):
446
+ sys.stdout.write(f"\n {YELLOW}{key}{RESET}: {DIM}")
447
+ sys.stdout.flush()
448
+ _field_first_delta = True
449
+ case ToolFieldDelta(text=text):
450
+ if _field_first_delta and "\n" in text:
451
+ sys.stdout.write("\n")
452
+ _field_first_delta = False
453
+ sys.stdout.write(text)
454
+ sys.stdout.flush()
455
+ case ToolFieldEnd():
456
+ sys.stdout.write(RESET)
457
+ sys.stdout.flush()
458
+
459
+
460
+ # ── Input handling ───────────────────────────────────────────────────
461
+
462
+
463
+ def _read_input() -> str:
464
+ """Read user input, collecting extra lines from a multiline paste."""
465
+ import select
466
+
467
+ first = input("repl> ")
468
+ lines = [first]
469
+ fd = sys.stdin.fileno()
470
+ while select.select([fd], [], [], 0.05)[0]:
471
+ chunk = os.read(fd, 65536)
472
+ if not chunk:
473
+ break
474
+ extra = chunk.decode(errors="replace").splitlines()
475
+ for line in extra:
476
+ print(f" ... {line}")
477
+ lines.extend(extra)
478
+ return "\n".join(lines).strip()
479
+
480
+
481
+ # ── REPL commands ────────────────────────────────────────────────────
482
+
483
+
484
+ class Command(NamedTuple):
485
+ """A REPL command with separate show (no arg) and apply (with arg) modes."""
486
+
487
+ show: Callable[[], None]
488
+ apply: Callable[[str], None]
489
+
490
+
491
+ # CLI arg attr → slash command name (for unified init).
492
+ _CLI_TO_SLASH: dict[str, str] = {
493
+ "thinking": "/thinking",
494
+ "temperature": "/temperature",
495
+ "max_tokens": "/max-tokens",
496
+ "debug": "/debug",
497
+ }
498
+
499
+
500
+ def _apply_cli_args(args: object, commands: dict[str, Command]) -> None:
501
+ """Apply CLI arguments through the same command handlers as slash commands."""
502
+ for attr, cmd_name in _CLI_TO_SLASH.items():
503
+ val: Any = getattr(args, attr, None)
504
+ if val is None or val is False:
505
+ continue
506
+ arg = "on" if isinstance(val, bool) else val if isinstance(val, str) else str(val)
507
+ commands[cmd_name].apply(arg)
508
+
509
+
510
+ # ── model ──
511
+
512
+
513
+ def _show_model(transport: Any) -> None:
514
+ model = transport.model
515
+ caps = ", ".join(sorted(c.value for c in model.capabilities))
516
+ print(f"Current model: {BOLD}{model.id}{RESET}")
517
+ print(f"Capabilities: {caps}")
518
+ print(f"Available: {', '.join(transport.models.keys())}")
519
+
520
+
521
+ def _apply_model(
522
+ transport: Any,
523
+ agent: Agent,
524
+ tools: list[Tool[Any]],
525
+ root: Path,
526
+ agents_text: str,
527
+ arg: str,
528
+ ) -> None:
529
+ matches = transport.models.search(arg)
530
+ if len(matches) == 1:
531
+ transport.model = next(iter(matches.values()))
532
+ agent.system = build_system_prompt(root, transport.model, tools, agents_text)
533
+ print(f"Switched to {BOLD}{transport.model.id}{RESET}")
534
+ elif len(matches) == 0:
535
+ print(f"No model matching {arg!r}. Available: {', '.join(transport.models.keys())}")
536
+ else:
537
+ print(f"Ambiguous — matches: {', '.join(matches.keys())}")
538
+
539
+
540
+ # ── thinking ──
541
+
542
+
543
+ def _show_thinking(transport: Any) -> None:
544
+ level = getattr(transport, "thinking_level", None)
545
+ budget = getattr(transport, "thinking_budget", None)
546
+ get_opts = getattr(transport, "get_thinking_options", None)
547
+ valid_levels = get_opts() if get_opts else None
548
+ if level:
549
+ print(f"Thinking level: {BOLD}{level}{RESET}")
550
+ elif budget is not None:
551
+ print(f"Thinking budget: {BOLD}{budget}{RESET} tokens")
552
+ else:
553
+ print("Thinking: default")
554
+ if valid_levels is not None:
555
+ print(f"Valid levels: {', '.join(valid_levels)}")
556
+ elif get_opts is not None:
557
+ print("Usage: /thinking <budget_tokens>")
558
+
559
+
560
+ def _apply_thinking(transport: Any, arg: str) -> None:
561
+ get_opts = getattr(transport, "get_thinking_options", None)
562
+ valid_levels = get_opts() if get_opts else None
563
+ if arg.isdigit():
564
+ if valid_levels is not None:
565
+ model_id = getattr(getattr(transport, "model", None), "id", "?")
566
+ print(f"{model_id} uses thinking levels, not token budgets.")
567
+ print(f"Valid levels: {', '.join(valid_levels)}")
568
+ return
569
+ transport.thinking_budget = int(arg)
570
+ transport.thinking_level = None
571
+ print(f"Thinking budget: {BOLD}{arg}{RESET} tokens")
572
+ else:
573
+ name = arg.upper()
574
+ if valid_levels is not None and name not in valid_levels:
575
+ print(f"{name} is not valid. Valid levels: {', '.join(valid_levels)}")
576
+ return
577
+ transport.thinking_level = name
578
+ transport.thinking_budget = None
579
+ print(f"Thinking level: {BOLD}{name}{RESET}")
580
+
581
+
582
+ # ── temperature ──
583
+
584
+
585
+ def _show_temperature(transport: Any) -> None:
586
+ temp = getattr(transport, "temperature", None)
587
+ print(f"Temperature: {BOLD}{temp if temp is not None else 'default'}{RESET}")
588
+
589
+
590
+ def _apply_temperature(transport: Any, arg: str) -> None:
591
+ try:
592
+ val = float(arg)
593
+ except ValueError:
594
+ print(f"Invalid temperature: {arg!r}")
595
+ return
596
+ if hasattr(transport, "temperature"):
597
+ transport.temperature = val
598
+ print(f"Temperature: {BOLD}{val}{RESET}")
599
+ else:
600
+ print("Transport does not support temperature")
601
+
602
+
603
+ # ── iterations ──
604
+
605
+
606
+ def _show_iterations(agent: Agent) -> None:
607
+ print(f"Max iterations: {BOLD}{agent.max_iterations}{RESET}")
608
+
609
+
610
+ def _apply_iterations(agent: Agent, arg: str) -> None:
611
+ try:
612
+ val = int(arg)
613
+ except ValueError:
614
+ print(f"Invalid value: {arg!r}")
615
+ return
616
+ agent.max_iterations = val
617
+ print(f"Max iterations: {BOLD}{val}{RESET}")
618
+
619
+
620
+ # ── max-tokens ──
621
+
622
+
623
+ def _show_max_tokens(transport: Any) -> None:
624
+ cur = getattr(transport, "max_output_tokens", None)
625
+ model_default = getattr(getattr(transport, "model", None), "max_output_tokens", None)
626
+ if cur:
627
+ print(f"Max output tokens: {BOLD}{cur}{RESET} (model default: {model_default})")
628
+ else:
629
+ print(f"Max output tokens: {BOLD}{model_default}{RESET} (model default)")
630
+
631
+
632
+ def _apply_max_tokens(transport: Any, arg: str) -> None:
633
+ model_default = getattr(getattr(transport, "model", None), "max_output_tokens", None)
634
+ if arg == "default":
635
+ transport.max_output_tokens = None
636
+ print(f"Max output tokens: {BOLD}{model_default}{RESET} (model default)")
637
+ return
638
+ try:
639
+ val = int(arg)
640
+ except ValueError:
641
+ print(f"Invalid value: {arg!r}")
642
+ return
643
+ transport.max_output_tokens = val
644
+ print(f"Max output tokens: {BOLD}{val}{RESET}")
645
+
646
+
647
+ # ── debug ──
648
+
649
+
650
+ def _show_debug(transport: Any) -> None:
651
+ cur = getattr(transport, "debug", False)
652
+ print(f"Debug: {BOLD}{'on' if cur else 'off'}{RESET}")
653
+
654
+
655
+ def _apply_debug(transport: Any, arg: str) -> None:
656
+ val = arg.lower()
657
+ if val == "on":
658
+ transport.debug = True
659
+ print(f"Debug: {BOLD}on{RESET} (request/response bodies logged to stderr)")
660
+ elif val == "off":
661
+ transport.debug = False
662
+ print(f"Debug: {BOLD}off{RESET}")
663
+ else:
664
+ print("Usage: /debug on|off")
665
+
666
+
667
+ # ── Main ─────────────────────────────────────────────────────────────
668
+
669
+
670
+ async def main() -> None:
671
+ import argparse
672
+
673
+ parser = argparse.ArgumentParser(description="REPL coding assistant (axio)")
674
+ parser.add_argument("prompt", nargs="?", default=None, help="Single prompt (non-interactive)")
675
+ parser.add_argument("--transport", default=None, help="Transport name (auto-detected if omitted)")
676
+ parser.add_argument("--model", default=None, help="Model name")
677
+ parser.add_argument("--temperature", type=float, default=None)
678
+ parser.add_argument("--thinking", default=None, help="Thinking level or token budget (integer)")
679
+ parser.add_argument("--max-tokens", type=int, default=None, help="Max output tokens")
680
+ parser.add_argument("--max-iterations", type=int, default=50)
681
+ parser.add_argument("--debug", action="store_true", help="Log request/response bodies to stderr")
682
+ args = parser.parse_args()
683
+
684
+ transport_cls, _ = _select_transport(args.transport)
685
+ root = Path.cwd().resolve()
686
+ agents_text = load_agents_instructions(root)
687
+ setup_history()
688
+
689
+ async with aiohttp.ClientSession() as session:
690
+ transport = transport_cls(session=session)
691
+ await transport.fetch_models()
692
+
693
+ if args.model:
694
+ transport.model = transport.models[args.model]
695
+
696
+ # Transport-level commands (available before agent creation).
697
+ commands: dict[str, Command] = {
698
+ "/thinking": Command(lambda: _show_thinking(transport), lambda a: _apply_thinking(transport, a)),
699
+ "/temperature": Command(lambda: _show_temperature(transport), lambda a: _apply_temperature(transport, a)),
700
+ "/max-tokens": Command(lambda: _show_max_tokens(transport), lambda a: _apply_max_tokens(transport, a)),
701
+ "/debug": Command(lambda: _show_debug(transport), lambda a: _apply_debug(transport, a)),
702
+ }
703
+ _apply_cli_args(args, commands)
704
+
705
+ tools = list(TOOLS)
706
+ system = build_system_prompt(root, transport.model, tools, agents_text)
707
+ agent = Agent(
708
+ system=system,
709
+ tools=tools,
710
+ transport=transport,
711
+ max_iterations=args.max_iterations,
712
+ )
713
+ ctx = MemoryContextStore()
714
+
715
+ # Agent-dependent commands.
716
+ commands["/model"] = Command(
717
+ lambda: _show_model(transport),
718
+ lambda a: _apply_model(transport, agent, tools, root, agents_text, a),
719
+ )
720
+ commands["/iterations"] = Command(
721
+ lambda: _show_iterations(agent),
722
+ lambda a: _apply_iterations(agent, a),
723
+ )
724
+
725
+ loop = asyncio.get_event_loop()
726
+ prompt_task: asyncio.Task[None] | None = None
727
+
728
+ def _on_sigint() -> None:
729
+ nonlocal prompt_task
730
+ if prompt_task is not None and not prompt_task.done():
731
+ prompt_task.cancel()
732
+
733
+ loop.add_signal_handler(signal.SIGINT, _on_sigint)
734
+
735
+ try:
736
+ if args.prompt:
737
+ prompt_task = asyncio.create_task(run_prompt(agent, ctx, args.prompt))
738
+ try:
739
+ await prompt_task
740
+ except asyncio.CancelledError:
741
+ print(f"\n{DIM}[interrupted]{RESET}")
742
+ finally:
743
+ prompt_task = None
744
+ return
745
+
746
+ commands_list = ", ".join(["/help", *commands, "/quit"])
747
+ label = getattr(transport, "name", "unknown")
748
+ print(f"REPL ready ({label}). Commands: {commands_list}")
749
+
750
+ while True:
751
+ try:
752
+ user_input = await loop.run_in_executor(None, _read_input)
753
+ except EOFError:
754
+ print()
755
+ break
756
+
757
+ if not user_input:
758
+ continue
759
+ lowered = user_input.lower()
760
+ if lowered in {"/quit", "/exit", "/q"}:
761
+ break
762
+ if lowered == "/help":
763
+ tool_list = ", ".join(t.name for t in tools)
764
+ print(f"Type your request. Tools: {tool_list}")
765
+ print(f"Commands: {commands_list}")
766
+ continue
767
+
768
+ matched = False
769
+ for prefix, cmd in commands.items():
770
+ if lowered == prefix or lowered.startswith(prefix + " "):
771
+ arg = user_input[len(prefix) :].strip() or None
772
+ if arg is None:
773
+ cmd.show()
774
+ else:
775
+ cmd.apply(arg)
776
+ matched = True
777
+ break
778
+ if matched:
779
+ continue
780
+
781
+ prompt_task = asyncio.create_task(run_prompt(agent, ctx, user_input))
782
+ try:
783
+ await prompt_task
784
+ except asyncio.CancelledError:
785
+ print(f"\n{DIM}[interrupted]{RESET}")
786
+ finally:
787
+ prompt_task = None
788
+ finally:
789
+ loop.remove_signal_handler(signal.SIGINT)
790
+
791
+
792
+ def main_sync() -> None:
793
+ try:
794
+ asyncio.run(main())
795
+ except KeyboardInterrupt:
796
+ pass
797
+
798
+
799
+ if __name__ == "__main__":
800
+ main_sync()
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: axio-repl
3
+ Version: 0.2.3
4
+ Summary: Interactive REPL coding assistant for Axio
5
+ Project-URL: Homepage, https://github.com/mosquito/axio-agent
6
+ Project-URL: Repository, https://github.com/mosquito/axio-agent
7
+ License: MIT
8
+ Keywords: agent,ai,coding-assistant,llm,repl
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: aiohttp>=3.11
11
+ Requires-Dist: axio
12
+ Requires-Dist: axio-tools-local
13
+ Requires-Dist: axio-transport-openai
14
+ Description-Content-Type: text/markdown
15
+
16
+ # axio-repl
17
+
18
+ Interactive REPL coding assistant powered by the [axio](../axio) agent framework.
19
+ Works with any LLM backend via pluggable transports — bring your own API key.
20
+
21
+ ## Philosophy
22
+
23
+ axio-repl is an opinionated terminal agent that **actually verifies its work**.
24
+ The system prompt encodes hard-won lessons from watching models cut corners:
25
+
26
+ - **Stream everything, hide nothing.** Every piece of information shown to the
27
+ user — tool arguments, stdout/stderr, images, exit codes — must also be
28
+ faithfully presented to the model. The model should see exactly what the user
29
+ sees, so it can reason about the same reality. No summarizing, no truncating,
30
+ no dropping context between what's displayed and what's sent back.
31
+ - **Not tested — not done.** The agent must run tests, re-read edited files,
32
+ and observe actual results instead of assuming success from exit codes.
33
+ - **Iterative UI review.** When building or modifying UI, the agent captures
34
+ real screenshots at multiple viewport sizes (desktop, tablet, mobile) via
35
+ Playwright/Puppeteer, reads every screenshot through the model's vision, and
36
+ critically lists visual defects. It repeats the screenshot → fix → re-screenshot
37
+ loop until zero defects — no premature "looks good".
38
+ - **Ground everything in project context.** Read before editing. List the
39
+ directory before guessing. Never refuse a safe request.
40
+ - **Minimal edits.** Don't reformat surrounding code, don't narrate tool calls.
41
+ The user sees the full tool output in the terminal.
42
+
43
+ ## Features
44
+
45
+ - **Pluggable transports** — auto-detected from API keys via
46
+ `axio.transport` entry points. Ships with support for OpenAI, Anthropic,
47
+ Google (Gemini API & Vertex AI), Nebius, OpenRouter, and Codex.
48
+ - **Runtime model switching** — `/model <query>` to switch models mid-session
49
+ without restarting. Capabilities (vision, reasoning, image generation) are
50
+ re-evaluated and the system prompt adapts automatically.
51
+ - **Streaming tool arguments** — tool call fields appear incrementally as the
52
+ model generates them, so you see what's happening before execution starts.
53
+ - **Streaming tool output** — shell command stdout/stderr streams line-by-line
54
+ in real time instead of buffering until completion.
55
+ - **Vision** — `read_file` on images (PNG, JPG, GIF, WebP) and videos returns
56
+ multimodal content blocks. The model sees the actual pixels, not a description.
57
+ - **Image & video generation** — when the Google transport is installed,
58
+ `generate_image` and `generate_video` tools are available for Gemini Nano
59
+ Banana / Veo models.
60
+ - **AGENTS.md** — workspace-level instructions loaded into the system prompt from
61
+ an `AGENTS.md` file in the working directory.
62
+ - **Multiline paste** — pasting multi-line text into the prompt is handled
63
+ gracefully with continuation markers (`...`).
64
+ - **Graceful interruption** — Ctrl-C cancels the running agent loop, preserving
65
+ partial tool output in conversation context so the model knows what happened.
66
+ - **Readline history** — persisted across sessions in `~/.axio_repl_history`.
67
+ - **Single-prompt mode** — pass a prompt as argument for scripting and non-interactive use.
68
+
69
+ ## Install
70
+
71
+ ```bash
72
+ uv tool install axio-repl
73
+ ```
74
+
75
+ To add optional transports:
76
+
77
+ ```bash
78
+ uv tool install axio-repl --with axio-transport-anthropic
79
+ uv tool install axio-repl --with axio-transport-google
80
+ ```
81
+
82
+ Or within the monorepo workspace:
83
+
84
+ ```bash
85
+ uv run axio-repl
86
+ ```
87
+
88
+ ## Usage
89
+
90
+ ```bash
91
+ # Interactive REPL (auto-detects transport from API keys)
92
+ axio-repl
93
+
94
+ # Single prompt (non-interactive)
95
+ axio-repl "list the files in this project"
96
+
97
+ # Explicit transport and model
98
+ axio-repl --transport anthropic --model claude-sonnet-4-20250514
99
+
100
+ # Google Gemini
101
+ axio-repl --transport google --model gemini-3.1-pro-preview
102
+
103
+ # Custom temperature and iteration limit
104
+ axio-repl --temperature 0.5 --max-iterations 100
105
+ ```
106
+
107
+ ## REPL Commands
108
+
109
+ | Command | Description |
110
+ |----------------------|------------------------------------------------|
111
+ | `/help` | Show available tools and commands |
112
+ | `/model` | Show current model and list available models |
113
+ | `/model <query>` | Switch to a model matching the query |
114
+ | `/quit` `/exit` `/q` | Exit the REPL |
115
+
116
+ ## Tools
117
+
118
+ | Tool | Description |
119
+ |-----------------|----------------------------------------------------------------|
120
+ | `read_file` | Read file contents; images and videos returned as vision blocks |
121
+ | `write_file` | Create or overwrite files |
122
+ | `patch_file` | Replace line ranges in files (1-indexed, inclusive) |
123
+ | `list_files` | List directory contents |
124
+ | `search_files` | Text/regex search across files |
125
+ | `shell` | Run shell commands with streaming output and process-group cleanup |
126
+ | `generate_image` | Generate images via Gemini Nano Banana (Google transport only) |
127
+ | `generate_video` | Generate videos via Veo (Google transport only) |
128
+
129
+ ## Transports
130
+
131
+ Transports are discovered via the `axio.transport` entry point group.
132
+ The REPL picks the first transport whose required environment variable is set:
133
+
134
+ | Transport | Env Variable | Package |
135
+ |------------------|--------------------------------|----------------------------|
136
+ | `google` | `GEMINI_API_KEY` | `axio-transport-google` |
137
+ | `google-vertex` | `GOOGLE_GENAI_USE_VERTEXAI` | `axio-transport-google` |
138
+ | `anthropic` | `ANTHROPIC_API_KEY` | `axio-transport-anthropic` |
139
+ | `openai` | `OPENAI_API_KEY` | `axio-transport-openai` |
140
+ | `nebius` | `NEBIUS_API_KEY` | `axio-transport-openai` |
141
+ | `openrouter` | `OPENROUTER_API_KEY` | `axio-transport-openai` |
142
+ | `codex` | *(API key varies)* | `axio-transport-codex` |
143
+
144
+ Use `--transport <name>` to force a specific transport regardless of env vars.
145
+
146
+ ## Capability-Aware System Prompt
147
+
148
+ The system prompt adapts based on the selected model's declared capabilities:
149
+
150
+ - **Vision** — unlocks instructions to `read_file` images and do screenshot-based
151
+ UI review.
152
+ - **Reasoning** — notes that extended thinking is available.
153
+ - **Image generation** — enables inline image generation guidance.
154
+ - **Video** — enables `read_file` for video content.
155
+ - **Tool use** — gates all tool-related rules (edit workflow, testing, verification).
156
+
157
+ This means switching from a text-only model to a vision model mid-session
158
+ (via `/model`) automatically updates what the agent is instructed to do.
159
+
160
+ ## Architecture
161
+
162
+ ```
163
+ ┌─────────────┐ ┌───────────┐ ┌──────────────────┐
164
+ │ axio-repl │────▶│ axio │────▶│ transport │
165
+ │ (terminal │ │ (agent │ │ (anthropic / │
166
+ │ UI, I/O) │ │ loop) │ │ google / openai │
167
+ └─────────────┘ └───────────┘ │ / nebius / ...) │
168
+ │ └──────────────────┘
169
+ ┌─────┴─────┐
170
+ │ tools │
171
+ │ (local fs │
172
+ │ + shell) │
173
+ └───────────┘
174
+ ```
175
+
176
+ - **axio-repl** owns the terminal UI: readline, event rendering, REPL commands.
177
+ - **axio** runs the agent loop: dispatch tools, manage conversation context,
178
+ handle cancellation.
179
+ - **transports** handle LLM communication — message conversion, streaming,
180
+ model registries.
181
+ - **axio-tools-local** provides the file and shell tools.
@@ -0,0 +1,5 @@
1
+ axio_repl/__init__.py,sha256=nb8OakqROC4VbkTHy_JqZO2y43yDxn8EwGwcDIcwoA8,28942
2
+ axio_repl-0.2.3.dist-info/METADATA,sha256=6_Pwt4yHyDK-p-TCVU7wKncbu5g-QROkDupI4WZ8BOE,8539
3
+ axio_repl-0.2.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
4
+ axio_repl-0.2.3.dist-info/entry_points.txt,sha256=AXjaibchgZ6abzELW8InBvepvT7oAJE8By0QOTBfUOk,50
5
+ axio_repl-0.2.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ axio-repl = axio_repl:main_sync