pdo-agent 2.0.0__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.
pdo/main.py ADDED
@@ -0,0 +1,961 @@
1
+ """Terminal entry point.
2
+
3
+ Wires the configuration, LLM client, tool registry, memory and agent together,
4
+ then runs an interactive REPL. ``main`` is registered as the ``pdo`` console
5
+ script in ``pyproject.toml``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ import shutil
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from rich.align import Align
20
+ from rich.console import Console
21
+ from rich.markdown import Markdown
22
+ from rich.markup import escape
23
+ from rich.panel import Panel
24
+ from rich.table import Table
25
+ from rich.text import Text
26
+
27
+ from . import __version__
28
+ from .agent.core import Agent
29
+ from .agent.memory import MemoryStore, get_memory_store
30
+ from .banner import render_logo
31
+ from .config import (
32
+ Config,
33
+ ConfigError,
34
+ get_mcp_config_path,
35
+ get_plugins_dir,
36
+ get_skills_dir,
37
+ load_config,
38
+ )
39
+ from .llm import LLMError, OpenAIClient
40
+ from .logging_setup import configure_logging
41
+ from .mcp import active_servers, load_mcp_config, start_servers
42
+ from .providers import PROVIDERS, Provider
43
+ from .skills import Skill, load_skills
44
+ from .theme import accent, accent_ansi, current_theme, set_theme, theme_names
45
+ from .tools.base import truncate
46
+ from .tools.registry import ToolRegistry, get_registry, plugin_tool_names
47
+
48
+ # Pattern for @file references in user input, e.g. "explain @src/pdo/main.py".
49
+ _FILE_REF = re.compile(r"@([^\s]+)")
50
+
51
+ # API keys entered interactively via /models, kept in memory for the session
52
+ # only (never written to disk).
53
+ _SESSION_KEYS: dict[str, str] = {}
54
+
55
+ # prompt_toolkit powers the interactive prompt (slash-command autocomplete). It
56
+ # is optional at import time so the module still loads if it is unavailable; the
57
+ # REPL falls back to a plain prompt in that case.
58
+ try:
59
+ from prompt_toolkit import PromptSession
60
+ from prompt_toolkit.completion import Completer, Completion
61
+ from prompt_toolkit.formatted_text import HTML
62
+ from prompt_toolkit.key_binding import KeyBindings
63
+ from prompt_toolkit.shortcuts import CompleteStyle
64
+ from prompt_toolkit.styles import Style
65
+
66
+ _HAVE_PTK = True
67
+
68
+ class _SlashCompleter(Completer):
69
+ """Suggest slash commands, but only while the line is a ``/`` command.
70
+
71
+ Plain text (anything not starting with ``/``, or once a space is typed)
72
+ yields no completions, so the menu never pops up for normal messages.
73
+ """
74
+
75
+ def __init__(self, commands: dict[str, str]) -> None:
76
+ self._commands = commands
77
+
78
+ def get_completions(self, document, complete_event):
79
+ text = document.text_before_cursor
80
+ if not text.startswith("/") or " " in text:
81
+ return
82
+ for name, desc in self._commands.items():
83
+ if name.startswith(text.lower()):
84
+ yield Completion(name, start_position=-len(text), display_meta=desc)
85
+
86
+ except ImportError: # pragma: no cover - exercised only without the optional dep
87
+ _HAVE_PTK = False
88
+
89
+ # Friendly past-tense verbs for the tool-activity log (Codex/Claude-Code style).
90
+ _TOOL_VERBS: dict[str, str] = {
91
+ "run_shell": "Ran",
92
+ "read_file": "Read",
93
+ "write_file": "Wrote",
94
+ "append_file": "Updated",
95
+ "list_directory": "Explored",
96
+ "create_directory": "Created",
97
+ "memory_save": "Saved",
98
+ "memory_search": "Searched",
99
+ "memory_delete": "Deleted",
100
+ "delegate_task": "Delegated",
101
+ }
102
+
103
+ console = Console()
104
+ logger = logging.getLogger("pdo.main")
105
+
106
+ # Single source of truth for the slash commands: name -> description. Used for
107
+ # both the autocomplete menu and the /help panel so they never drift apart.
108
+ _COMMANDS: dict[str, str] = {
109
+ "/help": "Show this help",
110
+ "/models": "Switch provider and model (OpenAI / Anthropic / OpenRouter / Ollama)",
111
+ "/tools": "List available tools",
112
+ "/index": "Build/refresh the codebase search index for this directory",
113
+ "/mcp": "Show connected MCP servers and their tools",
114
+ "/theme": "Change the color theme (e.g. /theme green)",
115
+ "/export": "Save the conversation to a Markdown file",
116
+ "/sessions": "List saved conversation sessions",
117
+ "/new": "Start a new conversation session (e.g. /new feature-x)",
118
+ "/resume": "Switch to another session (e.g. /resume default)",
119
+ "/memory": "Show saved facts and preferences",
120
+ "/history": "Show recent conversation history",
121
+ "/clear": "Clear the current session's history",
122
+ "/version": "Show the PDO version",
123
+ "/exit": "Quit PDO",
124
+ }
125
+
126
+ def _help_text(commands: dict[str, str]) -> str:
127
+ rows = "\n".join(
128
+ f" [{accent()}]{name}[/{accent()}] {desc}" for name, desc in commands.items()
129
+ )
130
+ return (
131
+ "[bold]Commands[/bold]\n"
132
+ + rows
133
+ + "\n\nType [bold]/[/bold] to see this menu inline. Reference files with "
134
+ "[bold]@path[/bold]. Anything else is sent to the agent."
135
+ )
136
+
137
+
138
+ # Theme for the prompt_toolkit input: subtle footer + a tidy completion menu.
139
+ _PTK_STYLE = {
140
+ "bottom-toolbar": "fg:#9aa0a6 bg:#1b1b1b",
141
+ "completion-menu.completion": "bg:#23252b fg:#c8ccd4",
142
+ "completion-menu.completion.current": "bg:#3a3d44 fg:#ffffff bold",
143
+ "completion-menu.meta.completion": "bg:#23252b fg:#7f868f",
144
+ "completion-menu.meta.completion.current": "bg:#3a3d44 fg:#c8ccd4",
145
+ }
146
+
147
+
148
+ def _short_cwd() -> str:
149
+ """Return the current directory with $HOME collapsed to ``~`` (like a shell)."""
150
+ cwd = os.getcwd()
151
+ home = os.path.expanduser("~")
152
+ if cwd == home:
153
+ return "~"
154
+ if cwd.startswith(home + os.sep):
155
+ return "~" + cwd[len(home):]
156
+ return cwd
157
+
158
+
159
+ def _make_prompt_session(config: Config, agent: Agent, commands: dict[str, str]):
160
+ """Build the boxed input session with slash autocomplete and a status footer.
161
+
162
+ Returns ``None`` when prompt_toolkit is unavailable or output isn't a TTY,
163
+ signalling the REPL to use a plain prompt instead.
164
+ """
165
+ if not _HAVE_PTK or not sys.stdout.isatty():
166
+ return None
167
+
168
+ completer = _SlashCompleter(commands)
169
+
170
+ def bottom_toolbar():
171
+ # Read live each time so /models and token counts update immediately.
172
+ total = agent.token_usage().get("total_tokens", 0)
173
+ tokens = f" · {total:,} tok" if total else ""
174
+ return HTML(
175
+ f" pdo · <b>{config.openai_model}</b> · {_short_cwd()}{tokens}"
176
+ " · ⌥⏎ newline "
177
+ )
178
+
179
+ # Enter submits; Alt/Option+Enter (ESC then Enter) inserts a newline so
180
+ # multi-line prompts can be composed without sending.
181
+ bindings = KeyBindings()
182
+
183
+ @bindings.add("escape", "enter")
184
+ def _insert_newline(event):
185
+ event.current_buffer.insert_text("\n")
186
+
187
+ # complete_while_typing makes the dropdown appear immediately on "/".
188
+ # Single-column menu (one command per line) with the descriptions; reserve
189
+ # extra rows so the list isn't clipped — scroll the rest with the arrow keys.
190
+ return PromptSession(
191
+ completer=completer,
192
+ complete_while_typing=True,
193
+ complete_style=CompleteStyle.COLUMN,
194
+ reserve_space_for_menu=14,
195
+ bottom_toolbar=bottom_toolbar,
196
+ key_bindings=bindings,
197
+ style=Style.from_dict(_PTK_STYLE),
198
+ )
199
+
200
+
201
+ def _read_input(session) -> str:
202
+ """Read one line of input inside a bordered box, or plain as a fallback."""
203
+ if session is None:
204
+ return console.input("[bold blue]you ▸[/bold blue] ").strip()
205
+
206
+ width = shutil.get_terminal_size((80, 24)).columns
207
+ inner = max(width - 2, 8)
208
+ # Top/bottom borders are printed around the live prompt line; the right edge
209
+ # of the box is the right-aligned prompt (rprompt), which stays put as you type.
210
+ console.print(f"[grey42]╭{'─' * inner}╮[/grey42]")
211
+ try:
212
+ text = session.prompt(
213
+ HTML(
214
+ f"<ansibrightblack>│</ansibrightblack> "
215
+ f"<{accent_ansi()}><b>› </b></{accent_ansi()}>"
216
+ ),
217
+ rprompt=HTML("<ansibrightblack>│</ansibrightblack>"),
218
+ # Continuation rows (after Alt+Enter) keep the box's left border.
219
+ prompt_continuation=HTML("<ansibrightblack>│</ansibrightblack> "),
220
+ )
221
+ finally:
222
+ console.print(f"[grey42]╰{'─' * inner}╯[/grey42]")
223
+ return text.strip()
224
+
225
+
226
+ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
227
+ parser = argparse.ArgumentParser(
228
+ prog="pdo",
229
+ description="PDO (Python Do) — a terminal-first AI agent. Think. Plan. Do.",
230
+ )
231
+ parser.add_argument("prompt", nargs="*", help="Run a single prompt and exit (one-shot mode).")
232
+ parser.add_argument("--version", action="store_true", help="Print the version and exit.")
233
+ parser.add_argument(
234
+ "--serve",
235
+ action="store_true",
236
+ help="Run as an MCP server over stdio (exposes a run_task tool).",
237
+ )
238
+ parser.add_argument("--json", action="store_true", help="One-shot: print the reply as JSON.")
239
+ parser.add_argument("--no-markdown", action="store_true", help="Disable Markdown rendering.")
240
+ parser.add_argument("--theme", metavar="NAME", help=f"Color theme: {', '.join(theme_names())}.")
241
+ return parser.parse_args(argv)
242
+
243
+
244
+ def main(argv: list[str] | None = None) -> int:
245
+ """Run PDO. Returns a process exit code. Supports one-shot and interactive modes."""
246
+ args = _parse_args(argv)
247
+ if args.version:
248
+ print(f"PDO {__version__}")
249
+ return 0
250
+
251
+ try:
252
+ config = load_config()
253
+ except ConfigError as exc:
254
+ console.print(Panel(str(exc), title="Configuration error", border_style="red"))
255
+ return 1
256
+
257
+ # CLI flags override environment config.
258
+ if args.no_markdown:
259
+ config.render_markdown = False
260
+ if args.theme:
261
+ config.theme = args.theme
262
+ set_theme(config.theme)
263
+
264
+ configure_logging()
265
+ registry = get_registry()
266
+ store = get_memory_store()
267
+
268
+ try:
269
+ llm = OpenAIClient(
270
+ api_key=config.openai_api_key,
271
+ model=config.openai_model,
272
+ temperature=config.temperature,
273
+ base_url=config.openai_base_url,
274
+ )
275
+ except Exception as exc: # noqa: BLE001 — show a friendly message, don't traceback
276
+ console.print(f"[red]Failed to initialise the LLM client:[/red] {exc}")
277
+ return 1
278
+
279
+ mcp_servers = _init_mcp(registry, quiet=args.serve or (args.prompt and args.json))
280
+ try:
281
+ # Serve mode: stdio JSON-RPC (PDO as an MCP server).
282
+ if args.serve:
283
+ return _run_serve(config, llm, registry)
284
+
285
+ # One-shot mode: run a single prompt and exit (great for scripts/pipes).
286
+ if args.prompt:
287
+ return _run_once(config, llm, registry, store, " ".join(args.prompt), args.json)
288
+
289
+ agent = _build_agent(config, llm, registry, store)
290
+ skills = load_skills(get_skills_dir())
291
+ _show_splash(config)
292
+ return _repl(agent, registry, store, config, skills)
293
+ finally:
294
+ for server in mcp_servers:
295
+ server.stop()
296
+
297
+
298
+ def _init_mcp(registry: ToolRegistry, quiet: bool = False):
299
+ """Start MCP servers from mcp.json and register their tools (best-effort)."""
300
+ try:
301
+ servers_config = load_mcp_config(get_mcp_config_path())
302
+ except Exception: # noqa: BLE001
303
+ logger.exception("Could not load MCP config")
304
+ return []
305
+ if not servers_config:
306
+ return []
307
+
308
+ servers, summary = start_servers(registry, servers_config)
309
+ if not quiet:
310
+ for name, count, error in summary:
311
+ if error:
312
+ console.print(f"[yellow]MCP {name}: {error}[/yellow]")
313
+ else:
314
+ console.print(f"[dim]MCP {name}: connected ({count} tool(s))[/dim]")
315
+ return servers
316
+
317
+
318
+ def _run_serve(config: Config, llm: OpenAIClient, registry: ToolRegistry) -> int:
319
+ """Run PDO as an MCP server over stdio (never writes UI output to stdout)."""
320
+ import tempfile
321
+
322
+ from .agent.memory import MemoryStore as _MemoryStore
323
+ from .serve import PDOServer
324
+ from .tools.base import set_confirm_override
325
+
326
+ # stdout carries JSON-RPC: refuse anything that would prompt interactively.
327
+ set_confirm_override(lambda _prompt: False)
328
+
329
+ memory = _MemoryStore(Path(tempfile.mkdtemp(prefix="pdo-serve-")))
330
+ agent = Agent(config, llm, registry, memory, planning=False)
331
+ logger.info("Serving PDO over stdio (MCP); model=%s", config.openai_model)
332
+ try:
333
+ PDOServer(agent).serve_forever(sys.stdin, sys.stdout)
334
+ except KeyboardInterrupt:
335
+ pass
336
+ finally:
337
+ set_confirm_override(None)
338
+ return 0
339
+
340
+
341
+ def _run_once(
342
+ config: Config,
343
+ llm: OpenAIClient,
344
+ registry: ToolRegistry,
345
+ store: MemoryStore,
346
+ prompt: str,
347
+ as_json: bool,
348
+ ) -> int:
349
+ """Run a single prompt non-interactively and print the result."""
350
+ agent = _build_agent(config, llm, registry, store, quiet=True)
351
+ try:
352
+ expanded, images = _expand_file_refs(prompt)
353
+ answer = agent.run_turn(expanded, images=images)
354
+ except Exception as exc: # noqa: BLE001
355
+ if as_json:
356
+ print(json.dumps({"error": str(exc)}))
357
+ else:
358
+ console.print(f"[red]Error:[/red] {exc}")
359
+ return 1
360
+
361
+ if as_json:
362
+ print(json.dumps({"model": config.openai_model, "response": answer}))
363
+ elif config.render_markdown:
364
+ console.print(Markdown(answer))
365
+ else:
366
+ print(answer)
367
+ return 0
368
+
369
+
370
+ def _build_agent(
371
+ config: Config,
372
+ llm: OpenAIClient,
373
+ registry: ToolRegistry,
374
+ store: MemoryStore,
375
+ *,
376
+ quiet: bool = False,
377
+ ) -> Agent:
378
+ """Create an agent whose streaming output is rendered to the terminal.
379
+
380
+ ``quiet`` suppresses all live output (used by one-shot mode). When Markdown
381
+ rendering is on, tokens aren't streamed — the final reply is rendered as
382
+ Markdown by the caller instead.
383
+ """
384
+ state = {"label_shown": False}
385
+ stream_tokens = not quiet and not config.render_markdown
386
+
387
+ def on_token(token: str) -> None:
388
+ if not stream_tokens:
389
+ return
390
+ if not state["label_shown"]:
391
+ console.print(f"[bold {accent()}]●[/bold {accent()}] ", end="")
392
+ state["label_shown"] = True
393
+ console.print(token, end="", markup=False, highlight=False, soft_wrap=True)
394
+
395
+ def on_tool(name: str, args: dict) -> None:
396
+ if quiet:
397
+ return
398
+ verb = _TOOL_VERBS.get(name, "Used")
399
+ console.print(
400
+ f"\n[{accent()}]●[/{accent()}] [bold]{verb}[/bold] "
401
+ f"[white]{name}[/white]([dim]{escape(_format_args(args))}[/dim])"
402
+ )
403
+ state["label_shown"] = False
404
+
405
+ def on_tool_result(name: str, result: str) -> None:
406
+ if quiet:
407
+ return
408
+ lines = (result or "").strip().splitlines()
409
+ snippet = lines[0] if lines else "(no output)"
410
+ if len(snippet) > 80:
411
+ snippet = snippet[:77] + "..."
412
+ more = f" [dim](+{len(lines) - 1} more lines)[/dim]" if len(lines) > 1 else ""
413
+ console.print(f" [grey42]└[/grey42] [dim]{escape(snippet)}[/dim]{more}")
414
+
415
+ return _AgentWithReset(
416
+ config, llm, registry, store, on_token, on_tool, on_tool_result, state
417
+ )
418
+
419
+
420
+ class _AgentWithReset(Agent):
421
+ """Agent wrapper that resets the streaming label state before each turn."""
422
+
423
+ def __init__(
424
+ self, config, llm, registry, store, on_token, on_tool, on_tool_result, state
425
+ ) -> None:
426
+ super().__init__(
427
+ config,
428
+ llm,
429
+ registry,
430
+ store,
431
+ on_token=on_token,
432
+ on_tool=on_tool,
433
+ on_tool_result=on_tool_result,
434
+ )
435
+ self._state = state
436
+
437
+ def run_turn(self, user_input: str, images: list[str] | None = None) -> str:
438
+ self._state["label_shown"] = False
439
+ return super().run_turn(user_input, images=images)
440
+
441
+
442
+ def _show_splash(config: Config) -> None:
443
+ """Clear the screen and show the pixel-art PDO logo, then start."""
444
+ endpoint = config.openai_base_url or "OpenAI (default)"
445
+ interactive = sys.stdout.isatty()
446
+
447
+ if interactive:
448
+ console.clear()
449
+ console.print()
450
+ console.print(Align.center(Text(render_logo(), style=f"bold {accent()}")))
451
+ console.print(Align.center(Text("Think. Plan. Do.", style="dim")))
452
+ console.print()
453
+ console.print(
454
+ Align.center(
455
+ Text(
456
+ f"v{__version__} model: {config.openai_model} endpoint: {endpoint}",
457
+ style=accent(),
458
+ )
459
+ )
460
+ )
461
+ console.print()
462
+ # A brief pause so the splash registers as a screen, not a flicker. Skipped
463
+ # for non-interactive runs so scripts/pipes aren't slowed down.
464
+ if interactive:
465
+ time.sleep(1.2)
466
+ console.print(
467
+ "[dim]Type [bold]/[/bold] for commands, [bold]/exit[/bold] to quit.[/dim]\n"
468
+ )
469
+
470
+
471
+ def _repl(
472
+ agent: Agent,
473
+ registry: ToolRegistry,
474
+ store: MemoryStore,
475
+ config: Config,
476
+ skills: dict[str, Skill],
477
+ ) -> int:
478
+ # Built-in commands plus one slash command per loaded skill.
479
+ commands = {**_COMMANDS, **{f"/{s.name}": s.description for s in skills.values()}}
480
+ session = _make_prompt_session(config, agent, commands)
481
+ while True:
482
+ try:
483
+ user_input = _read_input(session)
484
+ except (EOFError, KeyboardInterrupt):
485
+ console.print("\nGoodbye!")
486
+ return 0
487
+
488
+ if not user_input:
489
+ continue
490
+
491
+ if user_input.startswith("/"):
492
+ if _handle_command(user_input, registry, store, agent, config, skills, commands):
493
+ return 0
494
+ continue
495
+
496
+ expanded, images = _expand_file_refs(user_input)
497
+ _run_turn_interactive(agent, config, expanded, images)
498
+
499
+
500
+ # File extensions treated as image attachments (sent to vision models).
501
+ _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
502
+
503
+
504
+ def _expand_file_refs(text: str) -> tuple[str, list[str]]:
505
+ """Resolve ``@path`` references: inline text files, collect image paths.
506
+
507
+ Returns the (possibly expanded) message text and a list of image file paths
508
+ to attach to the turn for vision-capable models.
509
+ """
510
+ attachments: list[tuple[str, str]] = []
511
+ images: list[str] = []
512
+ for match in _FILE_REF.finditer(text):
513
+ path = Path(match.group(1)).expanduser()
514
+ if not path.is_file():
515
+ continue
516
+ if path.suffix.lower() in _IMAGE_EXTS:
517
+ images.append(str(path.resolve()))
518
+ continue
519
+ try:
520
+ attachments.append((str(path), truncate(path.read_text("utf-8", "ignore"), 8000)))
521
+ except OSError:
522
+ continue
523
+ if attachments or images:
524
+ console.print(f"[dim]📎 attached {len(attachments) + len(images)} file(s)[/dim]")
525
+ if not attachments:
526
+ return text, images
527
+ blocks = "\n\n".join(f"[Attached file: {p}]\n```\n{c}\n```" for p, c in attachments)
528
+ return f"{text}\n\n{blocks}", images
529
+
530
+
531
+ def _run_turn_interactive(
532
+ agent: Agent, config: Config, user_input: str, images: list[str] | None = None
533
+ ) -> None:
534
+ """Run one turn with a thinking spinner and (optionally) Markdown rendering."""
535
+ try:
536
+ if config.render_markdown:
537
+ # Tokens aren't streamed in this mode; show a spinner while the agent
538
+ # works (tool-activity lines still print live), then render the reply.
539
+ with console.status(f"[{accent()}]Thinking…[/{accent()}]", spinner="dots"):
540
+ answer = agent.run_turn(user_input, images=images)
541
+ console.print()
542
+ console.print(Markdown(answer))
543
+ else:
544
+ agent.run_turn(user_input, images=images) # raw token streaming
545
+ except KeyboardInterrupt:
546
+ console.print("\n[dim]⏹ Cancelled.[/dim]")
547
+ except LLMError as exc:
548
+ console.print(f"\n[red]LLM error:[/red] {exc}")
549
+ except Exception as exc: # noqa: BLE001 — never kill the REPL on one bad turn
550
+ logger.exception("Turn failed")
551
+ console.print(f"\n[red]Unexpected error:[/red] {exc}")
552
+ console.print() # spacing after the reply
553
+
554
+
555
+ def _handle_command(
556
+ command: str,
557
+ registry: ToolRegistry,
558
+ store: MemoryStore,
559
+ agent: Agent,
560
+ config: Config,
561
+ skills: dict[str, Skill],
562
+ commands: dict[str, str],
563
+ ) -> bool:
564
+ """Handle a slash command. Returns True if PDO should exit."""
565
+ cmd = command.strip().lower()
566
+
567
+ if cmd in ("/exit", "/quit"):
568
+ console.print("Goodbye!")
569
+ return True
570
+ if cmd == "/help":
571
+ console.print(Panel(_help_text(commands), border_style=accent()))
572
+ elif cmd in ("/models", "/model"):
573
+ _choose_model(agent, config)
574
+ elif cmd.startswith("/theme"):
575
+ _handle_theme(command, config)
576
+ elif cmd.startswith("/export"):
577
+ _handle_export(command, store)
578
+ elif cmd == "/sessions":
579
+ _print_sessions(store)
580
+ elif cmd.startswith("/new"):
581
+ parts = command.split(maxsplit=1)
582
+ name = store.new_session(parts[1].strip() if len(parts) > 1 else None)
583
+ console.print(f"[green]✓ Started new session[/green] [{accent()}]{name}[/{accent()}]")
584
+ elif cmd.startswith("/resume"):
585
+ _handle_resume(command, store)
586
+ elif cmd == "/version":
587
+ console.print(f"PDO version [{accent()}]{__version__}[/{accent()}]")
588
+ elif cmd == "/tools":
589
+ _print_tools(registry)
590
+ elif cmd == "/mcp":
591
+ _print_mcp()
592
+ elif cmd == "/index":
593
+ _handle_index()
594
+ elif cmd == "/memory":
595
+ _print_memory(store)
596
+ elif cmd == "/history":
597
+ _print_history(store)
598
+ elif cmd == "/clear":
599
+ store.clear_history()
600
+ console.print("[green]Conversation history cleared.[/green]")
601
+ else:
602
+ # A user-defined skill? (slash command backed by a prompt template.)
603
+ name = command[1:].split(maxsplit=1)[0].lower() if len(command) > 1 else ""
604
+ if name in skills:
605
+ parts = command.split(maxsplit=1)
606
+ rendered = skills[name].render(parts[1].strip() if len(parts) > 1 else "")
607
+ expanded, images = _expand_file_refs(rendered)
608
+ _run_turn_interactive(agent, config, expanded, images)
609
+ else:
610
+ console.print(f"[yellow]Unknown command:[/yellow] {command} (try /help)")
611
+ return False
612
+
613
+
614
+ def _handle_theme(command: str, config: Config) -> None:
615
+ """`/theme` shows the current theme; `/theme NAME` switches it."""
616
+ parts = command.split()
617
+ available = ", ".join(theme_names())
618
+ if len(parts) < 2:
619
+ console.print(f"Current theme: [{accent()}]{current_theme()}[/{accent()}]")
620
+ console.print(f"[dim]Available: {available}. Use /theme <name>.[/dim]")
621
+ return
622
+ name = parts[1].lower()
623
+ if set_theme(name):
624
+ config.theme = name
625
+ console.print(f"[{accent()}]✓ Theme set to {name}.[/{accent()}]")
626
+ else:
627
+ console.print(f"[yellow]Unknown theme {name!r}.[/yellow] Available: {available}")
628
+
629
+
630
+ def _handle_index() -> None:
631
+ """(Re)build the BM25 codebase index for the current directory."""
632
+ from .rag import build_index
633
+
634
+ with console.status(f"[{accent()}]Indexing…[/{accent()}]", spinner="dots"):
635
+ index = build_index(Path.cwd())
636
+ files = len({chunk.path for chunk in index.chunks})
637
+ console.print(
638
+ f"[green]✓ Indexed[/green] {files} file(s) into {len(index.chunks)} chunk(s). "
639
+ "The agent can now use codebase_search."
640
+ )
641
+
642
+
643
+ def _print_sessions(store: MemoryStore) -> None:
644
+ current = store.current_session()
645
+ console.print("[bold]Sessions[/bold]")
646
+ for name in store.list_sessions():
647
+ marker = f"[{accent()}]●[/{accent()}]" if name == current else " "
648
+ console.print(f" {marker} {name}")
649
+ console.print("[dim]Switch with /resume <name>, or start one with /new <name>.[/dim]")
650
+
651
+
652
+ def _handle_resume(command: str, store: MemoryStore) -> None:
653
+ parts = command.split(maxsplit=1)
654
+ if len(parts) < 2:
655
+ console.print("[yellow]Usage: /resume <session-name>[/yellow]")
656
+ _print_sessions(store)
657
+ return
658
+ name = parts[1].strip()
659
+ if name not in store.list_sessions():
660
+ console.print(f"[yellow]No session named {name!r}.[/yellow]")
661
+ _print_sessions(store)
662
+ return
663
+ store.switch_session(name)
664
+ console.print(f"[green]✓ Resumed session[/green] [{accent()}]{name}[/{accent()}]")
665
+
666
+
667
+ def _handle_export(command: str, store: MemoryStore) -> None:
668
+ """Save the conversation history to a Markdown file."""
669
+ parts = command.split(maxsplit=1)
670
+ if len(parts) > 1:
671
+ path = Path(parts[1].strip()).expanduser()
672
+ else:
673
+ path = Path.cwd() / f"pdo-conversation-{int(time.time())}.md"
674
+
675
+ entries = store.history()
676
+ if not entries:
677
+ console.print("[yellow]No conversation to export yet.[/yellow]")
678
+ return
679
+
680
+ lines = ["# PDO conversation", ""]
681
+ for entry in entries:
682
+ who = "You" if entry["role"] == "user" else "PDO"
683
+ lines.append(f"**{who}:**")
684
+ lines.append("")
685
+ lines.append(entry["content"])
686
+ lines.append("")
687
+ try:
688
+ path.write_text("\n".join(lines), encoding="utf-8")
689
+ except OSError as exc:
690
+ console.print(f"[red]Could not write {path}:[/red] {exc}")
691
+ return
692
+ console.print(f"[green]✓ Exported {len(entries)} messages to[/green] {path}")
693
+
694
+
695
+ def _ask_choice(label: str, count: int) -> int | None:
696
+ """Read a 1-based menu choice; returns None to cancel."""
697
+ raw = console.input(f"[bold]{label}[/bold] [dim](1-{count}, blank to cancel):[/dim] ").strip()
698
+ if not raw:
699
+ return None
700
+ if raw.isdigit() and 1 <= int(raw) <= count:
701
+ return int(raw)
702
+ console.print("[yellow]Invalid choice.[/yellow]")
703
+ return None
704
+
705
+
706
+ def _prompt_secret(prompt: str) -> str:
707
+ """Read a secret (API key) without echoing it to the screen."""
708
+ if _HAVE_PTK and sys.stdout.isatty():
709
+ from prompt_toolkit import prompt as ptk_prompt
710
+
711
+ try:
712
+ return ptk_prompt(prompt, is_password=True).strip()
713
+ except (EOFError, KeyboardInterrupt):
714
+ return ""
715
+ import getpass
716
+
717
+ try:
718
+ return getpass.getpass(prompt).strip()
719
+ except (EOFError, KeyboardInterrupt):
720
+ return ""
721
+
722
+
723
+ def _resolve_api_key(provider: Provider) -> str:
724
+ """Find an API key for ``provider`` from the session cache or environment.
725
+
726
+ Falls back to ``OPENAI_API_KEY`` for OpenRouter, since users often reuse it.
727
+ Prompts interactively (and caches for the session) if nothing is found.
728
+ """
729
+ key = _SESSION_KEYS.get(provider.key) or os.getenv(provider.env_key, "").strip()
730
+ if not key and provider.key == "openrouter":
731
+ key = os.getenv("OPENAI_API_KEY", "").strip()
732
+ if key:
733
+ return key
734
+
735
+ console.print(f"[yellow]No {provider.env_key} found in the environment.[/yellow]")
736
+ key = _prompt_secret(f"Enter your {provider.label} API key (blank to cancel): ")
737
+ if key:
738
+ _SESSION_KEYS[provider.key] = key # session-only, never written to disk
739
+ return key
740
+
741
+
742
+ def _provider_base_url(provider: Provider) -> str | None:
743
+ """Resolve the endpoint, honouring OLLAMA_BASE_URL for the local provider."""
744
+ if provider.key == "ollama":
745
+ return os.getenv("OLLAMA_BASE_URL", "").strip() or provider.base_url
746
+ return provider.base_url
747
+
748
+
749
+ def _fetch_models(base_url: str | None, key: str) -> list[str]:
750
+ """Fetch live model ids from an OpenAI-compatible /models endpoint.
751
+
752
+ Returns a sorted list, or an empty list if the endpoint can't be reached or
753
+ doesn't support listing (the caller then falls back to the curated list).
754
+ """
755
+ try:
756
+ from openai import OpenAI
757
+
758
+ client = OpenAI(api_key=key or "none", base_url=base_url)
759
+ return sorted(model.id for model in client.models.list().data)
760
+ except Exception as exc: # noqa: BLE001 — listing is best-effort
761
+ logger.warning("Could not fetch models from %s: %s", base_url or "OpenAI", exc)
762
+ return []
763
+
764
+
765
+ # At or below this many models, show a numbered menu; above it, type-to-search.
766
+ _MODEL_MENU_LIMIT = 12
767
+
768
+
769
+ def _select_model(models: list[str]) -> str | None:
770
+ """Pick a model: a numbered menu for short lists (e.g. local Ollama models),
771
+ or type-to-search for long ones (e.g. hundreds on OpenRouter). Blank cancels.
772
+ """
773
+ interactive = _HAVE_PTK and sys.stdout.isatty()
774
+
775
+ if interactive and len(models) > _MODEL_MENU_LIMIT:
776
+ from prompt_toolkit import prompt as ptk_prompt
777
+ from prompt_toolkit.completion import FuzzyWordCompleter
778
+
779
+ try:
780
+ return (
781
+ ptk_prompt(
782
+ "Model (type to search, Enter to confirm): ",
783
+ completer=FuzzyWordCompleter(models),
784
+ complete_while_typing=True,
785
+ ).strip()
786
+ or None
787
+ )
788
+ except (EOFError, KeyboardInterrupt):
789
+ return None
790
+
791
+ # Numbered menu: short lists, or any non-interactive terminal.
792
+ shown = models[:40]
793
+ for index, model in enumerate(shown, 1):
794
+ console.print(f" [cyan]{index}[/cyan]. {model}")
795
+ if len(models) > len(shown):
796
+ console.print(f" [dim]… and {len(models) - len(shown)} more[/dim]")
797
+
798
+ custom_index = len(shown) + 1 if interactive else 0
799
+ if custom_index:
800
+ console.print(f" [cyan]{custom_index}[/cyan]. [dim]Enter a custom model id[/dim]")
801
+
802
+ selection = _ask_choice("Model", custom_index or len(shown))
803
+ if selection is None:
804
+ return None
805
+ if custom_index and selection == custom_index:
806
+ return console.input("Custom model id: ").strip() or None
807
+ return shown[selection - 1]
808
+
809
+
810
+ def _choose_model(agent: Agent, config: Config) -> None:
811
+ """Interactive ``/models`` flow: pick provider → connection → model, then switch."""
812
+ providers = list(PROVIDERS.values())
813
+
814
+ console.print("\n[bold cyan]Choose a provider[/bold cyan]")
815
+ for index, provider in enumerate(providers, 1):
816
+ console.print(f" [cyan]{index}[/cyan]. {provider.label}")
817
+ choice = _ask_choice("Provider", len(providers))
818
+ if choice is None:
819
+ return
820
+ provider = providers[choice - 1]
821
+
822
+ base_url = _provider_base_url(provider)
823
+
824
+ if provider.key == "ollama":
825
+ # Local server: no authentication and no connection-method step.
826
+ key = "ollama"
827
+ else:
828
+ # Connection method. Only API keys are supported; the menu makes the
829
+ # choice explicit and explains why subscription login isn't available.
830
+ console.print(f"\n[bold cyan]Connect to {provider.label} via[/bold cyan]")
831
+ console.print(" [cyan]1[/cyan]. API key")
832
+ console.print(
833
+ " [cyan]2[/cyan]. Subscription / account login [dim](not supported)[/dim]"
834
+ )
835
+ method = _ask_choice("Method", 2)
836
+ if method is None:
837
+ return
838
+ if method == 2:
839
+ console.print(
840
+ "[yellow]Subscription login isn't supported — a Claude.ai or "
841
+ "ChatGPT plan does not include API access. Use an API key "
842
+ "(option 1).[/yellow]"
843
+ )
844
+ return
845
+ key = _resolve_api_key(provider)
846
+ if not key:
847
+ console.print("[dim]Cancelled — no API key provided.[/dim]")
848
+ return
849
+
850
+ console.print(f"[dim]Fetching available models from {provider.label}…[/dim]")
851
+ models = _fetch_models(base_url, key)
852
+ source = "live from provider"
853
+ if not models:
854
+ models = list(provider.models)
855
+ source = "built-in fallback list"
856
+ if provider.key == "ollama":
857
+ console.print(
858
+ f"[yellow]Couldn't reach Ollama at {base_url}. "
859
+ "Is it running ('ollama serve')?[/yellow]"
860
+ )
861
+ console.print(f"[dim]{len(models)} models available ({source}).[/dim]")
862
+
863
+ model = _select_model(models)
864
+ if not model:
865
+ console.print("[dim]Cancelled.[/dim]")
866
+ return
867
+
868
+ try:
869
+ llm = OpenAIClient(
870
+ api_key=key,
871
+ model=model,
872
+ temperature=config.temperature,
873
+ base_url=base_url,
874
+ )
875
+ except Exception as exc: # noqa: BLE001 — show a friendly message, keep the REPL alive
876
+ console.print(f"[red]Failed to initialise {provider.label}:[/red] {exc}")
877
+ return
878
+
879
+ agent.set_llm(llm)
880
+ config.openai_model = model
881
+ config.openai_base_url = base_url
882
+ config.openai_api_key = key
883
+ console.print(f"[green]✓ Switched to {provider.label} · {model}[/green]")
884
+
885
+
886
+ def _print_tools(registry: ToolRegistry) -> None:
887
+ plugin_names = set(plugin_tool_names())
888
+ table = Table(title="Available tools", title_style="bold cyan")
889
+ table.add_column("Name", style="cyan", no_wrap=True)
890
+ table.add_column("Description")
891
+ table.add_column("Source", style="dim", no_wrap=True)
892
+ for tool in registry.all():
893
+ if tool.name.startswith("mcp__"):
894
+ source = "mcp"
895
+ elif tool.name in plugin_names:
896
+ source = "plugin"
897
+ else:
898
+ source = "built-in"
899
+ table.add_row(tool.name, tool.description, source)
900
+ console.print(table)
901
+ console.print(
902
+ f"[dim]Drop a .py file defining a Tool subclass in "
903
+ f"{get_plugins_dir()} to add your own.[/dim]"
904
+ )
905
+
906
+
907
+ def _print_mcp() -> None:
908
+ servers = active_servers()
909
+ if not servers:
910
+ console.print(
911
+ "[dim]No MCP servers connected. Configure them in "
912
+ f"{get_mcp_config_path()}.[/dim]"
913
+ )
914
+ return
915
+ for server in servers:
916
+ tools = server.list_tools()
917
+ console.print(f"[{accent()}]●[/{accent()}] [bold]{server.name}[/bold] — {len(tools)} tool(s)")
918
+ for spec in tools:
919
+ console.print(f" [dim]{spec['name']}[/dim] — {spec.get('description', '')[:70]}")
920
+
921
+
922
+ def _print_memory(store: MemoryStore) -> None:
923
+ facts = store.all_facts()
924
+ prefs = store.preferences()
925
+ if not facts and not prefs:
926
+ console.print("[dim]No memories or preferences saved yet.[/dim]")
927
+ return
928
+ if prefs:
929
+ console.print("[bold]Preferences[/bold]")
930
+ for key, value in prefs.items():
931
+ console.print(f" {key} = {value}")
932
+ if facts:
933
+ console.print("[bold]Facts[/bold]")
934
+ for fact in facts:
935
+ console.print(f" [{fact['id']}] {fact['text']}")
936
+
937
+
938
+ def _print_history(store: MemoryStore) -> None:
939
+ entries = store.history(limit=20)
940
+ if not entries:
941
+ console.print("[dim]No conversation history yet.[/dim]")
942
+ return
943
+ for entry in entries:
944
+ role = entry["role"]
945
+ colour = "blue" if role == "user" else "green"
946
+ console.print(f"[{colour}]{role}[/{colour}]: {entry['content']}")
947
+
948
+
949
+ def _format_args(args: dict) -> str:
950
+ """Render tool arguments compactly for the activity line."""
951
+ parts = []
952
+ for key, value in args.items():
953
+ text = str(value).replace("\n", " ")
954
+ if len(text) > 40:
955
+ text = text[:37] + "..."
956
+ parts.append(f"{key}={text!r}")
957
+ return ", ".join(parts)
958
+
959
+
960
+ if __name__ == "__main__":
961
+ sys.exit(main())