# retoor from __future__ import annotations import argparse import asyncio import logging import os from dataclasses import replace from pathlib import Path from typing import Any, Optional import dataset from . import console from .actions import Dispatcher from .agent import Agent from .agentic import AgenticController, LessonStore from .chunks import ChunkStore from .config import Settings, load_settings from .cost import CostTracker from .errors import AuthRequiredError from .http_client import PlatformClient from .llm import LLMClient from .registry import CATALOG from .tasks import Scheduler, TaskController, TaskStore logger = logging.getLogger("devii.cli") EXIT_COMMANDS = {"/exit", "/quit", "/q"} WELCOME = "Devii - DevPlace assistant" LOGIN_REQUEST = ( "Welcome. I manage your DevPlace account, but you must log in first. " "Please tell me your email and password to continue." ) GOODBYE = "Session ended." RESULT_NOTICE_CHARS = 20000 def _cli_tasks_db_path() -> Path: base = os.environ.get("DEVII_HOME") or os.environ.get("XDG_DATA_HOME") root = Path(base) if base else Path.home() / ".local" / "share" target = root / "devii" target.mkdir(parents=True, exist_ok=True) return target / "devii_tasks.db" def _label(row: dict[str, Any]) -> str: return row.get("label") or row.get("uid", "task") def _make_event_handler() -> Any: def handle(kind: str, row: dict[str, Any], payload: str) -> None: name = _label(row) if kind == "start": console.notice(f"\n[task {name}] running...") elif kind in ("done", "finished"): suffix = " (completed, no further runs)" if kind == "finished" else "" console.notice(f"[task {name}] finished{suffix}:") console.assistant(payload[:RESULT_NOTICE_CHARS]) elif kind == "error": console.warn(f"[task {name}] failed: {payload}") return handle TRACE_GLYPHS = { "call": "↳", "ok": "✓", "err": "✗", "plan-gate": "plan required first", "reflect-trigger": "reflecting on error", "verify-gate": "verification gate", "compact": "context compaction", } def _trace(event: str, name: str = "", detail: str = "") -> None: glyph = TRACE_GLYPHS.get(event, event) if event in ("call", "ok", "err"): suffix = f" {detail}" if detail else "" console.notice(f" {glyph} {name}{suffix}") else: console.notice(f" ! {glyph}") def _make_executor( settings: Settings, llm: LLMClient, dispatcher: Dispatcher, lessons: LessonStore, cost_tracker: CostTracker, chunk_store: ChunkStore, ) -> Any: tools = CATALOG.tool_schemas() async def execute(prompt: str) -> str: worker = Agent( settings, llm, dispatcher, tools, lessons=lessons, cost_tracker=cost_tracker, chunk_store=chunk_store, ) return await worker.respond(prompt) return execute async def _bootstrap_auth(client: PlatformClient, settings: Settings) -> str: if client.authenticated: logger.info("Authenticated via API key") return "Authenticated via API key. How can I help?" if settings.login_email and settings.login_password: try: await client.login(settings.login_email, settings.login_password) return f"Logged in as {client.username}. How can I help?" except AuthRequiredError as exc: console.warn(f"[auth] automatic login failed: {exc.message}") return LOGIN_REQUEST def _resolve_owner(settings: Settings) -> tuple[str, str]: try: from devplacepy.database import db, get_table if "users" not in db.tables: return "user", "cli" users = get_table("users") user = None if settings.platform_api_key: user = users.find_one(api_key=settings.platform_api_key) if user is None and settings.login_email: user = users.find_one(email=settings.login_email.lower()) if user: return "user", user["uid"] except Exception: # noqa: BLE001 - no local DB / remote-only: fall back to a standalone store return "user", "cli" return "user", "cli" def _build_stores(owner_kind: str, owner_id: str): if owner_id == "cli": owned_db = dataset.connect(f"sqlite:///{_cli_tasks_db_path()}") else: from devplacepy.database import ( db as owned_db, ) # share the platform DB for this account return TaskStore(owned_db, owner_kind, owner_id), LessonStore( owned_db, owner_kind, owner_id ) async def run(settings: Settings, prompt: Optional[str] = None) -> None: client = PlatformClient( settings.base_url, settings.timeout_seconds, settings.platform_api_key ) llm = LLMClient(settings) greeting = await _bootstrap_auth(client, settings) owner_kind, owner_id = _resolve_owner(settings) store, lessons = _build_stores(owner_kind, owner_id) controller = TaskController(store) cost_tracker = CostTracker() chunk_store = ChunkStore() agentic = AgenticController(lessons, settings) dispatcher = Dispatcher( CATALOG, client, settings, controller, agentic, is_admin=True, is_primary_admin=True, ) tools = CATALOG.tool_schemas_for( client.authenticated, is_admin=True, is_primary_admin=True ) agentic.bind( llm=llm, dispatcher=dispatcher, tools=tools, on_trace=_trace, cost_tracker=cost_tracker, chunk_store=chunk_store, ) agent = Agent( settings, llm, dispatcher, tools, lessons=lessons, on_trace=_trace, cost_tracker=cost_tracker, chunk_store=chunk_store, ) if prompt is not None: try: reply = await agent.respond(prompt) print(reply) finally: await client.aclose() await llm.aclose() return scheduler = Scheduler( store, _make_executor(settings, llm, dispatcher, lessons, cost_tracker, chunk_store), _make_event_handler(), settings.scheduler_tick_seconds, ) console.banner(WELCOME) console.notice(f"Endpoint {settings.base_url} | model {settings.ai_model}") console.notice( "Type /exit to quit. Agentic ReAct loop with planning, self-learning memory, and " "background tasks is active.\n" ) console.assistant(greeting) scheduler.start() try: while True: try: user_text = (await console.prompt("you> ")).strip() except (EOFError, KeyboardInterrupt): print() break if not user_text: continue if user_text.lower() in EXIT_COMMANDS: break try: reply = await agent.respond(user_text) except Exception as exc: # noqa: BLE001 - keep the chat alive logger.exception("Unhandled error while responding") console.warn(f"[error] {exc}") continue console.assistant(reply) finally: await scheduler.stop() await client.aclose() await llm.aclose() console.notice(GOODBYE) def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: parser = argparse.ArgumentParser( prog="devii", description="Agentic chat assistant that manages a DevPlace account.", ) parser.add_argument( "-p", "--prompt", help="Run a single prompt non-interactively and exit." ) parser.add_argument( "--api-key", help="DevPlace API key for automatic authentication (Bearer)." ) parser.add_argument("--email", help="Login email for automatic basic-auth login.") parser.add_argument( "--password", help="Login password for automatic basic-auth login." ) parser.add_argument( "--basic-auth", help="Shorthand credentials in the form email:password." ) parser.add_argument("--base-url", help="Override the platform base URL.") parser.add_argument("--ai-url", help="Override the model endpoint URL.") parser.add_argument("--ai-key", help="Override the model API key.") parser.add_argument("--model", help="Override the model name.") return parser.parse_args(argv) def _settings_from_args(args: argparse.Namespace) -> Settings: settings = load_settings() email = args.email or settings.login_email password = args.password or settings.login_password if args.basic_auth and ":" in args.basic_auth: email, password = args.basic_auth.split(":", 1) overrides: dict[str, Any] = {"login_email": email, "login_password": password} if args.api_key: overrides["platform_api_key"] = args.api_key if args.base_url: overrides["base_url"] = args.base_url.rstrip("/") if args.ai_url: overrides["ai_url"] = args.ai_url if args.ai_key: overrides["ai_key"] = args.ai_key if args.model: overrides["ai_model"] = args.model return replace(settings, **overrides) NOISY_LOGGERS = ( "httpcore", "httpx", "httpcore.http11", "httpcore.connection", "asyncio", "urllib3", ) def main(argv: Optional[list[str]] = None) -> None: args = _parse_args(argv) settings = _settings_from_args(args) logging.basicConfig( level=getattr(logging, settings.log_level, logging.WARNING), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) for name in NOISY_LOGGERS: logging.getLogger(name).setLevel(logging.WARNING) logger.info("Starting Devii") try: asyncio.run(run(settings, prompt=args.prompt)) except KeyboardInterrupt: pass if __name__ == "__main__": main()