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/__init__.py +21 -0
- pdo/agent/__init__.py +6 -0
- pdo/agent/core.py +275 -0
- pdo/agent/delegate.py +56 -0
- pdo/agent/executor.py +87 -0
- pdo/agent/memory.py +191 -0
- pdo/agent/messages.py +87 -0
- pdo/agent/planner.py +38 -0
- pdo/agent/reviewer.py +25 -0
- pdo/agent/router.py +37 -0
- pdo/api.py +65 -0
- pdo/banner.py +53 -0
- pdo/config.py +151 -0
- pdo/llm.py +211 -0
- pdo/logging_setup.py +34 -0
- pdo/main.py +961 -0
- pdo/mcp.py +264 -0
- pdo/prompts/system.md +46 -0
- pdo/providers.py +86 -0
- pdo/rag.py +191 -0
- pdo/serve.py +124 -0
- pdo/skills.py +59 -0
- pdo/theme.py +47 -0
- pdo/tools/__init__.py +6 -0
- pdo/tools/base.py +89 -0
- pdo/tools/code.py +48 -0
- pdo/tools/data.py +57 -0
- pdo/tools/edit.py +55 -0
- pdo/tools/filesystem.py +175 -0
- pdo/tools/git.py +44 -0
- pdo/tools/memory.py +70 -0
- pdo/tools/rag.py +60 -0
- pdo/tools/registry.py +203 -0
- pdo/tools/search.py +83 -0
- pdo/tools/shell.py +125 -0
- pdo/tools/web.py +163 -0
- pdo_agent-2.0.0.dist-info/METADATA +456 -0
- pdo_agent-2.0.0.dist-info/RECORD +42 -0
- pdo_agent-2.0.0.dist-info/WHEEL +5 -0
- pdo_agent-2.0.0.dist-info/entry_points.txt +2 -0
- pdo_agent-2.0.0.dist-info/licenses/LICENSE +21 -0
- pdo_agent-2.0.0.dist-info/top_level.txt +1 -0
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())
|