1 line
2.3 MiB
Plaintext
1 line
2.3 MiB
Plaintext
{"repo": "ahmidarrow/remedyai", "files": {"src/remedy/interfaces/config.py": {"0fd1b05f88fa0a7cb81d91f349b80ec4f8f6719b": {"t": 1785746885.2818978, "n": 55821, "h": "7c605e14c689c76d", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n \"poe\": (\"POE_API_KEY\", \"REMEDY_POE_API_KEY\"),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (Free)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": (\n \"Rate-limited guest chat only (third-party). Curated stable models — \"\n \"not the full gateway catalog. Add a real provider for agents / vision.\"\n ),\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n # Curated guest-chat allowlist only. Never merge live /models (that dumps\n # image/video ids, promo names, and paid-key models like deepseek-*).\n # live_models: False → catalog.py skips endpoint discovery for demo.\n \"live_models\": False,\n \"models\": [\n {\n \"id\": \"codestral-latest\",\n \"name\": \"Codestral demo\",\n \"vision\": False,\n },\n {\n \"id\": \"gemini-3.1-flash-lite\",\n \"name\": \"Gemini Flash Lite demo\",\n \"vision\": False,\n },\n {\n \"id\": \"gpt-oss:20b\",\n \"name\": \"GPT-OSS 20B demo\",\n \"vision\": False,\n },\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n # Fallback only — live GET /models is the source of truth with a key.\n # V4 API currently exposes flash + pro (chat/reasoner ids map → flash).\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": (\n \"Free-tier models (ids ending in :free) come and go; live /models \"\n \"lists what your key can use. Curated fallbacks below are long-running free chat.\"\n ),\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n # Stable free chat fallbacks only (not promo/image). Live discovery adds more.\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\n \"id\": \"meta-llama/llama-3.3-70b-instruct:free\",\n \"name\": \"Llama 3.3 70B (free)\",\n },\n ],\n },\n \"poe\": {\n \"label\": \"Poe\",\n \"base_url\": \"https://api.poe.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"POE_API_KEY\", \"REMEDY_POE_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"badge\": \"Multi-model\",\n \"limits_blurb\": (\n \"One Poe API key → many frontier bots (Claude, GPT, Gemini, Grok, …). \"\n \"Uses your Poe subscription points / add-on balance. \"\n \"Live GET /models lists bots your account can call.\"\n ),\n \"privacy_note\": \"Chat is sent to Poe (Quora) and routed to the selected bot/provider.\",\n \"key_docs_url\": \"https://poe.com/api_key\",\n # Bot names as used by Poe OpenAI-compatible API (case-sensitive).\n # Live discovery expands this when a key is present.\n \"models\": [\n {\"id\": \"Claude-Sonnet-4.6\", \"name\": \"Claude Sonnet 4.6\", \"vision\": True},\n {\"id\": \"Claude-Opus-4.7\", \"name\": \"Claude Opus 4.7\", \"vision\": True},\n {\"id\": \"GPT-5.4\", \"name\": \"GPT-5.4\", \"vision\": True},\n {\"id\": \"Gemini-3.1-Pro\", \"name\": \"Gemini 3.1 Pro\", \"vision\": True},\n {\"id\": \"Grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\n\n Hides the guest demo entry when ``REMEDY_DEMO_DISABLED`` is set so air-gapped\n / enterprise builds never offer a third-party gateway path that cannot run.\n \"\"\"\n demo_disabled = os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() in (\n \"1\",\n \"true\",\n \"yes\",\n )\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n if pid == \"demo\" and demo_disabled:\n continue\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"api.poe.com\" in u or \"poe.com\" in u:\n return \"poe\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\", \"poe\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n # Poe hosts many labs' bots under Poe bot names (not closed native catalogs).\n # Demo is *not* flexible — guest gateway junk (image/video/foreign) is clamped\n # to the curated allowlist so free-setup never silently hits blocked models.\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"poe\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE and prov != \"demo\":\n mid = default_model\n elif prov == \"demo\" and default_models:\n known = {m[\"id\"] for m in default_models}\n if mid not in known:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: snap foreign / garbage ids to provider default.\n # Allow native-family prefixes (deepseek-*, grok-*) for live /models extras.\n elif mid not in known and not _native_model_id_for_provider(prov, mid):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\ndef _native_model_id_for_provider(provider: str, model_id: str) -> bool:\n \"\"\"True when *model_id* looks like a native id for *provider* (not catalog-only).\"\"\"\n mid = (model_id or \"\").strip().lower()\n prov = (provider or \"\").strip().lower()\n if not mid or not prov:\n return False\n if prov == \"deepseek\":\n return mid.startswith(\"deepseek\")\n if prov == \"xai\":\n return mid.startswith(\"grok\") or mid.startswith(\"xai/\")\n if prov == \"openai\":\n return mid.startswith((\"gpt-\", \"o1\", \"o3\", \"o4\", \"chatgpt-\"))\n if prov == \"anthropic\":\n return mid.startswith(\"claude\")\n if prov == \"google\":\n return mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\")\n if prov == \"mistral\":\n return mid.startswith((\"mistral\", \"codestral\", \"open-mistral\"))\n if prov == \"groq\":\n return mid.startswith(\"groq/\") or mid in {\n m[\"id\"] for m in (PROVIDER_CATALOG.get(\"groq\") or {}).get(\"models\") or []\n }\n return False\n\n\ndef validate_provider_model(provider: str | None, model: str | None) -> str:\n \"\"\"Return a clean model id or raise ValueError for closed-catalog garbage.\n\n Flexible providers (ollama/custom/openrouter) accept any non-empty id.\n Closed providers accept catalog ids + native-family prefixes only.\n \"\"\"\n prov = (provider or \"\").strip().lower() or \"openai\"\n mid = (model or \"\").strip()\n if not mid:\n raise ValueError(\"Model id is required\")\n # Demo uses a hard curated allowlist (same as catalog route filter).\n if prov == \"demo\":\n catalog = PROVIDER_CATALOG.get(\"demo\") or {}\n known = {m[\"id\"] for m in (catalog.get(\"models\") or [])}\n if mid in known:\n return mid\n sample = \", \".join(sorted(known)[:8]) or \"(none)\"\n raise ValueError(\n f\"Unknown demo model {mid!r}. Guest free chat allows: {sample}.\"\n )\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"poe\"})\n if prov in _FLEXIBLE or prov not in PROVIDER_CATALOG:\n return mid\n # Apply legacy aliases first\n mid = _LEGACY_MODEL_ALIASES.get(mid.lower(), mid)\n catalog = PROVIDER_CATALOG.get(prov) or {}\n known = {m[\"id\"] for m in (catalog.get(\"models\") or [])}\n if mid in known or _native_model_id_for_provider(prov, mid):\n return mid\n sample = \", \".join(sorted(known)[:8]) or \"(none)\"\n raise ValueError(\n f\"Unknown model {mid!r} for provider {prov!r}. \"\n f\"Pick a listed model (e.g. {sample}).\"\n )\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n am = str(config.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = str(config.get(\"access_scope\") or \"project\").strip().lower() or \"project\"\n hm = str(config.get(\"harness_mode\") or \"auto\").strip().lower()\n if hm not in (\"off\", \"manual\", \"auto\"):\n hm = \"auto\"\n tl = str(config.get(\"thinking_level\") or \"high\").strip().lower()\n if tl not in (\"off\", \"low\", \"medium\", \"high\"):\n tl = \"high\"\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n # Partner trust / agency — must come from config.toml (status-bar thumbs).\n # Previously omitted → always defaulted to approval_mode=\"ask\" at startup\n # while Settings UI still showed auto from raw TOML.\n access_scope=scope,\n harness_mode=hm,\n harness_min_context_pct=float(config.get(\"harness_min_context_pct\") or 0.75),\n harness_max_context_pct=float(config.get(\"harness_max_context_pct\") or 0.92),\n thinking_level=tl,\n approval_mode=am,\n launch_at_login=bool(config.get(\"launch_at_login\", False)),\n start_in_tray=bool(config.get(\"start_in_tray\", False)),\n close_to_tray=bool(config.get(\"close_to_tray\", False)),\n )\n\n\ndef generate_default_config(\n home_dir: Path,\n *,\n first_run_project: Path | None = None,\n) -> str:\n \"\"\"Generate a default TOML config file content.\n\n On first-run only, *first_run_project* (New Project folder) may be written so\n Settings has a safe starter path. New chat sessions still start with no\n project (root) unless the user attaches one.\n \"\"\"\n if first_run_project is not None:\n proj_line = f'project_path = \"{Path(first_run_project).expanduser().as_posix()}\"'\n scope_line = 'access_scope = \"project\" # first-run sandbox; change to full when ready'\n else:\n proj_line = (\n '# project_path = \"\" # empty = no default; New Session = root (full access)'\n )\n scope_line = '# access_scope = \"project\" # project | home | full | untrusted'\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Optional default folder (Settings). New Session does NOT auto-attach a project —\n# sessions without a project are root (full access). Pick a project per session\n# or set a default here for tools that need a workspace root.\n{proj_line}\n{scope_line}\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Messaging channels (also Settings → Messengers). Tokens live in the secure store.\n# Desktop sessions share the same memory.db — messenger chats appear in the sidebar.\n# cli, telegram, discord, slack, mattermost, whatsapp, teams, matrix, google_chat, signal, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n# In-app Browser slide homepage (Settings → Project workspace)\n# browser_home_url = \"https://github.com/AhmiDarrow/RemedyAI\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messengers — non-secret fields only (bot tokens via Settings / secret store)\n[telegram]\nallow_chat_ids = []\nallow_all = false\n\n[discord]\nchannel_id = \"\"\nguild_id = \"\"\n\n[slack]\nchannel_id = \"\"\n\n[mattermost]\nbase_url = \"\"\nteam_id = \"\"\nchannel_id = \"\"\n\n[whatsapp]\nphone_number_id = \"\"\nallow_from = []\n\n[teams]\napp_id = \"\"\ntenant_id = \"\"\n\n[matrix]\nhomeserver = \"\"\nuser_id = \"\"\nroom_id = \"\"\n\n[google_chat]\nspace_id = \"\"\n\n[signal]\ncli_path = \"signal-cli\"\naccount = \"\"\n\n# Local visual decoder (llama-server + SmolVLM2 2.2B) — first-run download; not in installer\n[vision]\nenabled = false\nmodel_id = \"smolvlm2-2.2b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory (first run only).\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n first_run_project = None\n try:\n from remedy.core.workspace import ensure_new_project_seed\n\n # One-time: create Documents/…/New Project and record it in config.\n # Users can clear project_path later; New Session stays root either way.\n first_run_project = ensure_new_project_seed()\n except Exception:\n first_run_project = None\n config_path.write_text(\n generate_default_config(hd, first_run_project=first_run_project),\n encoding=\"utf-8\",\n )\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"POE_API_KEY\", \"poe\"),\n (\"REMEDY_POE_API_KEY\", \"poe\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "b14b427877f6380572bc2fb28271727a7ce0e2df": {"t": 1785768473.681314, "n": 55821, "h": "7c605e14c689c76d", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n \"poe\": (\"POE_API_KEY\", \"REMEDY_POE_API_KEY\"),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (Free)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": (\n \"Rate-limited guest chat only (third-party). Curated stable models — \"\n \"not the full gateway catalog. Add a real provider for agents / vision.\"\n ),\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n # Curated guest-chat allowlist only. Never merge live /models (that dumps\n # image/video ids, promo names, and paid-key models like deepseek-*).\n # live_models: False → catalog.py skips endpoint discovery for demo.\n \"live_models\": False,\n \"models\": [\n {\n \"id\": \"codestral-latest\",\n \"name\": \"Codestral demo\",\n \"vision\": False,\n },\n {\n \"id\": \"gemini-3.1-flash-lite\",\n \"name\": \"Gemini Flash Lite demo\",\n \"vision\": False,\n },\n {\n \"id\": \"gpt-oss:20b\",\n \"name\": \"GPT-OSS 20B demo\",\n \"vision\": False,\n },\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n # Fallback only — live GET /models is the source of truth with a key.\n # V4 API currently exposes flash + pro (chat/reasoner ids map → flash).\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": (\n \"Free-tier models (ids ending in :free) come and go; live /models \"\n \"lists what your key can use. Curated fallbacks below are long-running free chat.\"\n ),\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n # Stable free chat fallbacks only (not promo/image). Live discovery adds more.\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\n \"id\": \"meta-llama/llama-3.3-70b-instruct:free\",\n \"name\": \"Llama 3.3 70B (free)\",\n },\n ],\n },\n \"poe\": {\n \"label\": \"Poe\",\n \"base_url\": \"https://api.poe.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"POE_API_KEY\", \"REMEDY_POE_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"badge\": \"Multi-model\",\n \"limits_blurb\": (\n \"One Poe API key → many frontier bots (Claude, GPT, Gemini, Grok, …). \"\n \"Uses your Poe subscription points / add-on balance. \"\n \"Live GET /models lists bots your account can call.\"\n ),\n \"privacy_note\": \"Chat is sent to Poe (Quora) and routed to the selected bot/provider.\",\n \"key_docs_url\": \"https://poe.com/api_key\",\n # Bot names as used by Poe OpenAI-compatible API (case-sensitive).\n # Live discovery expands this when a key is present.\n \"models\": [\n {\"id\": \"Claude-Sonnet-4.6\", \"name\": \"Claude Sonnet 4.6\", \"vision\": True},\n {\"id\": \"Claude-Opus-4.7\", \"name\": \"Claude Opus 4.7\", \"vision\": True},\n {\"id\": \"GPT-5.4\", \"name\": \"GPT-5.4\", \"vision\": True},\n {\"id\": \"Gemini-3.1-Pro\", \"name\": \"Gemini 3.1 Pro\", \"vision\": True},\n {\"id\": \"Grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\n\n Hides the guest demo entry when ``REMEDY_DEMO_DISABLED`` is set so air-gapped\n / enterprise builds never offer a third-party gateway path that cannot run.\n \"\"\"\n demo_disabled = os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() in (\n \"1\",\n \"true\",\n \"yes\",\n )\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n if pid == \"demo\" and demo_disabled:\n continue\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"api.poe.com\" in u or \"poe.com\" in u:\n return \"poe\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\", \"poe\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n # Poe hosts many labs' bots under Poe bot names (not closed native catalogs).\n # Demo is *not* flexible — guest gateway junk (image/video/foreign) is clamped\n # to the curated allowlist so free-setup never silently hits blocked models.\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"poe\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE and prov != \"demo\":\n mid = default_model\n elif prov == \"demo\" and default_models:\n known = {m[\"id\"] for m in default_models}\n if mid not in known:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: snap foreign / garbage ids to provider default.\n # Allow native-family prefixes (deepseek-*, grok-*) for live /models extras.\n elif mid not in known and not _native_model_id_for_provider(prov, mid):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\ndef _native_model_id_for_provider(provider: str, model_id: str) -> bool:\n \"\"\"True when *model_id* looks like a native id for *provider* (not catalog-only).\"\"\"\n mid = (model_id or \"\").strip().lower()\n prov = (provider or \"\").strip().lower()\n if not mid or not prov:\n return False\n if prov == \"deepseek\":\n return mid.startswith(\"deepseek\")\n if prov == \"xai\":\n return mid.startswith(\"grok\") or mid.startswith(\"xai/\")\n if prov == \"openai\":\n return mid.startswith((\"gpt-\", \"o1\", \"o3\", \"o4\", \"chatgpt-\"))\n if prov == \"anthropic\":\n return mid.startswith(\"claude\")\n if prov == \"google\":\n return mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\")\n if prov == \"mistral\":\n return mid.startswith((\"mistral\", \"codestral\", \"open-mistral\"))\n if prov == \"groq\":\n return mid.startswith(\"groq/\") or mid in {\n m[\"id\"] for m in (PROVIDER_CATALOG.get(\"groq\") or {}).get(\"models\") or []\n }\n return False\n\n\ndef validate_provider_model(provider: str | None, model: str | None) -> str:\n \"\"\"Return a clean model id or raise ValueError for closed-catalog garbage.\n\n Flexible providers (ollama/custom/openrouter) accept any non-empty id.\n Closed providers accept catalog ids + native-family prefixes only.\n \"\"\"\n prov = (provider or \"\").strip().lower() or \"openai\"\n mid = (model or \"\").strip()\n if not mid:\n raise ValueError(\"Model id is required\")\n # Demo uses a hard curated allowlist (same as catalog route filter).\n if prov == \"demo\":\n catalog = PROVIDER_CATALOG.get(\"demo\") or {}\n known = {m[\"id\"] for m in (catalog.get(\"models\") or [])}\n if mid in known:\n return mid\n sample = \", \".join(sorted(known)[:8]) or \"(none)\"\n raise ValueError(\n f\"Unknown demo model {mid!r}. Guest free chat allows: {sample}.\"\n )\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"poe\"})\n if prov in _FLEXIBLE or prov not in PROVIDER_CATALOG:\n return mid\n # Apply legacy aliases first\n mid = _LEGACY_MODEL_ALIASES.get(mid.lower(), mid)\n catalog = PROVIDER_CATALOG.get(prov) or {}\n known = {m[\"id\"] for m in (catalog.get(\"models\") or [])}\n if mid in known or _native_model_id_for_provider(prov, mid):\n return mid\n sample = \", \".join(sorted(known)[:8]) or \"(none)\"\n raise ValueError(\n f\"Unknown model {mid!r} for provider {prov!r}. \"\n f\"Pick a listed model (e.g. {sample}).\"\n )\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n am = str(config.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = str(config.get(\"access_scope\") or \"project\").strip().lower() or \"project\"\n hm = str(config.get(\"harness_mode\") or \"auto\").strip().lower()\n if hm not in (\"off\", \"manual\", \"auto\"):\n hm = \"auto\"\n tl = str(config.get(\"thinking_level\") or \"high\").strip().lower()\n if tl not in (\"off\", \"low\", \"medium\", \"high\"):\n tl = \"high\"\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n # Partner trust / agency — must come from config.toml (status-bar thumbs).\n # Previously omitted → always defaulted to approval_mode=\"ask\" at startup\n # while Settings UI still showed auto from raw TOML.\n access_scope=scope,\n harness_mode=hm,\n harness_min_context_pct=float(config.get(\"harness_min_context_pct\") or 0.75),\n harness_max_context_pct=float(config.get(\"harness_max_context_pct\") or 0.92),\n thinking_level=tl,\n approval_mode=am,\n launch_at_login=bool(config.get(\"launch_at_login\", False)),\n start_in_tray=bool(config.get(\"start_in_tray\", False)),\n close_to_tray=bool(config.get(\"close_to_tray\", False)),\n )\n\n\ndef generate_default_config(\n home_dir: Path,\n *,\n first_run_project: Path | None = None,\n) -> str:\n \"\"\"Generate a default TOML config file content.\n\n On first-run only, *first_run_project* (New Project folder) may be written so\n Settings has a safe starter path. New chat sessions still start with no\n project (root) unless the user attaches one.\n \"\"\"\n if first_run_project is not None:\n proj_line = f'project_path = \"{Path(first_run_project).expanduser().as_posix()}\"'\n scope_line = 'access_scope = \"project\" # first-run sandbox; change to full when ready'\n else:\n proj_line = (\n '# project_path = \"\" # empty = no default; New Session = root (full access)'\n )\n scope_line = '# access_scope = \"project\" # project | home | full | untrusted'\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Optional default folder (Settings). New Session does NOT auto-attach a project —\n# sessions without a project are root (full access). Pick a project per session\n# or set a default here for tools that need a workspace root.\n{proj_line}\n{scope_line}\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Messaging channels (also Settings → Messengers). Tokens live in the secure store.\n# Desktop sessions share the same memory.db — messenger chats appear in the sidebar.\n# cli, telegram, discord, slack, mattermost, whatsapp, teams, matrix, google_chat, signal, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n# In-app Browser slide homepage (Settings → Project workspace)\n# browser_home_url = \"https://github.com/AhmiDarrow/RemedyAI\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messengers — non-secret fields only (bot tokens via Settings / secret store)\n[telegram]\nallow_chat_ids = []\nallow_all = false\n\n[discord]\nchannel_id = \"\"\nguild_id = \"\"\n\n[slack]\nchannel_id = \"\"\n\n[mattermost]\nbase_url = \"\"\nteam_id = \"\"\nchannel_id = \"\"\n\n[whatsapp]\nphone_number_id = \"\"\nallow_from = []\n\n[teams]\napp_id = \"\"\ntenant_id = \"\"\n\n[matrix]\nhomeserver = \"\"\nuser_id = \"\"\nroom_id = \"\"\n\n[google_chat]\nspace_id = \"\"\n\n[signal]\ncli_path = \"signal-cli\"\naccount = \"\"\n\n# Local visual decoder (llama-server + SmolVLM2 2.2B) — first-run download; not in installer\n[vision]\nenabled = false\nmodel_id = \"smolvlm2-2.2b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory (first run only).\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n first_run_project = None\n try:\n from remedy.core.workspace import ensure_new_project_seed\n\n # One-time: create Documents/…/New Project and record it in config.\n # Users can clear project_path later; New Session stays root either way.\n first_run_project = ensure_new_project_seed()\n except Exception:\n first_run_project = None\n config_path.write_text(\n generate_default_config(hd, first_run_project=first_run_project),\n encoding=\"utf-8\",\n )\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"POE_API_KEY\", \"poe\"),\n (\"REMEDY_POE_API_KEY\", \"poe\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "ea6dfe47716d31418017a20c1a106ecbbf7357b3": {"t": 1785768475.1166363, "n": 55066, "h": "12f58e843b71af4a", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n \"poe\": (\"POE_API_KEY\", \"REMEDY_POE_API_KEY\"),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (Free)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": (\n \"Rate-limited guest chat only (third-party). Curated stable models — \"\n \"not the full gateway catalog. Add a real provider for agents / vision.\"\n ),\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n # Curated guest-chat allowlist only. Never merge live /models (that dumps\n # image/video ids, promo names, and paid-key models like deepseek-*).\n # live_models: False → catalog.py skips endpoint discovery for demo.\n \"live_models\": False,\n \"models\": [\n {\n \"id\": \"codestral-latest\",\n \"name\": \"Codestral demo\",\n \"vision\": False,\n },\n {\n \"id\": \"gemini-3.1-flash-lite\",\n \"name\": \"Gemini Flash Lite demo\",\n \"vision\": False,\n },\n {\n \"id\": \"gpt-oss:20b\",\n \"name\": \"GPT-OSS 20B demo\",\n \"vision\": False,\n },\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n # Fallback only — live GET /models is the source of truth with a key.\n # V4 API currently exposes flash + pro (chat/reasoner ids map → flash).\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": (\n \"Free-tier models (ids ending in :free) come and go; live /models \"\n \"lists what your key can use. Curated fallbacks below are long-running free chat.\"\n ),\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n # Stable free chat fallbacks only (not promo/image). Live discovery adds more.\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\n \"id\": \"meta-llama/llama-3.3-70b-instruct:free\",\n \"name\": \"Llama 3.3 70B (free)\",\n },\n ],\n },\n \"poe\": {\n \"label\": \"Poe\",\n \"base_url\": \"https://api.poe.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"POE_API_KEY\", \"REMEDY_POE_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"badge\": \"Multi-model\",\n \"limits_blurb\": (\n \"One Poe API key → many frontier bots (Claude, GPT, Gemini, Grok, …). \"\n \"Uses your Poe subscription points / add-on balance. \"\n \"Live GET /models lists bots your account can call.\"\n ),\n \"privacy_note\": \"Chat is sent to Poe (Quora) and routed to the selected bot/provider.\",\n \"key_docs_url\": \"https://poe.com/api_key\",\n # Bot names as used by Poe OpenAI-compatible API (case-sensitive).\n # Live discovery expands this when a key is present.\n \"models\": [\n {\"id\": \"Claude-Sonnet-4.6\", \"name\": \"Claude Sonnet 4.6\", \"vision\": True},\n {\"id\": \"Claude-Opus-4.7\", \"name\": \"Claude Opus 4.7\", \"vision\": True},\n {\"id\": \"GPT-5.4\", \"name\": \"GPT-5.4\", \"vision\": True},\n {\"id\": \"Gemini-3.1-Pro\", \"name\": \"Gemini 3.1 Pro\", \"vision\": True},\n {\"id\": \"Grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\n\n Hides the guest demo entry when ``REMEDY_DEMO_DISABLED`` is set so air-gapped\n / enterprise builds never offer a third-party gateway path that cannot run.\n \"\"\"\n demo_disabled = os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() in (\n \"1\",\n \"true\",\n \"yes\",\n )\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n if pid == \"demo\" and demo_disabled:\n continue\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"api.poe.com\" in u or \"poe.com\" in u:\n return \"poe\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\", \"poe\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n # Poe hosts many labs' bots under Poe bot names (not closed native catalogs).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\", \"poe\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: snap foreign / garbage ids to provider default.\n # Allow native-family prefixes (deepseek-*, grok-*) for live /models extras.\n elif mid not in known and not _native_model_id_for_provider(prov, mid):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\ndef _native_model_id_for_provider(provider: str, model_id: str) -> bool:\n \"\"\"True when *model_id* looks like a native id for *provider* (not catalog-only).\"\"\"\n mid = (model_id or \"\").strip().lower()\n prov = (provider or \"\").strip().lower()\n if not mid or not prov:\n return False\n if prov == \"deepseek\":\n return mid.startswith(\"deepseek\")\n if prov == \"xai\":\n return mid.startswith(\"grok\") or mid.startswith(\"xai/\")\n if prov == \"openai\":\n return mid.startswith((\"gpt-\", \"o1\", \"o3\", \"o4\", \"chatgpt-\"))\n if prov == \"anthropic\":\n return mid.startswith(\"claude\")\n if prov == \"google\":\n return mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\")\n if prov == \"mistral\":\n return mid.startswith((\"mistral\", \"codestral\", \"open-mistral\"))\n if prov == \"groq\":\n return mid.startswith(\"groq/\") or mid in {\n m[\"id\"] for m in (PROVIDER_CATALOG.get(\"groq\") or {}).get(\"models\") or []\n }\n return False\n\n\ndef validate_provider_model(provider: str | None, model: str | None) -> str:\n \"\"\"Return a clean model id or raise ValueError for closed-catalog garbage.\n\n Flexible providers (ollama/custom/openrouter) accept any non-empty id.\n Closed providers accept catalog ids + native-family prefixes only.\n \"\"\"\n prov = (provider or \"\").strip().lower() or \"openai\"\n mid = (model or \"\").strip()\n if not mid:\n raise ValueError(\"Model id is required\")\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\", \"poe\"})\n if prov in _FLEXIBLE or prov not in PROVIDER_CATALOG:\n return mid\n # Apply legacy aliases first\n mid = _LEGACY_MODEL_ALIASES.get(mid.lower(), mid)\n catalog = PROVIDER_CATALOG.get(prov) or {}\n known = {m[\"id\"] for m in (catalog.get(\"models\") or [])}\n if mid in known or _native_model_id_for_provider(prov, mid):\n return mid\n sample = \", \".join(sorted(known)[:8]) or \"(none)\"\n raise ValueError(\n f\"Unknown model {mid!r} for provider {prov!r}. \"\n f\"Pick a listed model (e.g. {sample}).\"\n )\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n am = str(config.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = str(config.get(\"access_scope\") or \"project\").strip().lower() or \"project\"\n hm = str(config.get(\"harness_mode\") or \"auto\").strip().lower()\n if hm not in (\"off\", \"manual\", \"auto\"):\n hm = \"auto\"\n tl = str(config.get(\"thinking_level\") or \"high\").strip().lower()\n if tl not in (\"off\", \"low\", \"medium\", \"high\"):\n tl = \"high\"\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n # Partner trust / agency — must come from config.toml (status-bar thumbs).\n # Previously omitted → always defaulted to approval_mode=\"ask\" at startup\n # while Settings UI still showed auto from raw TOML.\n access_scope=scope,\n harness_mode=hm,\n harness_min_context_pct=float(config.get(\"harness_min_context_pct\") or 0.75),\n harness_max_context_pct=float(config.get(\"harness_max_context_pct\") or 0.92),\n thinking_level=tl,\n approval_mode=am,\n launch_at_login=bool(config.get(\"launch_at_login\", False)),\n start_in_tray=bool(config.get(\"start_in_tray\", False)),\n close_to_tray=bool(config.get(\"close_to_tray\", False)),\n )\n\n\ndef generate_default_config(\n home_dir: Path,\n *,\n first_run_project: Path | None = None,\n) -> str:\n \"\"\"Generate a default TOML config file content.\n\n On first-run only, *first_run_project* (New Project folder) may be written so\n Settings has a safe starter path. New chat sessions still start with no\n project (root) unless the user attaches one.\n \"\"\"\n if first_run_project is not None:\n proj_line = f'project_path = \"{Path(first_run_project).expanduser().as_posix()}\"'\n scope_line = 'access_scope = \"project\" # first-run sandbox; change to full when ready'\n else:\n proj_line = (\n '# project_path = \"\" # empty = no default; New Session = root (full access)'\n )\n scope_line = '# access_scope = \"project\" # project | home | full | untrusted'\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Optional default folder (Settings). New Session does NOT auto-attach a project —\n# sessions without a project are root (full access). Pick a project per session\n# or set a default here for tools that need a workspace root.\n{proj_line}\n{scope_line}\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Messaging channels (also Settings → Messengers). Tokens live in the secure store.\n# Desktop sessions share the same memory.db — messenger chats appear in the sidebar.\n# cli, telegram, discord, slack, mattermost, whatsapp, teams, matrix, google_chat, signal, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n# In-app Browser slide homepage (Settings → Project workspace)\n# browser_home_url = \"https://github.com/AhmiDarrow/RemedyAI\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messengers — non-secret fields only (bot tokens via Settings / secret store)\n[telegram]\nallow_chat_ids = []\nallow_all = false\n\n[discord]\nchannel_id = \"\"\nguild_id = \"\"\n\n[slack]\nchannel_id = \"\"\n\n[mattermost]\nbase_url = \"\"\nteam_id = \"\"\nchannel_id = \"\"\n\n[whatsapp]\nphone_number_id = \"\"\nallow_from = []\n\n[teams]\napp_id = \"\"\ntenant_id = \"\"\n\n[matrix]\nhomeserver = \"\"\nuser_id = \"\"\nroom_id = \"\"\n\n[google_chat]\nspace_id = \"\"\n\n[signal]\ncli_path = \"signal-cli\"\naccount = \"\"\n\n# Local visual decoder (llama-server + SmolVLM2 2.2B) — first-run download; not in installer\n[vision]\nenabled = false\nmodel_id = \"smolvlm2-2.2b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory (first run only).\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n first_run_project = None\n try:\n from remedy.core.workspace import ensure_new_project_seed\n\n # One-time: create Documents/…/New Project and record it in config.\n # Users can clear project_path later; New Session stays root either way.\n first_run_project = ensure_new_project_seed()\n except Exception:\n first_run_project = None\n config_path.write_text(\n generate_default_config(hd, first_run_project=first_run_project),\n encoding=\"utf-8\",\n )\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"POE_API_KEY\", \"poe\"),\n (\"REMEDY_POE_API_KEY\", \"poe\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "81672813a616a4b3792841e664e42c1125b98fa2": {"t": 1785768476.9329004, "n": 54699, "h": "4529ff7ce1bdf6ea", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n \"poe\": (\"POE_API_KEY\", \"REMEDY_POE_API_KEY\"),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (Free)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": (\n \"Rate-limited guest chat only (third-party). Curated stable models — \"\n \"not the full gateway catalog. Add a real provider for agents / vision.\"\n ),\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n # Curated guest-chat allowlist only. Never merge live /models (that dumps\n # image/video ids, promo names, and paid-key models like deepseek-*).\n # live_models: False → catalog.py skips endpoint discovery for demo.\n \"live_models\": False,\n \"models\": [\n {\n \"id\": \"codestral-latest\",\n \"name\": \"Codestral demo\",\n \"vision\": False,\n },\n {\n \"id\": \"gemini-3.1-flash-lite\",\n \"name\": \"Gemini Flash Lite demo\",\n \"vision\": False,\n },\n {\n \"id\": \"gpt-oss:20b\",\n \"name\": \"GPT-OSS 20B demo\",\n \"vision\": False,\n },\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n # Fallback only — live GET /models is the source of truth with a key.\n # V4 API currently exposes flash + pro (chat/reasoner ids map → flash).\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": (\n \"Free-tier models (ids ending in :free) come and go; live /models \"\n \"lists what your key can use. Curated fallbacks below are long-running free chat.\"\n ),\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n # Stable free chat fallbacks only (not promo/image). Live discovery adds more.\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\n \"id\": \"meta-llama/llama-3.3-70b-instruct:free\",\n \"name\": \"Llama 3.3 70B (free)\",\n },\n ],\n },\n \"poe\": {\n \"label\": \"Poe\",\n \"base_url\": \"https://api.poe.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"POE_API_KEY\", \"REMEDY_POE_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"badge\": \"Multi-model\",\n \"limits_blurb\": (\n \"One Poe API key → many frontier bots (Claude, GPT, Gemini, Grok, …). \"\n \"Uses your Poe subscription points / add-on balance. \"\n \"Live GET /models lists bots your account can call.\"\n ),\n \"privacy_note\": \"Chat is sent to Poe (Quora) and routed to the selected bot/provider.\",\n \"key_docs_url\": \"https://poe.com/api_key\",\n # Bot names as used by Poe OpenAI-compatible API (case-sensitive).\n # Live discovery expands this when a key is present.\n \"models\": [\n {\"id\": \"Claude-Sonnet-4.6\", \"name\": \"Claude Sonnet 4.6\", \"vision\": True},\n {\"id\": \"Claude-Opus-4.7\", \"name\": \"Claude Opus 4.7\", \"vision\": True},\n {\"id\": \"GPT-5.4\", \"name\": \"GPT-5.4\", \"vision\": True},\n {\"id\": \"Gemini-3.1-Pro\", \"name\": \"Gemini 3.1 Pro\", \"vision\": True},\n {\"id\": \"Grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"api.poe.com\" in u or \"poe.com\" in u:\n return \"poe\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\", \"poe\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n # Poe hosts many labs' bots under Poe bot names (not closed native catalogs).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\", \"poe\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: snap foreign / garbage ids to provider default.\n # Allow native-family prefixes (deepseek-*, grok-*) for live /models extras.\n elif mid not in known and not _native_model_id_for_provider(prov, mid):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\ndef _native_model_id_for_provider(provider: str, model_id: str) -> bool:\n \"\"\"True when *model_id* looks like a native id for *provider* (not catalog-only).\"\"\"\n mid = (model_id or \"\").strip().lower()\n prov = (provider or \"\").strip().lower()\n if not mid or not prov:\n return False\n if prov == \"deepseek\":\n return mid.startswith(\"deepseek\")\n if prov == \"xai\":\n return mid.startswith(\"grok\") or mid.startswith(\"xai/\")\n if prov == \"openai\":\n return mid.startswith((\"gpt-\", \"o1\", \"o3\", \"o4\", \"chatgpt-\"))\n if prov == \"anthropic\":\n return mid.startswith(\"claude\")\n if prov == \"google\":\n return mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\")\n if prov == \"mistral\":\n return mid.startswith((\"mistral\", \"codestral\", \"open-mistral\"))\n if prov == \"groq\":\n return mid.startswith(\"groq/\") or mid in {\n m[\"id\"] for m in (PROVIDER_CATALOG.get(\"groq\") or {}).get(\"models\") or []\n }\n return False\n\n\ndef validate_provider_model(provider: str | None, model: str | None) -> str:\n \"\"\"Return a clean model id or raise ValueError for closed-catalog garbage.\n\n Flexible providers (ollama/custom/openrouter) accept any non-empty id.\n Closed providers accept catalog ids + native-family prefixes only.\n \"\"\"\n prov = (provider or \"\").strip().lower() or \"openai\"\n mid = (model or \"\").strip()\n if not mid:\n raise ValueError(\"Model id is required\")\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\", \"poe\"})\n if prov in _FLEXIBLE or prov not in PROVIDER_CATALOG:\n return mid\n # Apply legacy aliases first\n mid = _LEGACY_MODEL_ALIASES.get(mid.lower(), mid)\n catalog = PROVIDER_CATALOG.get(prov) or {}\n known = {m[\"id\"] for m in (catalog.get(\"models\") or [])}\n if mid in known or _native_model_id_for_provider(prov, mid):\n return mid\n sample = \", \".join(sorted(known)[:8]) or \"(none)\"\n raise ValueError(\n f\"Unknown model {mid!r} for provider {prov!r}. \"\n f\"Pick a listed model (e.g. {sample}).\"\n )\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n am = str(config.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = str(config.get(\"access_scope\") or \"project\").strip().lower() or \"project\"\n hm = str(config.get(\"harness_mode\") or \"auto\").strip().lower()\n if hm not in (\"off\", \"manual\", \"auto\"):\n hm = \"auto\"\n tl = str(config.get(\"thinking_level\") or \"high\").strip().lower()\n if tl not in (\"off\", \"low\", \"medium\", \"high\"):\n tl = \"high\"\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n # Partner trust / agency — must come from config.toml (status-bar thumbs).\n # Previously omitted → always defaulted to approval_mode=\"ask\" at startup\n # while Settings UI still showed auto from raw TOML.\n access_scope=scope,\n harness_mode=hm,\n harness_min_context_pct=float(config.get(\"harness_min_context_pct\") or 0.75),\n harness_max_context_pct=float(config.get(\"harness_max_context_pct\") or 0.92),\n thinking_level=tl,\n approval_mode=am,\n launch_at_login=bool(config.get(\"launch_at_login\", False)),\n start_in_tray=bool(config.get(\"start_in_tray\", False)),\n close_to_tray=bool(config.get(\"close_to_tray\", False)),\n )\n\n\ndef generate_default_config(\n home_dir: Path,\n *,\n first_run_project: Path | None = None,\n) -> str:\n \"\"\"Generate a default TOML config file content.\n\n On first-run only, *first_run_project* (New Project folder) may be written so\n Settings has a safe starter path. New chat sessions still start with no\n project (root) unless the user attaches one.\n \"\"\"\n if first_run_project is not None:\n proj_line = f'project_path = \"{Path(first_run_project).expanduser().as_posix()}\"'\n scope_line = 'access_scope = \"project\" # first-run sandbox; change to full when ready'\n else:\n proj_line = (\n '# project_path = \"\" # empty = no default; New Session = root (full access)'\n )\n scope_line = '# access_scope = \"project\" # project | home | full | untrusted'\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Optional default folder (Settings). New Session does NOT auto-attach a project —\n# sessions without a project are root (full access). Pick a project per session\n# or set a default here for tools that need a workspace root.\n{proj_line}\n{scope_line}\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Messaging channels (also Settings → Messengers). Tokens live in the secure store.\n# Desktop sessions share the same memory.db — messenger chats appear in the sidebar.\n# cli, telegram, discord, slack, mattermost, whatsapp, teams, matrix, google_chat, signal, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n# In-app Browser slide homepage (Settings → Project workspace)\n# browser_home_url = \"https://github.com/AhmiDarrow/RemedyAI\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messengers — non-secret fields only (bot tokens via Settings / secret store)\n[telegram]\nallow_chat_ids = []\nallow_all = false\n\n[discord]\nchannel_id = \"\"\nguild_id = \"\"\n\n[slack]\nchannel_id = \"\"\n\n[mattermost]\nbase_url = \"\"\nteam_id = \"\"\nchannel_id = \"\"\n\n[whatsapp]\nphone_number_id = \"\"\nallow_from = []\n\n[teams]\napp_id = \"\"\ntenant_id = \"\"\n\n[matrix]\nhomeserver = \"\"\nuser_id = \"\"\nroom_id = \"\"\n\n[google_chat]\nspace_id = \"\"\n\n[signal]\ncli_path = \"signal-cli\"\naccount = \"\"\n\n# Local visual decoder (llama-server + SmolVLM2 2.2B) — first-run download; not in installer\n[vision]\nenabled = false\nmodel_id = \"smolvlm2-2.2b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory (first run only).\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n first_run_project = None\n try:\n from remedy.core.workspace import ensure_new_project_seed\n\n # One-time: create Documents/…/New Project and record it in config.\n # Users can clear project_path later; New Session stays root either way.\n first_run_project = ensure_new_project_seed()\n except Exception:\n first_run_project = None\n config_path.write_text(\n generate_default_config(hd, first_run_project=first_run_project),\n encoding=\"utf-8\",\n )\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"POE_API_KEY\", \"poe\"),\n (\"REMEDY_POE_API_KEY\", \"poe\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "0ae802e74054d32aae1713ef426fff0b28350b06": {"t": 1785768478.7983305, "n": 52315, "h": "ac584951e141383c", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: snap foreign / garbage ids to provider default.\n # Allow native-family prefixes (deepseek-*, grok-*) for live /models extras.\n elif mid not in known and not _native_model_id_for_provider(prov, mid):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\ndef _native_model_id_for_provider(provider: str, model_id: str) -> bool:\n \"\"\"True when *model_id* looks like a native id for *provider* (not catalog-only).\"\"\"\n mid = (model_id or \"\").strip().lower()\n prov = (provider or \"\").strip().lower()\n if not mid or not prov:\n return False\n if prov == \"deepseek\":\n return mid.startswith(\"deepseek\")\n if prov == \"xai\":\n return mid.startswith(\"grok\") or mid.startswith(\"xai/\")\n if prov == \"openai\":\n return mid.startswith((\"gpt-\", \"o1\", \"o3\", \"o4\", \"chatgpt-\"))\n if prov == \"anthropic\":\n return mid.startswith(\"claude\")\n if prov == \"google\":\n return mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\")\n if prov == \"mistral\":\n return mid.startswith((\"mistral\", \"codestral\", \"open-mistral\"))\n if prov == \"groq\":\n return mid.startswith(\"groq/\") or mid in {\n m[\"id\"] for m in (PROVIDER_CATALOG.get(\"groq\") or {}).get(\"models\") or []\n }\n return False\n\n\ndef validate_provider_model(provider: str | None, model: str | None) -> str:\n \"\"\"Return a clean model id or raise ValueError for closed-catalog garbage.\n\n Flexible providers (ollama/custom/openrouter) accept any non-empty id.\n Closed providers accept catalog ids + native-family prefixes only.\n \"\"\"\n prov = (provider or \"\").strip().lower() or \"openai\"\n mid = (model or \"\").strip()\n if not mid:\n raise ValueError(\"Model id is required\")\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n if prov in _FLEXIBLE or prov not in PROVIDER_CATALOG:\n return mid\n # Apply legacy aliases first\n mid = _LEGACY_MODEL_ALIASES.get(mid.lower(), mid)\n catalog = PROVIDER_CATALOG.get(prov) or {}\n known = {m[\"id\"] for m in (catalog.get(\"models\") or [])}\n if mid in known or _native_model_id_for_provider(prov, mid):\n return mid\n sample = \", \".join(sorted(known)[:8]) or \"(none)\"\n raise ValueError(\n f\"Unknown model {mid!r} for provider {prov!r}. \"\n f\"Pick a listed model (e.g. {sample}).\"\n )\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n am = str(config.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = str(config.get(\"access_scope\") or \"project\").strip().lower() or \"project\"\n hm = str(config.get(\"harness_mode\") or \"auto\").strip().lower()\n if hm not in (\"off\", \"manual\", \"auto\"):\n hm = \"auto\"\n tl = str(config.get(\"thinking_level\") or \"high\").strip().lower()\n if tl not in (\"off\", \"low\", \"medium\", \"high\"):\n tl = \"high\"\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n # Partner trust / agency — must come from config.toml (status-bar thumbs).\n # Previously omitted → always defaulted to approval_mode=\"ask\" at startup\n # while Settings UI still showed auto from raw TOML.\n access_scope=scope,\n harness_mode=hm,\n harness_min_context_pct=float(config.get(\"harness_min_context_pct\") or 0.75),\n harness_max_context_pct=float(config.get(\"harness_max_context_pct\") or 0.92),\n thinking_level=tl,\n approval_mode=am,\n launch_at_login=bool(config.get(\"launch_at_login\", False)),\n start_in_tray=bool(config.get(\"start_in_tray\", False)),\n close_to_tray=bool(config.get(\"close_to_tray\", False)),\n )\n\n\ndef generate_default_config(\n home_dir: Path,\n *,\n first_run_project: Path | None = None,\n) -> str:\n \"\"\"Generate a default TOML config file content.\n\n On first-run only, *first_run_project* (New Project folder) may be written so\n Settings has a safe starter path. New chat sessions still start with no\n project (root) unless the user attaches one.\n \"\"\"\n if first_run_project is not None:\n proj_line = f'project_path = \"{Path(first_run_project).expanduser().as_posix()}\"'\n scope_line = 'access_scope = \"project\" # first-run sandbox; change to full when ready'\n else:\n proj_line = (\n '# project_path = \"\" # empty = no default; New Session = root (full access)'\n )\n scope_line = '# access_scope = \"project\" # project | home | full | untrusted'\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Optional default folder (Settings). New Session does NOT auto-attach a project —\n# sessions without a project are root (full access). Pick a project per session\n# or set a default here for tools that need a workspace root.\n{proj_line}\n{scope_line}\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Messaging channels (also Settings → Messengers). Tokens live in the secure store.\n# Desktop sessions share the same memory.db — messenger chats appear in the sidebar.\n# cli, telegram, discord, slack, mattermost, whatsapp, teams, matrix, google_chat, signal, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n# In-app Browser slide homepage (Settings → Project workspace)\n# browser_home_url = \"https://github.com/AhmiDarrow/RemedyAI\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messengers — non-secret fields only (bot tokens via Settings / secret store)\n[telegram]\nallow_chat_ids = []\nallow_all = false\n\n[discord]\nchannel_id = \"\"\nguild_id = \"\"\n\n[slack]\nchannel_id = \"\"\n\n[mattermost]\nbase_url = \"\"\nteam_id = \"\"\nchannel_id = \"\"\n\n[whatsapp]\nphone_number_id = \"\"\nallow_from = []\n\n[teams]\napp_id = \"\"\ntenant_id = \"\"\n\n[matrix]\nhomeserver = \"\"\nuser_id = \"\"\nroom_id = \"\"\n\n[google_chat]\nspace_id = \"\"\n\n[signal]\ncli_path = \"signal-cli\"\naccount = \"\"\n\n# Local visual decoder (llama-server + SmolVLM2 2.2B) — first-run download; not in installer\n[vision]\nenabled = false\nmodel_id = \"smolvlm2-2.2b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory (first run only).\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n first_run_project = None\n try:\n from remedy.core.workspace import ensure_new_project_seed\n\n # One-time: create Documents/…/New Project and record it in config.\n # Users can clear project_path later; New Session stays root either way.\n first_run_project = ensure_new_project_seed()\n except Exception:\n first_run_project = None\n config_path.write_text(\n generate_default_config(hd, first_run_project=first_run_project),\n encoding=\"utf-8\",\n )\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "8416e83ffde047768495d088b1d7bebb42addcd1": {"t": 1785768481.5915284, "n": 50339, "h": "a15b924ff6bf9158", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n # Allow any deepseek-* / grok-* id (endpoint may list more than fallback).\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov == \"xai\" and mid not in known and model_owner not in (None, \"xai\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n am = str(config.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = str(config.get(\"access_scope\") or \"project\").strip().lower() or \"project\"\n hm = str(config.get(\"harness_mode\") or \"auto\").strip().lower()\n if hm not in (\"off\", \"manual\", \"auto\"):\n hm = \"auto\"\n tl = str(config.get(\"thinking_level\") or \"high\").strip().lower()\n if tl not in (\"off\", \"low\", \"medium\", \"high\"):\n tl = \"high\"\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n # Partner trust / agency — must come from config.toml (status-bar thumbs).\n # Previously omitted → always defaulted to approval_mode=\"ask\" at startup\n # while Settings UI still showed auto from raw TOML.\n access_scope=scope,\n harness_mode=hm,\n harness_min_context_pct=float(config.get(\"harness_min_context_pct\") or 0.75),\n harness_max_context_pct=float(config.get(\"harness_max_context_pct\") or 0.92),\n thinking_level=tl,\n approval_mode=am,\n launch_at_login=bool(config.get(\"launch_at_login\", False)),\n start_in_tray=bool(config.get(\"start_in_tray\", False)),\n close_to_tray=bool(config.get(\"close_to_tray\", False)),\n )\n\n\ndef generate_default_config(\n home_dir: Path,\n *,\n first_run_project: Path | None = None,\n) -> str:\n \"\"\"Generate a default TOML config file content.\n\n On first-run only, *first_run_project* (New Project folder) may be written so\n Settings has a safe starter path. New chat sessions still start with no\n project (root) unless the user attaches one.\n \"\"\"\n if first_run_project is not None:\n proj_line = f'project_path = \"{Path(first_run_project).expanduser().as_posix()}\"'\n scope_line = 'access_scope = \"project\" # first-run sandbox; change to full when ready'\n else:\n proj_line = (\n '# project_path = \"\" # empty = no default; New Session = root (full access)'\n )\n scope_line = '# access_scope = \"project\" # project | home | full | untrusted'\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Optional default folder (Settings). New Session does NOT auto-attach a project —\n# sessions without a project are root (full access). Pick a project per session\n# or set a default here for tools that need a workspace root.\n{proj_line}\n{scope_line}\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Messaging channels (also Settings → Messengers). Tokens live in the secure store.\n# Desktop sessions share the same memory.db — messenger chats appear in the sidebar.\n# cli, telegram, discord, slack, mattermost, whatsapp, teams, matrix, google_chat, signal, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n# In-app Browser slide homepage (Settings → Project workspace)\n# browser_home_url = \"https://github.com/AhmiDarrow/RemedyAI\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messengers — non-secret fields only (bot tokens via Settings / secret store)\n[telegram]\nallow_chat_ids = []\nallow_all = false\n\n[discord]\nchannel_id = \"\"\nguild_id = \"\"\n\n[slack]\nchannel_id = \"\"\n\n[mattermost]\nbase_url = \"\"\nteam_id = \"\"\nchannel_id = \"\"\n\n[whatsapp]\nphone_number_id = \"\"\nallow_from = []\n\n[teams]\napp_id = \"\"\ntenant_id = \"\"\n\n[matrix]\nhomeserver = \"\"\nuser_id = \"\"\nroom_id = \"\"\n\n[google_chat]\nspace_id = \"\"\n\n[signal]\ncli_path = \"signal-cli\"\naccount = \"\"\n\n# Local visual decoder (llama-server + SmolVLM2 2.2B) — first-run download; not in installer\n[vision]\nenabled = false\nmodel_id = \"smolvlm2-2.2b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory (first run only).\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n first_run_project = None\n try:\n from remedy.core.workspace import ensure_new_project_seed\n\n # One-time: create Documents/…/New Project and record it in config.\n # Users can clear project_path later; New Session stays root either way.\n first_run_project = ensure_new_project_seed()\n except Exception:\n first_run_project = None\n config_path.write_text(\n generate_default_config(hd, first_run_project=first_run_project),\n encoding=\"utf-8\",\n )\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "5fc6f4d7c461aaa9face7fb3e78b4482b18ab1bc": {"t": 1785768483.8644443, "n": 50339, "h": "0ee29e7b3ba89662", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n # Allow any deepseek-* / grok-* id (endpoint may list more than fallback).\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov == \"xai\" and mid not in known and model_owner not in (None, \"xai\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n am = str(config.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = str(config.get(\"access_scope\") or \"project\").strip().lower() or \"project\"\n hm = str(config.get(\"harness_mode\") or \"auto\").strip().lower()\n if hm not in (\"off\", \"manual\", \"auto\"):\n hm = \"auto\"\n tl = str(config.get(\"thinking_level\") or \"high\").strip().lower()\n if tl not in (\"off\", \"low\", \"medium\", \"high\"):\n tl = \"high\"\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n # Partner trust / agency — must come from config.toml (status-bar thumbs).\n # Previously omitted → always defaulted to approval_mode=\"ask\" at startup\n # while Settings UI still showed auto from raw TOML.\n access_scope=scope,\n harness_mode=hm,\n harness_min_context_pct=float(config.get(\"harness_min_context_pct\") or 0.75),\n harness_max_context_pct=float(config.get(\"harness_max_context_pct\") or 0.92),\n thinking_level=tl,\n approval_mode=am,\n launch_at_login=bool(config.get(\"launch_at_login\", False)),\n start_in_tray=bool(config.get(\"start_in_tray\", False)),\n close_to_tray=bool(config.get(\"close_to_tray\", False)),\n )\n\n\ndef generate_default_config(\n home_dir: Path,\n *,\n first_run_project: Path | None = None,\n) -> str:\n \"\"\"Generate a default TOML config file content.\n\n On first-run only, *first_run_project* (New Project folder) may be written so\n Settings has a safe starter path. New chat sessions still start with no\n project (root) unless the user attaches one.\n \"\"\"\n if first_run_project is not None:\n proj_line = f'project_path = \"{Path(first_run_project).expanduser().as_posix()}\"'\n scope_line = 'access_scope = \"project\" # first-run sandbox; change to full when ready'\n else:\n proj_line = (\n '# project_path = \"\" # empty = no default; New Session = root (full access)'\n )\n scope_line = '# access_scope = \"project\" # project | home | full | untrusted'\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Optional default folder (Settings). New Session does NOT auto-attach a project —\n# sessions without a project are root (full access). Pick a project per session\n# or set a default here for tools that need a workspace root.\n{proj_line}\n{scope_line}\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Messaging channels (also Settings → Messengers). Tokens live in the secure store.\n# Desktop sessions share the same memory.db — messenger chats appear in the sidebar.\n# cli, telegram, discord, slack, mattermost, whatsapp, teams, matrix, google_chat, signal, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n# In-app Browser slide homepage (Settings → Project workspace)\n# browser_home_url = \"https://github.com/AhmiDarrow/RemedyAI\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messengers — non-secret fields only (bot tokens via Settings / secret store)\n[telegram]\nallow_chat_ids = []\nallow_all = false\n\n[discord]\nchannel_id = \"\"\nguild_id = \"\"\n\n[slack]\nchannel_id = \"\"\n\n[mattermost]\nbase_url = \"\"\nteam_id = \"\"\nchannel_id = \"\"\n\n[whatsapp]\nphone_number_id = \"\"\nallow_from = []\n\n[teams]\napp_id = \"\"\ntenant_id = \"\"\n\n[matrix]\nhomeserver = \"\"\nuser_id = \"\"\nroom_id = \"\"\n\n[google_chat]\nspace_id = \"\"\n\n[signal]\ncli_path = \"signal-cli\"\naccount = \"\"\n\n# Local visual decoder (llama-server + Qwen2.5-VL 3B) — opt-in; not downloaded until enabled\n[vision]\nenabled = false\nmodel_id = \"qwen2.5-vl-3b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory (first run only).\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n first_run_project = None\n try:\n from remedy.core.workspace import ensure_new_project_seed\n\n # One-time: create Documents/…/New Project and record it in config.\n # Users can clear project_path later; New Session stays root either way.\n first_run_project = ensure_new_project_seed()\n except Exception:\n first_run_project = None\n config_path.write_text(\n generate_default_config(hd, first_run_project=first_run_project),\n encoding=\"utf-8\",\n )\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "f97432728fe20905f8cc2187b2d6bbe638c7f9a5": {"t": 1785768485.6616473, "n": 49902, "h": "99a261eed282f9f5", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n # Allow any deepseek-* / grok-* id (endpoint may list more than fallback).\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov == \"xai\" and mid not in known and model_owner not in (None, \"xai\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n am = str(config.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = str(config.get(\"access_scope\") or \"project\").strip().lower() or \"project\"\n hm = str(config.get(\"harness_mode\") or \"auto\").strip().lower()\n if hm not in (\"off\", \"manual\", \"auto\"):\n hm = \"auto\"\n tl = str(config.get(\"thinking_level\") or \"high\").strip().lower()\n if tl not in (\"off\", \"low\", \"medium\", \"high\"):\n tl = \"high\"\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n # Partner trust / agency — must come from config.toml (status-bar thumbs).\n # Previously omitted → always defaulted to approval_mode=\"ask\" at startup\n # while Settings UI still showed auto from raw TOML.\n access_scope=scope,\n harness_mode=hm,\n harness_min_context_pct=float(config.get(\"harness_min_context_pct\") or 0.75),\n harness_max_context_pct=float(config.get(\"harness_max_context_pct\") or 0.92),\n thinking_level=tl,\n approval_mode=am,\n launch_at_login=bool(config.get(\"launch_at_login\", False)),\n start_in_tray=bool(config.get(\"start_in_tray\", False)),\n close_to_tray=bool(config.get(\"close_to_tray\", False)),\n )\n\n\ndef generate_default_config(\n home_dir: Path,\n *,\n first_run_project: Path | None = None,\n) -> str:\n \"\"\"Generate a default TOML config file content.\n\n On first-run only, *first_run_project* (New Project folder) may be written so\n Settings has a safe starter path. New chat sessions still start with no\n project (root) unless the user attaches one.\n \"\"\"\n if first_run_project is not None:\n proj_line = f'project_path = \"{Path(first_run_project).expanduser().as_posix()}\"'\n scope_line = 'access_scope = \"project\" # first-run sandbox; change to full when ready'\n else:\n proj_line = (\n '# project_path = \"\" # empty = no default; New Session = root (full access)'\n )\n scope_line = '# access_scope = \"project\" # project | home | full | untrusted'\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Optional default folder (Settings). New Session does NOT auto-attach a project —\n# sessions without a project are root (full access). Pick a project per session\n# or set a default here for tools that need a workspace root.\n{proj_line}\n{scope_line}\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Channels (CLI/config only today — desktop chat uses the local API):\n# cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n# In-app Browser slide homepage (Settings → Project workspace)\n# browser_home_url = \"https://github.com/AhmiDarrow/RemedyAI\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messaging channels — tokens via env/config only (not full desktop UI yet)\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\n# Local visual decoder (llama-server + Qwen2.5-VL 3B) — opt-in; not downloaded until enabled\n[vision]\nenabled = false\nmodel_id = \"qwen2.5-vl-3b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory (first run only).\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n first_run_project = None\n try:\n from remedy.core.workspace import ensure_new_project_seed\n\n # One-time: create Documents/…/New Project and record it in config.\n # Users can clear project_path later; New Session stays root either way.\n first_run_project = ensure_new_project_seed()\n except Exception:\n first_run_project = None\n config_path.write_text(\n generate_default_config(hd, first_run_project=first_run_project),\n encoding=\"utf-8\",\n )\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "7d69e803905f257e39469a2f88010adf6611cbd8": {"t": 1785768487.2528439, "n": 49910, "h": "3cbfe5414d7454dd", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n # Allow any deepseek-* / grok-* id (endpoint may list more than fallback).\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov == \"xai\" and mid not in known and model_owner not in (None, \"xai\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n am = str(config.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = str(config.get(\"access_scope\") or \"project\").strip().lower() or \"project\"\n hm = str(config.get(\"harness_mode\") or \"auto\").strip().lower()\n if hm not in (\"off\", \"manual\", \"auto\"):\n hm = \"auto\"\n tl = str(config.get(\"thinking_level\") or \"high\").strip().lower()\n if tl not in (\"off\", \"low\", \"medium\", \"high\"):\n tl = \"high\"\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n # Partner trust / agency — must come from config.toml (status-bar thumbs).\n # Previously omitted → always defaulted to approval_mode=\"ask\" at startup\n # while Settings UI still showed auto from raw TOML.\n access_scope=scope,\n harness_mode=hm,\n harness_min_context_pct=float(config.get(\"harness_min_context_pct\") or 0.75),\n harness_max_context_pct=float(config.get(\"harness_max_context_pct\") or 0.92),\n thinking_level=tl,\n approval_mode=am,\n launch_at_login=bool(config.get(\"launch_at_login\", False)),\n start_in_tray=bool(config.get(\"start_in_tray\", False)),\n close_to_tray=bool(config.get(\"close_to_tray\", False)),\n )\n\n\ndef generate_default_config(\n home_dir: Path,\n *,\n first_run_project: Path | None = None,\n) -> str:\n \"\"\"Generate a default TOML config file content.\n\n On first-run only, *first_run_project* (New Project folder) may be written so\n Settings has a safe starter path. New chat sessions still start with no\n project (root) unless the user attaches one.\n \"\"\"\n if first_run_project is not None:\n proj_line = f'project_path = \"{Path(first_run_project).expanduser().as_posix()}\"'\n scope_line = 'access_scope = \"project\" # first-run sandbox; change to full when ready'\n else:\n proj_line = (\n '# project_path = \"\" # empty = no default; New Session = root (full access)'\n )\n scope_line = '# access_scope = \"project\" # project | home | full | untrusted'\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Optional default folder (Settings). New Session does NOT auto-attach a project —\n# sessions without a project are root (full access). Pick a project per session\n# or set a default here for tools that need a workspace root.\n{proj_line}\n{scope_line}\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Channels (CLI/config only today — desktop chat uses the local API):\n# cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full | full+\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n# In-app Browser slide homepage (Settings → Project workspace)\n# browser_home_url = \"https://github.com/AhmiDarrow/RemedyAI\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messaging channels — tokens via env/config only (not full desktop UI yet)\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\n# Local visual decoder (llama-server + Qwen2.5-VL 3B) — opt-in; not downloaded until enabled\n[vision]\nenabled = false\nmodel_id = \"qwen2.5-vl-3b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory (first run only).\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n first_run_project = None\n try:\n from remedy.core.workspace import ensure_new_project_seed\n\n # One-time: create Documents/…/New Project and record it in config.\n # Users can clear project_path later; New Session stays root either way.\n first_run_project = ensure_new_project_seed()\n except Exception:\n first_run_project = None\n config_path.write_text(\n generate_default_config(hd, first_run_project=first_run_project),\n encoding=\"utf-8\",\n )\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "50cb911b508261f9d88c82c6399bfc355d976679": {"t": 1785768488.853604, "n": 49784, "h": "05a5801c088222ea", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n # Allow any deepseek-* / grok-* id (endpoint may list more than fallback).\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov == \"xai\" and mid not in known and model_owner not in (None, \"xai\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n am = str(config.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = str(config.get(\"access_scope\") or \"project\").strip().lower() or \"project\"\n hm = str(config.get(\"harness_mode\") or \"auto\").strip().lower()\n if hm not in (\"off\", \"manual\", \"auto\"):\n hm = \"auto\"\n tl = str(config.get(\"thinking_level\") or \"high\").strip().lower()\n if tl not in (\"off\", \"low\", \"medium\", \"high\"):\n tl = \"high\"\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n # Partner trust / agency — must come from config.toml (status-bar thumbs).\n # Previously omitted → always defaulted to approval_mode=\"ask\" at startup\n # while Settings UI still showed auto from raw TOML.\n access_scope=scope,\n harness_mode=hm,\n harness_min_context_pct=float(config.get(\"harness_min_context_pct\") or 0.75),\n harness_max_context_pct=float(config.get(\"harness_max_context_pct\") or 0.92),\n thinking_level=tl,\n approval_mode=am,\n launch_at_login=bool(config.get(\"launch_at_login\", False)),\n start_in_tray=bool(config.get(\"start_in_tray\", False)),\n close_to_tray=bool(config.get(\"close_to_tray\", False)),\n )\n\n\ndef generate_default_config(\n home_dir: Path,\n *,\n first_run_project: Path | None = None,\n) -> str:\n \"\"\"Generate a default TOML config file content.\n\n On first-run only, *first_run_project* (New Project folder) may be written so\n Settings has a safe starter path. New chat sessions still start with no\n project (root) unless the user attaches one.\n \"\"\"\n if first_run_project is not None:\n proj_line = f'project_path = \"{Path(first_run_project).expanduser().as_posix()}\"'\n scope_line = 'access_scope = \"project\" # first-run sandbox; change to full when ready'\n else:\n proj_line = (\n '# project_path = \"\" # empty = no default; New Session = root (full access)'\n )\n scope_line = '# access_scope = \"project\" # project | home | full | untrusted'\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Optional default folder (Settings). New Session does NOT auto-attach a project —\n# sessions without a project are root (full access). Pick a project per session\n# or set a default here for tools that need a workspace root.\n{proj_line}\n{scope_line}\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Channels (CLI/config only today — desktop chat uses the local API):\n# cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full | full+\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messaging channels — tokens via env/config only (not full desktop UI yet)\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\n# Local visual decoder (llama-server + Qwen2.5-VL 3B) — opt-in; not downloaded until enabled\n[vision]\nenabled = false\nmodel_id = \"qwen2.5-vl-3b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory (first run only).\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n first_run_project = None\n try:\n from remedy.core.workspace import ensure_new_project_seed\n\n # One-time: create Documents/…/New Project and record it in config.\n # Users can clear project_path later; New Session stays root either way.\n first_run_project = ensure_new_project_seed()\n except Exception:\n first_run_project = None\n config_path.write_text(\n generate_default_config(hd, first_run_project=first_run_project),\n encoding=\"utf-8\",\n )\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "1e6b07cdf7531df4c714fa2df479f4363521131d": {"t": 1785768492.0417457, "n": 48862, "h": "5ca9ffe80b08b44a", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n # Allow any deepseek-* / grok-* id (endpoint may list more than fallback).\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov == \"xai\" and mid not in known and model_owner not in (None, \"xai\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n am = str(config.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = str(config.get(\"access_scope\") or \"project\").strip().lower() or \"project\"\n hm = str(config.get(\"harness_mode\") or \"auto\").strip().lower()\n if hm not in (\"off\", \"manual\", \"auto\"):\n hm = \"auto\"\n tl = str(config.get(\"thinking_level\") or \"high\").strip().lower()\n if tl not in (\"off\", \"low\", \"medium\", \"high\"):\n tl = \"high\"\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n # Partner trust / agency — must come from config.toml (status-bar thumbs).\n # Previously omitted → always defaulted to approval_mode=\"ask\" at startup\n # while Settings UI still showed auto from raw TOML.\n access_scope=scope,\n harness_mode=hm,\n harness_min_context_pct=float(config.get(\"harness_min_context_pct\") or 0.75),\n harness_max_context_pct=float(config.get(\"harness_max_context_pct\") or 0.92),\n thinking_level=tl,\n approval_mode=am,\n launch_at_login=bool(config.get(\"launch_at_login\", False)),\n start_in_tray=bool(config.get(\"start_in_tray\", False)),\n close_to_tray=bool(config.get(\"close_to_tray\", False)),\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# Default workspace: sandboxed New Project (not install dir / process cwd).\n# Remedy can use full machine access via access_scope; New Project is the safe start.\n# project_path = \"~/Documents/Remedy Projects/New Project\"\n# access_scope = \"project\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# Empty / unset → full access on this PC (home as default root); pick a folder for coding.\n# project_path = \"C:/Users/You/Projects/MyApp\"\n# access_scope = \"project\" # project | home | full | untrusted\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Channels (CLI/config only today — desktop chat uses the local API):\n# cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full | full+\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messaging channels — tokens via env/config only (not full desktop UI yet)\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\n# Local visual decoder (llama-server + Qwen2.5-VL 3B) — opt-in; not downloaded until enabled\n[vision]\nenabled = false\nmodel_id = \"qwen2.5-vl-3b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "617d907b8420f4ac351487444dffa16ac1f69752": {"t": 1785768494.0417795, "n": 48613, "h": "31d9028379965ef5", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n # Allow any deepseek-* / grok-* id (endpoint may list more than fallback).\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov == \"xai\" and mid not in known and model_owner not in (None, \"xai\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n am = str(config.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = str(config.get(\"access_scope\") or \"project\").strip().lower() or \"project\"\n hm = str(config.get(\"harness_mode\") or \"auto\").strip().lower()\n if hm not in (\"off\", \"manual\", \"auto\"):\n hm = \"auto\"\n tl = str(config.get(\"thinking_level\") or \"high\").strip().lower()\n if tl not in (\"off\", \"low\", \"medium\", \"high\"):\n tl = \"high\"\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n # Partner trust / agency — must come from config.toml (status-bar thumbs).\n # Previously omitted → always defaulted to approval_mode=\"ask\" at startup\n # while Settings UI still showed auto from raw TOML.\n access_scope=scope,\n harness_mode=hm,\n harness_min_context_pct=float(config.get(\"harness_min_context_pct\") or 0.75),\n harness_max_context_pct=float(config.get(\"harness_max_context_pct\") or 0.92),\n thinking_level=tl,\n approval_mode=am,\n launch_at_login=bool(config.get(\"launch_at_login\", False)),\n start_in_tray=bool(config.get(\"start_in_tray\", False)),\n close_to_tray=bool(config.get(\"close_to_tray\", False)),\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# Empty / unset → full access on this PC (home as default root); pick a folder for coding.\n# project_path = \"C:/Users/You/Projects/MyApp\"\n# access_scope = \"project\" # project | home | full | untrusted\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Channels (CLI/config only today — desktop chat uses the local API):\n# cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full | full+\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messaging channels — tokens via env/config only (not full desktop UI yet)\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\n# Local visual decoder (llama-server + Qwen2.5-VL 3B) — opt-in; not downloaded until enabled\n[vision]\nenabled = false\nmodel_id = \"qwen2.5-vl-3b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "aea34a6f6713f6a8e4d10ba8b53ecdcea237f21d": {"t": 1785768498.7202494, "n": 47435, "h": "21b32e44adefa542", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n # Allow any deepseek-* / grok-* id (endpoint may list more than fallback).\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov == \"xai\" and mid not in known and model_owner not in (None, \"xai\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n# Owner's manual (desktop F1) and LICENSE / COMMERCIAL.md describe free-tier terms.\n\nname = \"Remedy\"\n# What Remedy calls you (desktop Settings → You & Agent)\n# user_name = \"You\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# Empty / unset → full access on this PC (home as default root); pick a folder for coding.\n# project_path = \"C:/Users/You/Projects/MyApp\"\n# access_scope = \"project\" # project | home | full | untrusted\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - prefer secure store (desktop Settings) or REMEDY_LLM_API_KEY env:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Soft cap for skills in the hot catalog (Settings → Provider catalog)\n# skills_active_budget = 80\n\n# Channels (CLI/config only today — desktop chat uses the local API):\n# cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP *client* server list (advanced). Desktop also exposes Remedy as MCP *host*.\nmcp_servers = []\n\n# Learning loop / partner skills\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging: DEBUG | INFO | WARNING | ERROR\nlog_level = \"INFO\"\n\n# Optional tone flag (Settings → Advanced); default off\nsarcasm_mode = false\n\n# --- Partner controls (also on status bar / Settings → Security & power) ---\n# approval_mode = \"ask\" # ask (safe default) | auto (work-until-done, full owner power)\n# thinking_level = \"high\" # off | low | medium | high\n# tool_process = \"off\" # off | medium | full | full+\n# web_tools_enabled = false # opt-in public web_fetch (SSRF-guarded)\n# http_bootstrap = true # browser loopback token; desktop prefers IPC\n\n# Memory harness (chat compression)\n# harness_mode = \"auto\" # off | manual | auto\n# harness_min_context_pct = 0.75\n# harness_max_context_pct = 0.92\n\n# Desktop always-ready (also in Settings / desktop.json)\n# launch_at_login = false\n# start_in_tray = false\n# close_to_tray = true\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n# Messaging channels — tokens via env/config only (not full desktop UI yet)\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\n# Local visual decoder (llama-server + Qwen2.5-VL 3B) — opt-in; not downloaded until enabled\n[vision]\nenabled = false\nmodel_id = \"qwen2.5-vl-3b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "cb990325d6f596b560d2c912dedafcbdc9828c72": {"t": 1785768501.06324, "n": 46079, "h": "613fcc5a2040aef6", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n # Allow any deepseek-* / grok-* id (endpoint may list more than fallback).\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov == \"xai\" and mid not in known and model_owner not in (None, \"xai\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# Empty / unset → full access on this PC (home as default root); pick a folder for coding.\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\n# Local visual decoder (llama-server + Qwen2.5-VL 3B) — opt-in; not downloaded until enabled\n[vision]\nenabled = false\nmodel_id = \"qwen2.5-vl-3b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "7b1ab22ac117bd41e631944b816239b400108542": {"t": 1785768503.031895, "n": 45988, "h": "8ce960a9feed2ef3", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n # deepseek-chat / deepseek-reasoner retired 2026-07-24 → V4 ids.\n \"models\": [\n {\"id\": \"deepseek-v4-flash\", \"name\": \"DeepSeek V4 Flash\", \"vision\": False},\n {\"id\": \"deepseek-v4-pro\", \"name\": \"DeepSeek V4 Pro\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n # Fallback only — live GET /models is preferred when a key is present.\n \"models\": [\n {\"id\": \"grok-4.5\", \"name\": \"Grok 4.5\", \"vision\": True},\n {\"id\": \"grok-4.3\", \"name\": \"Grok 4.3\", \"vision\": True},\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\n# Retired / renamed API model ids → current ids (before live discovery or chat).\n# Endpoint discovery is preferred; these keep saved config working offline.\n_LEGACY_MODEL_ALIASES: dict[str, str] = {\n # DeepSeek: chat/reasoner retired 2026-07-24 (mapped to V4 Flash modes).\n \"deepseek-chat\": \"deepseek-v4-flash\",\n \"deepseek-reasoner\": \"deepseek-v4-flash\",\n \"deepseek-coder\": \"deepseek-v4-flash\",\n # xAI: older Grok slugs retired / redirected; prefer current flagship family.\n \"grok-3\": \"grok-4.3\",\n \"grok-3-mini\": \"grok-4.3\",\n \"grok-3-fast\": \"grok-4.3\",\n \"grok-3-mini-fast\": \"grok-4.3\",\n \"grok-2\": \"grok-4.3\",\n \"grok-2-1212\": \"grok-4.3\",\n \"grok-2-vision-1212\": \"grok-4.5\",\n \"grok-2-vision\": \"grok-4.5\",\n \"grok-beta\": \"grok-4.5\",\n \"grok-vision-beta\": \"grok-4.5\",\n}\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-v4-flash\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n - provider=deepseek, model=deepseek-chat → deepseek-v4-flash (retired id)\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # Map retired provider model ids before ownership checks.\n alias = _LEGACY_MODEL_ALIASES.get(mid.lower())\n if alias:\n mid = alias\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n # Allow any deepseek-* / grok-* id (endpoint may list more than fallback).\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov == \"xai\" and mid not in known and model_owner not in (None, \"xai\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\n# Local visual decoder (llama-server + Qwen2.5-VL 3B) — opt-in; not downloaded until enabled\n[vision]\nenabled = false\nmodel_id = \"qwen2.5-vl-3b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "cea979097963f329cbbc64d3880e08da12190469": {"t": 1785768504.5144699, "n": 44666, "h": "9fe447fd01ada007", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\", \"vision\": False},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n \"models\": [\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n {\"id\": \"grok-3\", \"name\": \"Grok 3\", \"vision\": False},\n {\"id\": \"grok-3-mini\", \"name\": \"Grok 3 Mini\", \"vision\": False},\n {\"id\": \"grok-2\", \"name\": \"Grok 2\", \"vision\": False},\n {\"id\": \"grok-2-vision-1212\", \"name\": \"Grok 2 Vision\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\n# Local visual decoder (llama-server + Qwen2.5-VL 3B) — opt-in; not downloaded until enabled\n[vision]\nenabled = false\nmodel_id = \"qwen2.5-vl-3b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n return os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() not in (\n \"1\",\n \"true\",\n \"yes\",\n )\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "c43b7ddceab4c127b221962a4e49032bae1b8848": {"t": 1785768506.235734, "n": 44704, "h": "f031feeac4559b8f", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Demo / guest gateways need a non-empty OpenAI-style bearer; no real secret.\n if provider == \"demo\":\n return DEMO_DUMMY_API_KEY\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n# free_tier: none | free_key | local | instant\n# free_key = permanent free tier with free signup key (no card typical)\n# local = runs on-device (Ollama)\n# instant = no signup / no real key (guest demo)\n\nDEMO_DUMMY_API_KEY = \"unused\"\nDEMO_BASE_URL = \"https://api.llm7.io/v1\"\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"demo\": {\n \"label\": \"Demo (free, no signup)\",\n \"base_url\": DEMO_BASE_URL,\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"instant\",\n \"badge\": \"No signup\",\n \"limits_blurb\": \"Rate-limited guest gateway (third-party). Fine to try Remedy; add a real provider for serious work.\",\n \"privacy_note\": \"Chat is sent to a free third-party API (not Remedy cloud). Prefer Ollama for private local use.\",\n \"key_docs_url\": \"https://llm7.io/\",\n \"models\": [\n {\"id\": \"codestral-latest\", \"name\": \"Codestral (demo)\", \"vision\": False},\n {\"id\": \"gpt-oss:20b\", \"name\": \"GPT-OSS 20B (demo)\", \"vision\": False},\n ],\n },\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.openai.com/api-keys\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.anthropic.com/settings/keys\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI (Gemini)\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Generous free tier via AI Studio (region limits may apply).\",\n \"key_docs_url\": \"https://aistudio.google.com/app/apikey\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://platform.deepseek.com/api_keys\",\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\", \"vision\": False},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"none\",\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n \"models\": [\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n {\"id\": \"grok-3\", \"name\": \"Grok 3\", \"vision\": False},\n {\"id\": \"grok-3-mini\", \"name\": \"Grok 3 Mini\", \"vision\": False},\n {\"id\": \"grok-2\", \"name\": \"Grok 2\", \"vision\": False},\n {\"id\": \"grok-2-vision-1212\", \"name\": \"Grok 2 Vision\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Very fast free tier — great for trying agent tools.\",\n \"key_docs_url\": \"https://console.groq.com/keys\",\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Free Experiment plan (no card typical).\",\n \"key_docs_url\": \"https://console.mistral.ai/api-keys\",\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"free_tier\": \"free_key\",\n \"badge\": \"Free key\",\n \"limits_blurb\": \"Many free models (ids ending in :free). One key, many backends.\",\n \"key_docs_url\": \"https://openrouter.ai/keys\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-oss-20b:free\", \"name\": \"GPT-OSS 20B (free)\"},\n {\"id\": \"meta-llama/llama-3.3-70b-instruct:free\", \"name\": \"Llama 3.3 70B (free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Gemma 3 27B (free)\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"free_tier\": \"local\",\n \"badge\": \"Local\",\n \"limits_blurb\": \"Fully free on your machine. Install Ollama and pull a model.\",\n \"key_docs_url\": \"https://ollama.com/download\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"free_tier\": \"none\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Ordered free options for Setup / Settings UI (provider id → display meta).\nFREE_PROVIDER_OPTIONS: list[dict[str, Any]] = [\n {\n \"id\": \"demo\",\n \"tier\": \"instant\",\n \"title\": \"Start instantly (Demo)\",\n \"blurb\": \"No account. Limited rate/quality. Chat leaves your PC to a free third-party gateway.\",\n },\n {\n \"id\": \"google\",\n \"tier\": \"free_key\",\n \"title\": \"Google Gemini\",\n \"blurb\": \"Free AI Studio key — strong free multimodal models.\",\n },\n {\n \"id\": \"groq\",\n \"tier\": \"free_key\",\n \"title\": \"Groq\",\n \"blurb\": \"Free key — very fast open models.\",\n },\n {\n \"id\": \"openrouter\",\n \"tier\": \"free_key\",\n \"title\": \"OpenRouter free models\",\n \"blurb\": \"Free key — pick models with :free suffix.\",\n },\n {\n \"id\": \"mistral\",\n \"tier\": \"free_key\",\n \"title\": \"Mistral\",\n \"blurb\": \"Free Experiment plan key.\",\n },\n {\n \"id\": \"ollama\",\n \"tier\": \"local\",\n \"title\": \"Ollama (on this PC)\",\n \"blurb\": \"Install locally for private, unlimited free use.\",\n },\n]\n\n\ndef free_options_public() -> list[dict[str, Any]]:\n \"\"\"Public free-options list for desktop Setup/Settings.\"\"\"\n out: list[dict[str, Any]] = []\n for item in FREE_PROVIDER_OPTIONS:\n pid = str(item[\"id\"])\n meta = PROVIDER_CATALOG.get(pid) or {}\n models = list(meta.get(\"models\") or [])\n out.append(\n {\n \"id\": pid,\n \"tier\": item.get(\"tier\"),\n \"title\": item.get(\"title\") or meta.get(\"label\") or pid,\n \"blurb\": item.get(\"blurb\") or meta.get(\"limits_blurb\") or \"\",\n \"badge\": meta.get(\"badge\"),\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"auth\": list(meta.get(\"auth\") or [\"api_key\"]),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n \"default_model\": str(models[0][\"id\"]) if models else \"default\",\n \"models\": models,\n \"free_tier\": meta.get(\"free_tier\") or item.get(\"tier\"),\n }\n )\n return out\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\", \"demo\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\n# Local visual decoder (llama-server + Qwen2.5-VL 3B) — opt-in; not downloaded until enabled\n[vision]\nenabled = false\nmodel_id = \"qwen2.5-vl-3b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider == \"ollama\":\n return True\n if provider == \"demo\":\n # Air-gapped / enterprise builds can disable guest demo.\n if os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower() in (\n \"1\",\n \"true\",\n \"yes\",\n ):\n return False\n return True\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid in (\"ollama\", \"demo\"):\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n \"free_tier\": meta.get(\"free_tier\") or \"none\",\n \"badge\": meta.get(\"badge\"),\n \"limits_blurb\": meta.get(\"limits_blurb\"),\n \"privacy_note\": meta.get(\"privacy_note\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n # Zero-setup demo so first run can chat without any keys (opt-out via env).\n if (\n allow_switch\n and os.environ.get(\"REMEDY_DEMO_DISABLED\", \"\").strip().lower()\n not in (\"1\", \"true\", \"yes\")\n and os.environ.get(\"REMEDY_PREFER_DEMO\", \"1\").strip().lower()\n not in (\"0\", \"false\", \"no\")\n ):\n prov, model, url = normalize_llm_settings(\n \"demo\",\n cfg.get(\"llm_model\") if str(cfg.get(\"llm_provider\") or \"\") == \"demo\" else None,\n None,\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "1ca5950e9468ad5afaa5fd18239ff79e64cc74a2": {"t": 1785768507.8425207, "n": 38648, "h": "4897d3a7ca3e989b", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\", \"vision\": True},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\", \"vision\": True},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\", \"vision\": True},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\", \"vision\": False},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\", \"vision\": True},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\", \"vision\": True},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\", \"vision\": True},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\", \"vision\": True},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\", \"vision\": True},\n ],\n },\n \"google\": {\n \"label\": \"Google AI\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\", \"vision\": True},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\", \"vision\": True},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\", \"vision\": True},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\", \"vision\": False},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\", \"vision\": False},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n \"models\": [\n {\"id\": \"grok-4\", \"name\": \"Grok 4\", \"vision\": True},\n {\"id\": \"grok-3\", \"name\": \"Grok 3\", \"vision\": False},\n {\"id\": \"grok-3-mini\", \"name\": \"Grok 3 Mini\", \"vision\": False},\n {\"id\": \"grok-2\", \"name\": \"Grok 2\", \"vision\": False},\n {\"id\": \"grok-2-vision-1212\", \"name\": \"Grok 2 Vision\", \"vision\": True},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\", \"vision\": False},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\", \"vision\": False},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\", \"vision\": False},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\", \"vision\": False},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\", \"vision\": False},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\", \"vision\": False},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n {\"id\": \"anthropic/claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (via OpenRouter)\"},\n {\"id\": \"google/gemini-2.0-flash-001\", \"name\": \"Gemini 2.0 Flash (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\n# Local visual decoder (llama-server + Qwen2.5-VL 3B) — opt-in; not downloaded until enabled\n[vision]\nenabled = false\nmodel_id = \"qwen2.5-vl-3b\"\n# Prefer local image→text even when the chat model has native vision (saves provider tokens)\nforce_decode = false\nhost = \"127.0.0.1\"\nport = 8740\nauto_start = true\nidle_stop_s = 600\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider in (\"ollama\",):\n return True\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid == \"ollama\":\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "3e3fc809fdb4d11b293b93971ed456e834edfe92": {"t": 1785768513.2972457, "n": 37909, "h": "f649c6683fce9d25", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\"},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\"},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\"},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\"},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\"},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\"},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\"},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\"},\n ],\n },\n \"google\": {\n \"label\": \"Google AI\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\"},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\"},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\"},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\"},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\"},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n \"models\": [\n {\"id\": \"grok-4\", \"name\": \"Grok 4\"},\n {\"id\": \"grok-3\", \"name\": \"Grok 3\"},\n {\"id\": \"grok-3-mini\", \"name\": \"Grok 3 Mini\"},\n {\"id\": \"grok-2\", \"name\": \"Grok 2\"},\n {\"id\": \"grok-2-vision-1212\", \"name\": \"Grok 2 Vision\"},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\"},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\"},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\"},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\"},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\"},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\"},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n {\"id\": \"anthropic/claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (via OpenRouter)\"},\n {\"id\": \"google/gemini-2.0-flash-001\", \"name\": \"Gemini 2.0 Flash (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Shared writer: scalars before tables + secret scrub (single code path).\n from remedy.interfaces.api_support import _write_config as api_write\n\n api_write(path, cfg)\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider in (\"ollama\",):\n return True\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid == \"ollama\":\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "c139fe275f0e456966de29abde0a3fe64197ed91": {"t": 1785768514.9065142, "n": 38795, "h": "12ecb25158e11e4f", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\"},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\"},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\"},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\"},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\"},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\"},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\"},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\"},\n ],\n },\n \"google\": {\n \"label\": \"Google AI\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\"},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\"},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\"},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\"},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\"},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n \"models\": [\n {\"id\": \"grok-4\", \"name\": \"Grok 4\"},\n {\"id\": \"grok-3\", \"name\": \"Grok 3\"},\n {\"id\": \"grok-3-mini\", \"name\": \"Grok 3 Mini\"},\n {\"id\": \"grok-2\", \"name\": \"Grok 2\"},\n {\"id\": \"grok-2-vision-1212\", \"name\": \"Grok 2 Vision\"},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\"},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\"},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\"},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\"},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\"},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\"},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n {\"id\": \"anthropic/claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (via OpenRouter)\"},\n {\"id\": \"google/gemini-2.0-flash-001\", \"name\": \"Gemini 2.0 Flash (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef config_file_readable(config_path: Path | None = None) -> bool:\n \"\"\"True when config.toml exists and parses as a non-empty TOML table.\n\n Corrupt TOML (e.g. root keys written after ``[table]`` sections) fails parse\n and must be treated as first-run / repair territory.\n \"\"\"\n path = Path(config_path or config_path_for_home()).expanduser()\n if not path.exists():\n return False\n try:\n content = path.read_text(encoding=\"utf-8\")\n if path.suffix in (\".yaml\", \".yml\"):\n data = yaml.safe_load(content) or {}\n else:\n data = tomllib.loads(content)\n return isinstance(data, dict)\n except Exception:\n return False\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - Config unreadable / corrupt → need setup (repair via wizard)\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n # Corrupt file: load_config returns {} and would look like a \"legacy\" install\n # without setup_completed — force the wizard so users are not stuck.\n if not config_file_readable(path):\n return True\n\n cfg = config if config is not None else load_config(path)\n # Empty parse result on an existing file is also first-run territory.\n if not cfg and path.exists():\n return True\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Minimal TOML writer: ALL root scalars first, THEN [table] sections.\n # Scalars after a table are absorbed into that table (TOML rule) and can\n # corrupt the file (duplicate keys / unreadable config).\n lines = [\"# Remedy AI Configuration\", \"\"]\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in cfg.items():\n if value is None:\n # Omit null keys instead of writing misleading empty strings.\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_toml_scalar(value)}\")\n if scalars and tables:\n lines.append(\"\")\n for key, value in tables:\n lines.append(f\"[{key}]\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_toml_scalar(v)}\")\n lines.append(\"\")\n path.write_text(\"\\n\".join(lines) + \"\\n\", encoding=\"utf-8\")\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider in (\"ollama\",):\n return True\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid == \"ollama\":\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "eb1c66a02a66f567490491abe142cbc85b576438": {"t": 1785768516.5932817, "n": 37300, "h": "a64d332e7bbb8857", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n return bool(k.startswith(\"eyJ\") and k.count(\".\") >= 2)\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\"},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\"},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\"},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\"},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\"},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\"},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\"},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\"},\n ],\n },\n \"google\": {\n \"label\": \"Google AI\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\"},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\"},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\"},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\"},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\"},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n \"models\": [\n {\"id\": \"grok-4\", \"name\": \"Grok 4\"},\n {\"id\": \"grok-3\", \"name\": \"Grok 3\"},\n {\"id\": \"grok-3-mini\", \"name\": \"Grok 3 Mini\"},\n {\"id\": \"grok-2\", \"name\": \"Grok 2\"},\n {\"id\": \"grok-2-vision-1212\", \"name\": \"Grok 2 Vision\"},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\"},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\"},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\"},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\"},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\"},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\"},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n {\"id\": \"anthropic/claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (via OpenRouter)\"},\n {\"id\": \"google/gemini-2.0-flash-001\", \"name\": \"Gemini 2.0 Flash (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n cfg = config if config is not None else load_config(path)\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Minimal TOML writer (top-level scalars only + nested dicts as sections).\n lines = [\"# Remedy AI Configuration\", \"\"]\n for key, value in cfg.items():\n if value is None:\n # Omit null keys instead of writing misleading empty strings.\n continue\n if isinstance(value, dict):\n lines.append(f\"[{key}]\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_toml_scalar(v)}\")\n lines.append(\"\")\n else:\n lines.append(f\"{key} = {_toml_scalar(value)}\")\n path.write_text(\"\\n\".join(lines) + \"\\n\", encoding=\"utf-8\")\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider in (\"ollama\",):\n return True\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid == \"ollama\":\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "cd6c410b2cc67f89af0f2b59abbb40188fac3bb1": {"t": 1785768518.041603, "n": 37328, "h": "33c31eac102ce2a5", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n# Env vars that can supply a key for a given provider (checked after provider_keys).\n_PROVIDER_ENV_KEYS: dict[str, tuple[str, ...]] = {\n \"openai\": (\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"),\n \"anthropic\": (\"ANTHROPIC_API_KEY\",),\n \"google\": (\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n \"deepseek\": (\"DEEPSEEK_API_KEY\",),\n \"xai\": (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"),\n \"groq\": (\"GROQ_API_KEY\",),\n \"mistral\": (\"MISTRAL_API_KEY\",),\n \"openrouter\": (\"OPENROUTER_API_KEY\",),\n}\n\n\ndef looks_like_xai_credential(key: str | None) -> bool:\n \"\"\"True if the secret is an xAI console key or OAuth access JWT.\"\"\"\n k = (key or \"\").strip()\n if not k:\n return False\n if k.startswith(\"xai-\"):\n return True\n # OAuth access tokens are JWTs (header.payload.sig)\n if k.startswith(\"eyJ\") and k.count(\".\") >= 2:\n return True\n return False\n\n\ndef infer_key_provider(key: str | None) -> str | None:\n \"\"\"Best-effort owner for a raw API key (used only for migration).\"\"\"\n k = (key or \"\").strip()\n if not k:\n return None\n if looks_like_xai_credential(k):\n return \"xai\"\n if k.startswith(\"sk-ant\"):\n return \"anthropic\"\n if k.startswith(\"gsk_\"):\n return \"groq\"\n if k.startswith(\"AIza\"):\n return \"google\"\n if k.startswith(\"sk-or-\"):\n return \"openrouter\"\n # DeepSeek console keys are sk-… (same prefix as OpenAI). Prefer deepseek when\n # the key was incorrectly parked on xAI; OpenAI users can re-save once.\n if k.startswith(\"sk-\"):\n return \"deepseek\"\n return None\n\n\ndef _home_from_cfg(cfg: dict[str, Any] | None, home: Path | str | None = None) -> Path | None:\n if home is not None:\n return Path(home).expanduser()\n if cfg and cfg.get(\"home_dir\"):\n return Path(str(cfg[\"home_dir\"])).expanduser()\n return None\n\n\ndef get_provider_keys(\n cfg: dict[str, Any] | None = None,\n *,\n home: Path | str | None = None,\n) -> dict[str, str]:\n \"\"\"Return {provider: api_key} from the **secure store** (not config.toml).\n\n Legacy plaintext ``cfg['provider_keys']`` is still read for one-shot\n migration, then ignored for ongoing use.\n \"\"\"\n from remedy.interfaces.secret_store import load_provider_keys\n\n home_path = _home_from_cfg(cfg, home)\n stored = load_provider_keys(home_path)\n # Transient legacy table (only until migrate_provider_keys scrubs it)\n raw = (cfg or {}).get(\"provider_keys\") or {}\n if isinstance(raw, dict):\n for k, v in raw.items():\n pk = str(k or \"\").strip().lower()\n val = str(v or \"\").strip()\n if pk and val and pk not in stored:\n stored[pk] = val\n return stored\n\n\ndef set_provider_key(\n cfg: dict[str, Any],\n provider: str,\n api_key: str | None,\n *,\n home: Path | str | None = None,\n) -> dict[str, Any]:\n \"\"\"Write/clear a per-provider API key in the secure store. Scrubs cfg secrets.\"\"\"\n from remedy.interfaces.secret_store import scrub_config_secrets, set_provider_secret\n\n provider = str(provider or \"\").strip().lower()\n if not provider:\n return cfg\n home_path = _home_from_cfg(cfg, home)\n set_provider_secret(provider, api_key, home=home_path)\n # Never keep secrets in the config dict destined for disk.\n cfg = scrub_config_secrets(cfg)\n cfg.pop(\"provider_keys\", None)\n cfg[\"llm_api_key\"] = \"\"\n return cfg\n\n\ndef migrate_provider_keys(cfg: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Move secrets into the secure store; scrub config; unstick foreign xAI keys.\n\n Legacy configs used a single ``llm_api_key`` shared across providers and/or a\n plaintext ``[provider_keys]`` table. Both are migrated into\n ``~/.remedy/auth/provider_keys.json`` (DPAPI-encrypted on Windows) and\n removed from the config representation.\n \"\"\"\n from remedy.interfaces.secret_store import migrate_secrets_from_config\n\n cfg = dict(cfg or {})\n home = _home_from_cfg(cfg)\n # Pull plaintext secrets into secure store + scrub cfg\n cfg = migrate_secrets_from_config(cfg, home=home)\n # Ensure global key is never left populated (secrets live in the store / OAuth)\n cfg[\"llm_api_key\"] = \"\"\n cfg.pop(\"provider_keys\", None)\n return cfg\n\n\ndef resolve_provider_api_key(\n cfg: dict[str, Any] | None = None,\n provider: str | None = None,\n *,\n home: Path | str | None = None,\n) -> str:\n \"\"\"Resolve the API key/bearer for *this* provider only (never another provider's).\n\n Order:\n 1. Secure store (``~/.remedy/auth/provider_keys.json``)\n 2. xAI OAuth / auth store via ``resolve_bearer``\n 3. Provider-specific env vars\n 4. Legacy ``llm_api_key`` / ``REMEDY_LLM_API_KEY`` only if plausible for provider\n (and never written back to disk by this function)\n \"\"\"\n cfg = dict(cfg or {})\n home_path = _home_from_cfg(cfg, home)\n provider = str(provider or cfg.get(\"llm_provider\") or \"\").strip().lower()\n if not provider:\n return \"\"\n\n # Secure store first (after optional lazy migration of any leftover plaintext)\n if cfg.get(\"provider_keys\") or str(cfg.get(\"llm_api_key\") or \"\").strip():\n cfg = migrate_provider_keys(cfg)\n\n from remedy.interfaces.secret_store import get_provider_secret\n\n mapped = (get_provider_secret(provider, home=home_path) or \"\").strip()\n if mapped:\n if provider == \"xai\" and not looks_like_xai_credential(mapped):\n mapped = \"\"\n else:\n return mapped\n\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n token = resolve_bearer(home_path)\n if token:\n return token\n except Exception:\n pass\n\n for env_name in _PROVIDER_ENV_KEYS.get(provider, ()):\n val = os.environ.get(env_name, \"\").strip()\n if val:\n if provider == \"xai\" and env_name in (\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"):\n return val\n if provider != \"xai\":\n return val\n if looks_like_xai_credential(val):\n return val\n\n # Legacy global / env — only when it matches this provider. Not persisted.\n global_key = str(\n cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\"\n ).strip()\n if not global_key:\n return \"\"\n if provider == \"xai\":\n return global_key if looks_like_xai_credential(global_key) else \"\"\n if looks_like_xai_credential(global_key):\n return \"\"\n return global_key\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\"},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\"},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\"},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\"},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\"},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\"},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\"},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\"},\n ],\n },\n \"google\": {\n \"label\": \"Google AI\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\"},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\"},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\"},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\"},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\"},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n \"models\": [\n {\"id\": \"grok-4\", \"name\": \"Grok 4\"},\n {\"id\": \"grok-3\", \"name\": \"Grok 3\"},\n {\"id\": \"grok-3-mini\", \"name\": \"Grok 3 Mini\"},\n {\"id\": \"grok-2\", \"name\": \"Grok 2\"},\n {\"id\": \"grok-2-vision-1212\", \"name\": \"Grok 2 Vision\"},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\"},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\"},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\"},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\"},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\"},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\"},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n {\"id\": \"anthropic/claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (via OpenRouter)\"},\n {\"id\": \"google/gemini-2.0-flash-001\", \"name\": \"Gemini 2.0 Flash (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # Per-provider keys (DeepSeek sk- never rides into xAI, etc.).\n llm_api_key = resolve_provider_api_key(config, llm_provider)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n cfg = config if config is not None else load_config(path)\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Minimal TOML writer (top-level scalars only + nested dicts as sections).\n lines = [\"# Remedy AI Configuration\", \"\"]\n for key, value in cfg.items():\n if value is None:\n # Omit null keys instead of writing misleading empty strings.\n continue\n if isinstance(value, dict):\n lines.append(f\"[{key}]\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_toml_scalar(v)}\")\n lines.append(\"\")\n else:\n lines.append(f\"{key} = {_toml_scalar(value)}\")\n path.write_text(\"\\n\".join(lines) + \"\\n\", encoding=\"utf-8\")\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n # Use a copy of the *input* mapping — migrate_provider_keys scrubs llm_api_key.\n raw = dict(config) if config is not None else load_config()\n provider = str(\n raw.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\"\n ).lower()\n base = str(\n raw.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n if provider in (\"ollama\",):\n return True\n plain = str(raw.get(\"llm_api_key\") or \"\").strip()\n if not plain and config is None:\n plain = str(os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if plain:\n if provider == \"xai\":\n return looks_like_xai_credential(plain) or plain.startswith(\"xai-\")\n return True\n # Secure store / OAuth / env (may migrate leftovers as a side effect).\n key = resolve_provider_api_key(raw, provider or \"openai\")\n return bool(key)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid == \"ollama\":\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "0b304ef52fd194bded4e30071b1c547e603a2a1f": {"t": 1785768519.5034814, "n": 31459, "h": "7ac13c9c62a3011d", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n # XAI_API_KEY / other env keys preselect provider on clean defaults.\n config = apply_env_provider_bootstrap(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"openai\": {\n \"label\": \"OpenAI\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENAI_API_KEY\", \"REMEDY_LLM_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\"},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\"},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\"},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\"},\n ],\n },\n \"anthropic\": {\n \"label\": \"Anthropic\",\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"ANTHROPIC_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\"},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\"},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\"},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\"},\n ],\n },\n \"google\": {\n \"label\": \"Google AI\",\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\"},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\"},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\"},\n ],\n },\n \"deepseek\": {\n \"label\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"DEEPSEEK_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\"},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\"},\n ],\n },\n \"xai\": {\n \"label\": \"xAI (Grok)\",\n \"base_url\": \"https://api.x.ai/v1\",\n \"auth\": [\"oauth\", \"api_key\"], # Sign in with xAI primary; console API key secondary\n \"env_keys\": [\"XAI_API_KEY\", \"REMEDY_XAI_API_KEY\"],\n \"show_base_url\": False,\n \"key_docs_url\": \"https://console.x.ai/team/default/api-keys\",\n \"models\": [\n {\"id\": \"grok-4\", \"name\": \"Grok 4\"},\n {\"id\": \"grok-3\", \"name\": \"Grok 3\"},\n {\"id\": \"grok-3-mini\", \"name\": \"Grok 3 Mini\"},\n {\"id\": \"grok-2\", \"name\": \"Grok 2\"},\n {\"id\": \"grok-2-vision-1212\", \"name\": \"Grok 2 Vision\"},\n ],\n },\n \"groq\": {\n \"label\": \"Groq\",\n \"base_url\": \"https://api.groq.com/openai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"GROQ_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"llama-3.3-70b-versatile\", \"name\": \"Llama 3.3 70B\"},\n {\"id\": \"llama-3.1-8b-instant\", \"name\": \"Llama 3.1 8B Instant\"},\n {\"id\": \"mixtral-8x7b-32768\", \"name\": \"Mixtral 8x7B\"},\n ],\n },\n \"mistral\": {\n \"label\": \"Mistral\",\n \"base_url\": \"https://api.mistral.ai/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"MISTRAL_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"mistral-small-latest\", \"name\": \"Mistral Small\"},\n {\"id\": \"mistral-large-latest\", \"name\": \"Mistral Large\"},\n {\"id\": \"codestral-latest\", \"name\": \"Codestral\"},\n ],\n },\n \"openrouter\": {\n \"label\": \"OpenRouter\",\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [\"OPENROUTER_API_KEY\"],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n {\"id\": \"anthropic/claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (via OpenRouter)\"},\n {\"id\": \"google/gemini-2.0-flash-001\", \"name\": \"Gemini 2.0 Flash (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"label\": \"Ollama (local)\",\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"auth\": [\"none\"],\n \"env_keys\": [],\n \"show_base_url\": False,\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"label\": \"Custom / OpenAI-compatible\",\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"auth\": [\"api_key\"],\n \"env_keys\": [],\n \"show_base_url\": True,\n \"advanced\": True, # hide under Advanced in desktop UI\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n# Providers that keep a closed model catalog (foreign model ids are snapped).\n_CLOSED_PROVIDERS = frozenset(\n {\"openai\", \"anthropic\", \"google\", \"deepseek\", \"xai\", \"groq\", \"mistral\"}\n)\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"grok\") or mid.startswith(\"xai/\"):\n return \"xai\"\n if mid.startswith(\"mistral\") or mid.startswith(\"codestral\") or mid.startswith(\"open-mistral\"):\n return \"mistral\"\n # Groq hosts open models; only treat explicit groq/ prefix as owned.\n if mid.startswith(\"groq/\"):\n return \"groq\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"api.x.ai\" in u or \"x.ai\" in u:\n return \"xai\"\n if \"api.groq.com\" in u or \"groq.com\" in u:\n return \"groq\"\n if \"mistral.ai\" in u:\n return \"mistral\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in _CLOSED_PROVIDERS and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_api_key = _resolve_str(\n config.get(\"llm_api_key\"),\n \"REMEDY_LLM_API_KEY\",\n \"\",\n )\n\n llm_provider = _resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n )\n\n # xAI: prefer OAuth access token / stored key from ~/.remedy/auth/xai.json\n # (OpenCode-style dual auth) over a missing config key.\n if not llm_api_key and str(llm_provider).lower() == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n home = config.get(\"home_dir\")\n token = resolve_bearer(Path(home).expanduser() if home else None)\n if token:\n llm_api_key = token\n except Exception as exc:\n logger.debug(\"xAI credential resolve skipped: %s\", exc)\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=llm_provider,\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported: openai, anthropic, google, deepseek, xai, groq, mistral, openrouter, ollama, custom\n# xAI also supports OAuth (Sign in with xAI) via desktop Settings / `remedy auth login xai`\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n# XAI_API_KEY auto-selects xAI on first run when present\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n cfg = config if config is not None else load_config(path)\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Minimal TOML writer (top-level scalars only + nested dicts as sections).\n lines = [\"# Remedy AI Configuration\", \"\"]\n for key, value in cfg.items():\n if value is None:\n # Omit null keys instead of writing misleading empty strings.\n continue\n if isinstance(value, dict):\n lines.append(f\"[{key}]\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_toml_scalar(v)}\")\n lines.append(\"\")\n else:\n lines.append(f\"{key} = {_toml_scalar(value)}\")\n path.write_text(\"\\n\".join(lines) + \"\\n\", encoding=\"utf-8\")\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n cfg = config if config is not None else load_config()\n key = str(cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if key:\n return True\n base = str(\n cfg.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n provider = str(cfg.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\").lower()\n if provider == \"xai\":\n try:\n from remedy.interfaces.xai_auth import load_credentials, resolve_bearer\n\n home = cfg.get(\"home_dir\")\n home_path = Path(home).expanduser() if home else None\n if load_credentials(home_path).connected or resolve_bearer(home_path):\n return True\n except Exception:\n pass\n # Env-only keys still count\n if (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n ):\n return True\n return provider in (\"ollama\",)\n\n\ndef public_provider_catalog() -> list[dict[str, Any]]:\n \"\"\"Catalog entries for GET /api/providers and desktop UI.\"\"\"\n items: list[dict[str, Any]] = []\n for pid, meta in PROVIDER_CATALOG.items():\n auth_modes = list(meta.get(\"auth\") or [\"api_key\"])\n if pid == \"ollama\":\n auth_modes = [\"none\"]\n models = list(meta.get(\"models\") or [])\n default_model = str(models[0][\"id\"]) if models else \"default\"\n items.append(\n {\n \"id\": pid,\n \"name\": meta.get(\"label\") or pid,\n \"base_url\": meta.get(\"base_url\"),\n \"models\": models,\n \"default_model\": default_model,\n \"auth\": auth_modes,\n \"oauth\": \"oauth\" in auth_modes,\n \"env_keys\": list(meta.get(\"env_keys\") or []),\n \"show_base_url\": bool(meta.get(\"show_base_url\", pid in (\"custom\", \"ollama\"))),\n \"advanced\": bool(meta.get(\"advanced\", False)),\n \"key_docs_url\": meta.get(\"key_docs_url\"),\n }\n )\n return items\n\n\ndef detect_ollama(base_url: str | None = None, timeout: float = 1.5) -> dict[str, Any]:\n \"\"\"Probe local Ollama (tags API). Returns available flag + model names.\"\"\"\n import json\n import urllib.error\n import urllib.request\n\n url = (base_url or PROVIDER_CATALOG[\"ollama\"][\"base_url\"] or \"\").rstrip(\"/\")\n # Native tags endpoint (strip /v1 if present)\n tags_url = url.removesuffix(\"/v1\") + \"/api/tags\"\n models: list[str] = []\n try:\n req = urllib.request.Request(\n tags_url,\n headers={\"Accept\": \"application/json\", \"User-Agent\": \"Remedy/detect-ollama\"},\n method=\"GET\",\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n body = json.loads(resp.read().decode(\"utf-8\") or \"{}\")\n for m in body.get(\"models\") or []:\n name = m.get(\"name\") or m.get(\"model\") or \"\"\n if name:\n # Prefer short name without :latest\n short = name.rstrip(\":latest\") if name.endswith(\":latest\") else name\n models.append(short)\n return {\n \"available\": True,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": models,\n \"tags_url\": tags_url,\n }\n except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError, ValueError):\n return {\n \"available\": False,\n \"base_url\": PROVIDER_CATALOG[\"ollama\"][\"base_url\"],\n \"models\": [],\n \"tags_url\": tags_url,\n }\n\n\ndef apply_env_provider_bootstrap(config: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"Preselect provider from env keys on clean / default config.\n\n Plan: if ``XAI_API_KEY`` is set and the config is still effectively default\n (no explicit key, provider openai default or empty), switch to xAI.\n Also honors provider-specific env keys when config has no API key yet.\n Mutates a copy; does not write disk.\n \"\"\"\n cfg = dict(config or {})\n has_key = bool(str(cfg.get(\"llm_api_key\") or \"\").strip())\n if has_key:\n return cfg\n\n provider = str(cfg.get(\"llm_provider\") or \"\").strip().lower()\n # Only auto-switch when unset or still on the factory default openai.\n allow_switch = provider in (\"\", \"openai\")\n\n # Priority: xAI first (plan requirement), then other known env keys.\n xai_key = (\n os.environ.get(\"XAI_API_KEY\", \"\").strip()\n or os.environ.get(\"REMEDY_XAI_API_KEY\", \"\").strip()\n )\n if xai_key and allow_switch:\n prov, model, url = normalize_llm_settings(\"xai\", cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\"))\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n # Do not persist raw key into config dict here; resolve_bearer/env handles auth.\n return cfg\n\n # Optional: if Ollama is running and no cloud keys, suggest ollama (detect only\n # when still on default openai with empty key — non-blocking soft prefer).\n if allow_switch and not os.environ.get(\"REMEDY_LLM_API_KEY\", \"\").strip():\n # Skip network probe unless explicitly requested via env (wizard/API call\n # does active detect). Soft file-free bootstrap stays offline-safe.\n if os.environ.get(\"REMEDY_PREFER_OLLAMA\", \"\").strip() in (\"1\", \"true\", \"yes\"):\n ollama = detect_ollama()\n if ollama.get(\"available\"):\n models = ollama.get(\"models\") or []\n mid = models[0] if models else \"llama3.2\"\n cfg[\"llm_provider\"] = \"ollama\"\n cfg[\"llm_model\"] = mid\n cfg[\"llm_base_url\"] = PROVIDER_CATALOG[\"ollama\"][\"base_url\"]\n return cfg\n\n # Map other env API keys → provider when still on default.\n if allow_switch:\n env_map = (\n (\"GROQ_API_KEY\", \"groq\"),\n (\"MISTRAL_API_KEY\", \"mistral\"),\n (\"DEEPSEEK_API_KEY\", \"deepseek\"),\n (\"OPENROUTER_API_KEY\", \"openrouter\"),\n (\"ANTHROPIC_API_KEY\", \"anthropic\"),\n (\"OPENAI_API_KEY\", \"openai\"),\n (\"GOOGLE_API_KEY\", \"google\"),\n (\"GEMINI_API_KEY\", \"google\"),\n )\n for env_name, pid in env_map:\n if os.environ.get(env_name, \"\").strip():\n prov, model, url = normalize_llm_settings(\n pid, cfg.get(\"llm_model\"), cfg.get(\"llm_base_url\")\n )\n cfg[\"llm_provider\"] = prov\n cfg[\"llm_model\"] = model\n cfg[\"llm_base_url\"] = url\n return cfg\n\n return cfg\n"}, "18925bbd5fa1d222c4b490709cfb847364042e94": {"t": 1785768522.1034665, "n": 21132, "h": "ff16940b4c25d7c7", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"openai\": {\n \"base_url\": \"https://api.openai.com/v1\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\"},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\"},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\"},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\"},\n ],\n },\n \"anthropic\": {\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\"},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\"},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\"},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\"},\n ],\n },\n \"google\": {\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\"},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\"},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\"},\n ],\n },\n \"deepseek\": {\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\"},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\"},\n ],\n },\n \"openrouter\": {\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n {\"id\": \"anthropic/claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (via OpenRouter)\"},\n {\"id\": \"google/gemini-2.0-flash-001\", \"name\": \"Gemini 2.0 Flash (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in (\"openai\", \"anthropic\", \"google\", \"deepseek\") and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_api_key = _resolve_str(\n config.get(\"llm_api_key\"),\n \"REMEDY_LLM_API_KEY\",\n \"\",\n )\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=_resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n ),\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported providers: openai, anthropic, google, deepseek, openrouter, ollama, custom\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n cfg = config if config is not None else load_config(path)\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Minimal TOML writer (top-level scalars only + nested dicts as sections).\n lines = [\"# Remedy AI Configuration\", \"\"]\n for key, value in cfg.items():\n if value is None:\n # Omit null keys instead of writing misleading empty strings.\n continue\n if isinstance(value, dict):\n lines.append(f\"[{key}]\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_toml_scalar(v)}\")\n lines.append(\"\")\n else:\n lines.append(f\"{key} = {_toml_scalar(value)}\")\n path.write_text(\"\\n\".join(lines) + \"\\n\", encoding=\"utf-8\")\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if value is None:\n # Callers should skip None; keep a defined encoding for list elements.\n return '\"\"'\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value if v is not None)\n return f\"[{items}]\"\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n cfg = config if config is not None else load_config()\n key = str(cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if key:\n return True\n base = str(\n cfg.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n provider = str(cfg.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\").lower()\n return provider in (\"ollama\",)\n"}, "d3b08907c398c7f2ba23210670f3cb0e6339f5d8": {"t": 1785768523.5681489, "n": 20856, "h": "12b303a39105c908", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"openai\": {\n \"base_url\": \"https://api.openai.com/v1\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\"},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\"},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\"},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\"},\n ],\n },\n \"anthropic\": {\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\"},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\"},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\"},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\"},\n ],\n },\n \"google\": {\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\"},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\"},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\"},\n ],\n },\n \"deepseek\": {\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\"},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\"},\n ],\n },\n \"openrouter\": {\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n {\"id\": \"anthropic/claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (via OpenRouter)\"},\n {\"id\": \"google/gemini-2.0-flash-001\", \"name\": \"Gemini 2.0 Flash (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n # Common local / Ollama model family prefixes (not exhaustive).\n if mid.startswith(\n (\n \"llama\",\n \"qwen\",\n \"codellama\",\n \"mistral\",\n \"mixtral\",\n \"phi\",\n \"gemma\",\n \"codegemma\",\n \"tinyllama\",\n \"wizard\",\n \"nous\",\n \"yi-\",\n \"solar\",\n \"orca\",\n \"starcoder\",\n \"deepseek-coder\",\n \"deepseek-r1\",\n )\n ):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in (\"openai\", \"anthropic\", \"google\", \"deepseek\") and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_api_key = _resolve_str(\n config.get(\"llm_api_key\"),\n \"REMEDY_LLM_API_KEY\",\n \"\",\n )\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=_resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n ),\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported providers: openai, anthropic, google, deepseek, openrouter, ollama, custom\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n cfg = config if config is not None else load_config(path)\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Minimal TOML writer (top-level scalars only + nested dicts as sections).\n lines = [\"# Remedy AI Configuration\", \"\"]\n for key, value in cfg.items():\n if isinstance(value, dict):\n lines.append(f\"[{key}]\")\n for k, v in value.items():\n lines.append(f\"{k} = {_toml_scalar(v)}\")\n lines.append(\"\")\n else:\n lines.append(f\"{key} = {_toml_scalar(value)}\")\n path.write_text(\"\\n\".join(lines) + \"\\n\", encoding=\"utf-8\")\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value)\n return f\"[{items}]\"\n if value is None:\n return '\"\"'\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n cfg = config if config is not None else load_config()\n key = str(cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if key:\n return True\n base = str(\n cfg.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n provider = str(cfg.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\").lower()\n return provider in (\"ollama\",)\n"}, "b0a3b76aee2fda2781a3bc469d753272b8cfc831": {"t": 1785768526.649921, "n": 20470, "h": "6b25fbd32cb1e84c", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"openai\": {\n \"base_url\": \"https://api.openai.com/v1\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\"},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\"},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\"},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\"},\n ],\n },\n \"anthropic\": {\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\"},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\"},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\"},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\"},\n ],\n },\n \"google\": {\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\"},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\"},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\"},\n ],\n },\n \"deepseek\": {\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\"},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\"},\n ],\n },\n \"openrouter\": {\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n {\"id\": \"anthropic/claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (via OpenRouter)\"},\n {\"id\": \"google/gemini-2.0-flash-001\", \"name\": \"Gemini 2.0 Flash (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"llama\") or mid.startswith(\"qwen\") or mid.startswith(\"codellama\") or mid.startswith(\"mistral\"):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in (\"openai\", \"anthropic\", \"google\", \"deepseek\") and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_api_key = _resolve_str(\n config.get(\"llm_api_key\"),\n \"REMEDY_LLM_API_KEY\",\n \"\",\n )\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=_resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n ),\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# First-run setup: false until `remedy setup` / desktop wizard / --skip-setup\nsetup_completed = false\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported providers: openai, anthropic, google, deepseek, openrouter, ollama, custom\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n\n\ndef config_path_for_home(home_dir: str | Path | None = None) -> Path:\n \"\"\"Return the canonical config.toml path for a home directory.\"\"\"\n hd = Path(home_dir or Path.home() / \".remedy\").expanduser()\n return hd / \"config.toml\"\n\n\ndef needs_first_run_setup(\n config: dict[str, Any] | None = None,\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n) -> bool:\n \"\"\"Return True when first-run setup should run before launch.\n\n Rules:\n - No config file → need setup\n - ``setup_completed`` present → honor it (True skips, False forces)\n - Legacy config without the flag → treat as already set up (do not re-wizard upgrades)\n\n Skipping setup (desktop Skip, CLI --skip-setup) writes ``setup_completed = true``\n so subsequent launches ignore the wizard.\n \"\"\"\n path = config_path\n if path is None and home_dir is not None:\n path = config_path_for_home(home_dir)\n if path is None:\n path = config_path_for_home()\n\n path = Path(path).expanduser()\n if not path.exists():\n return True\n\n cfg = config if config is not None else load_config(path)\n if \"setup_completed\" in cfg:\n return not bool(cfg[\"setup_completed\"])\n # Pre-flag installs: config already exists → do not force wizard again.\n return False\n\n\ndef mark_setup_completed(\n *,\n home_dir: str | Path | None = None,\n config_path: Path | None = None,\n extra: dict[str, Any] | None = None,\n) -> Path:\n \"\"\"Persist ``setup_completed = true`` (optionally merging extra keys).\n\n Creates a minimal config when none exists so first-launch skip is remembered.\n \"\"\"\n path = config_path or config_path_for_home(home_dir)\n path = Path(path).expanduser()\n path.parent.mkdir(parents=True, exist_ok=True)\n\n cfg: dict[str, Any] = load_config(path) if path.exists() else {}\n if extra:\n cfg.update(extra)\n cfg[\"setup_completed\"] = True\n if \"home_dir\" not in cfg:\n cfg[\"home_dir\"] = path.parent.as_posix()\n\n # Minimal TOML writer (top-level scalars only + nested dicts as sections).\n lines = [\"# Remedy AI Configuration\", \"\"]\n for key, value in cfg.items():\n if isinstance(value, dict):\n lines.append(f\"[{key}]\")\n for k, v in value.items():\n lines.append(f\"{k} = {_toml_scalar(v)}\")\n lines.append(\"\")\n else:\n lines.append(f\"{key} = {_toml_scalar(value)}\")\n path.write_text(\"\\n\".join(lines) + \"\\n\", encoding=\"utf-8\")\n return path\n\n\ndef _toml_scalar(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)) and not isinstance(value, bool):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_toml_scalar(v) for v in value)\n return f\"[{items}]\"\n if value is None:\n return '\"\"'\n # Escape backslashes and quotes for TOML strings\n text = str(value).replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n return f'\"{text}\"'\n\n\ndef provider_credentials_ready(config: dict[str, Any] | None = None) -> bool:\n \"\"\"True when an LLM key is configured (or local URL which needs none).\"\"\"\n cfg = config if config is not None else load_config()\n key = str(cfg.get(\"llm_api_key\") or os.environ.get(\"REMEDY_LLM_API_KEY\") or \"\").strip()\n if key:\n return True\n base = str(\n cfg.get(\"llm_base_url\")\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n ).strip()\n if base and _is_local_url(base):\n return True\n provider = str(cfg.get(\"llm_provider\") or os.environ.get(\"REMEDY_LLM_PROVIDER\") or \"\").lower()\n return provider in (\"ollama\",)\n"}, "2a57b2bdf66c98722aad8cb3c58d6281812d54b1": {"t": 1785768531.29903, "n": 16680, "h": "1c3f911b14b5b9f1", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"openai\": {\n \"base_url\": \"https://api.openai.com/v1\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\"},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\"},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\"},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\"},\n ],\n },\n \"anthropic\": {\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\"},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\"},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\"},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\"},\n ],\n },\n \"google\": {\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\"},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\"},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\"},\n ],\n },\n \"deepseek\": {\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\"},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\"},\n ],\n },\n \"openrouter\": {\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n {\"id\": \"anthropic/claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (via OpenRouter)\"},\n {\"id\": \"google/gemini-2.0-flash-001\", \"name\": \"Gemini 2.0 Flash (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"llama\") or mid.startswith(\"qwen\") or mid.startswith(\"codellama\") or mid.startswith(\"mistral\"):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in (\"openai\", \"anthropic\", \"google\", \"deepseek\") and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_api_key = _resolve_str(\n config.get(\"llm_api_key\"),\n \"REMEDY_LLM_API_KEY\",\n \"\",\n )\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=_resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n ),\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n project_path=config.get(\"project_path\") or os.environ.get(\"REMEDY_PROJECT_PATH\") or None,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# Default project/workspace folder for the agent (file tools, shell cwd, @file UI)\n# project_path = \"C:/Users/You/Projects/MyApp\"\n\n# --- LLM Provider ---\n# Supported providers: openai, anthropic, google, deepseek, openrouter, ollama, custom\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n"}, "130fa788ee998c7e0eefa031daec735079d2cf5a": {"t": 1785768532.8371031, "n": 16451, "h": "9213e12218a04b6a", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"openai\": {\n \"base_url\": \"https://api.openai.com/v1\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\"},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\"},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\"},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\"},\n ],\n },\n \"anthropic\": {\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\"},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\"},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\"},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\"},\n ],\n },\n \"google\": {\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\"},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\"},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\"},\n ],\n },\n \"deepseek\": {\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\"},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\"},\n ],\n },\n \"openrouter\": {\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n {\"id\": \"anthropic/claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (via OpenRouter)\"},\n {\"id\": \"google/gemini-2.0-flash-001\", \"name\": \"Gemini 2.0 Flash (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"llama\") or mid.startswith(\"qwen\") or mid.startswith(\"codellama\") or mid.startswith(\"mistral\"):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # Flexible providers can host any model id (Ollama pulls deepseek-*, etc.).\n _FLEXIBLE = frozenset({\"openrouter\", \"custom\", \"ollama\"})\n\n model_owner = infer_provider_from_model(mid)\n if model_owner and model_owner != prov and prov not in _FLEXIBLE:\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models and prov not in _FLEXIBLE:\n known = {m[\"id\"] for m in default_models}\n if not mid:\n mid = default_model\n # Closed catalogs: reject foreign model ids.\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in (\"openai\", \"anthropic\", \"google\", \"deepseek\") and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\n# Canonical desktop personas (aligned with SetupWizard).\nPERSONA_PROMPTS: dict[str, str] = {\n \"default\": \"\",\n \"balanced\": (\n \"Communication style: balanced — helpful and adaptable to the task. \"\n \"Match the user's depth; prefer clarity over verbosity.\"\n ),\n \"efficient\": (\n \"Communication style: efficient — concise, code-first, minimal explanation. \"\n \"Prefer short answers and actionable output.\"\n ),\n \"detailed\": (\n \"Communication style: detailed — thorough explanations with context, \"\n \"trade-offs, and clear structure.\"\n ),\n \"playful\": (\n \"Communication style: playful — casual tone with light humor while remaining accurate.\"\n ),\n # CLI wizard aliases\n \"concise\": (\n \"Communication style: concise — short answers, minimal fluff.\"\n ),\n \"verbose\": (\n \"Communication style: verbose — thorough explanations with examples.\"\n ),\n \"sarcastic\": (\n \"Communication style: dry humor is allowed; stay helpful and accurate.\"\n ),\n \"minimal\": (\n \"Communication style: minimal — answer only what was asked.\"\n ),\n}\n\n\ndef persona_system_addendum(persona: str | None) -> str:\n \"\"\"Return system-prompt text for a persona id, or empty string.\"\"\"\n if not persona:\n return \"\"\n key = persona.strip().lower()\n return PERSONA_PROMPTS.get(key, \"\")\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_api_key = _resolve_str(\n config.get(\"llm_api_key\"),\n \"REMEDY_LLM_API_KEY\",\n \"\",\n )\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=_resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n ),\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# --- LLM Provider ---\n# Supported providers: openai, anthropic, google, deepseek, openrouter, ollama, custom\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n"}, "47733d3c40c7c8df8865cda06c494c6775750cc3": {"t": 1785768534.7957861, "n": 15286, "h": "d9a0d10292e610f4", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n\n When ``home_dir`` is set and no explicit ``config_path`` is given, prefer\n ``{home_dir}/config.toml`` so desktop ``--home`` and CLI share one file.\n \"\"\"\n if config_path is None and home_dir:\n home_cfg = Path(home_dir).expanduser() / \"config.toml\"\n if home_cfg.exists():\n config_path = home_cfg\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\n# -- Provider catalog (defaults + model ownership) ---------------------------\n\nPROVIDER_CATALOG: dict[str, dict[str, Any]] = {\n \"openai\": {\n \"base_url\": \"https://api.openai.com/v1\",\n \"models\": [\n {\"id\": \"gpt-4o-mini\", \"name\": \"GPT-4o Mini\"},\n {\"id\": \"gpt-4o\", \"name\": \"GPT-4o\"},\n {\"id\": \"gpt-4.1-mini\", \"name\": \"GPT-4.1 Mini\"},\n {\"id\": \"o4-mini\", \"name\": \"o4-mini\"},\n ],\n },\n \"anthropic\": {\n \"base_url\": \"https://api.anthropic.com/v1\",\n \"models\": [\n {\"id\": \"claude-3-5-sonnet-latest\", \"name\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-latest\", \"name\": \"Claude 3.5 Haiku\"},\n {\"id\": \"claude-3-haiku-20240307\", \"name\": \"Claude 3 Haiku\"},\n {\"id\": \"claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (alias)\"},\n {\"id\": \"claude-3-haiku\", \"name\": \"Claude 3 Haiku (alias)\"},\n ],\n },\n \"google\": {\n \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n \"models\": [\n {\"id\": \"gemini-2.5-flash\", \"name\": \"Gemini 2.5 Flash\"},\n {\"id\": \"gemini-2.0-flash\", \"name\": \"Gemini 2.0 Flash\"},\n {\"id\": \"gemini-1.5-pro\", \"name\": \"Gemini 1.5 Pro\"},\n ],\n },\n \"deepseek\": {\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"models\": [\n {\"id\": \"deepseek-chat\", \"name\": \"DeepSeek Chat\"},\n {\"id\": \"deepseek-reasoner\", \"name\": \"DeepSeek Reasoner\"},\n ],\n },\n \"openrouter\": {\n \"base_url\": \"https://openrouter.ai/api/v1\",\n \"models\": [\n {\"id\": \"openrouter/auto\", \"name\": \"OpenRouter Auto\"},\n {\"id\": \"openai/gpt-4o-mini\", \"name\": \"GPT-4o Mini (via OpenRouter)\"},\n {\"id\": \"anthropic/claude-3.5-sonnet\", \"name\": \"Claude 3.5 Sonnet (via OpenRouter)\"},\n {\"id\": \"google/gemini-2.0-flash-001\", \"name\": \"Gemini 2.0 Flash (via OpenRouter)\"},\n ],\n },\n \"ollama\": {\n \"base_url\": \"http://127.0.0.1:11434/v1\",\n \"models\": [\n {\"id\": \"llama3.2\", \"name\": \"Llama 3.2\"},\n {\"id\": \"qwen2.5\", \"name\": \"Qwen 2.5\"},\n {\"id\": \"codellama\", \"name\": \"Code Llama\"},\n ],\n },\n \"custom\": {\n \"base_url\": \"http://127.0.0.1:5001/api/v1\",\n \"models\": [\n {\"id\": \"default\", \"name\": \"Default (custom endpoint)\"},\n ],\n },\n}\n\n\ndef infer_provider_from_model(model_id: str) -> str | None:\n \"\"\"Guess which *native* provider owns a model id (not OpenRouter-prefixed).\"\"\"\n mid = (model_id or \"\").strip().lower()\n if not mid:\n return None\n # OpenRouter-style vendor/model is multi-provider; treat as openrouter only\n # when the user already chose openrouter — do not force it here.\n if mid.startswith(\"claude\") or mid.startswith(\"anthropic/\"):\n return \"anthropic\"\n if mid.startswith(\"gpt-\") or mid.startswith(\"o1\") or mid.startswith(\"o3\") or mid.startswith(\"o4\"):\n return \"openai\"\n if mid.startswith(\"gemini\") or mid.startswith(\"models/gemini\"):\n return \"google\"\n if mid.startswith(\"deepseek\"):\n return \"deepseek\"\n if mid.startswith(\"llama\") or mid.startswith(\"qwen\") or mid.startswith(\"codellama\") or mid.startswith(\"mistral\"):\n return \"ollama\"\n return None\n\n\ndef infer_provider_from_base_url(base_url: str) -> str | None:\n \"\"\"Map a known host to a provider id.\"\"\"\n u = (base_url or \"\").lower()\n if not u:\n return None\n if \"anthropic.com\" in u:\n return \"anthropic\"\n if \"openai.com\" in u:\n return \"openai\"\n if \"deepseek.com\" in u:\n return \"deepseek\"\n if \"openrouter.ai\" in u:\n return \"openrouter\"\n if \"generativelanguage.googleapis.com\" in u or \"googleapis.com\" in u:\n return \"google\"\n if \"11434\" in u or \"ollama\" in u:\n return \"ollama\"\n return None\n\n\ndef normalize_llm_settings(\n provider: str | None,\n model: str | None,\n base_url: str | None,\n) -> tuple[str, str, str]:\n \"\"\"Align provider, model, and base_url so they don't cross-wire.\n\n Examples of bad states we fix:\n - provider=deepseek, model=claude-3-haiku → model=deepseek-chat\n - provider=deepseek, base_url=api.openai.com → base_url=api.deepseek.com\n \"\"\"\n prov = (provider or \"openai\").strip().lower() or \"openai\"\n if prov not in PROVIDER_CATALOG:\n # Unknown label → treat as custom OpenAI-compatible\n if prov not in (\"custom\",):\n # keep name but use custom defaults for missing pieces\n pass\n catalog = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG[\"custom\"]\n default_url = str(catalog.get(\"base_url\") or \"\")\n default_models = list(catalog.get(\"models\") or [])\n default_model = str(default_models[0][\"id\"]) if default_models else \"gpt-4o-mini\"\n\n url = (base_url or \"\").strip() or default_url\n mid = (model or \"\").strip() or default_model\n\n # If URL clearly belongs to another known provider, snap to this provider's URL.\n url_owner = infer_provider_from_base_url(url)\n if url_owner and url_owner != prov and prov in PROVIDER_CATALOG:\n # OpenRouter intentionally hosts many vendors — only snap when *this*\n # provider is not openrouter/custom.\n if prov not in (\"openrouter\", \"custom\", \"ollama\"):\n url = default_url\n\n # If model clearly belongs to another native provider (and user is not on\n # openrouter/custom which can proxy anything), reset to provider default.\n model_owner = infer_provider_from_model(mid)\n if (\n model_owner\n and model_owner != prov\n and prov not in (\"openrouter\", \"custom\")\n ):\n mid = default_model\n elif prov in PROVIDER_CATALOG and default_models:\n known = {m[\"id\"] for m in default_models}\n # For closed catalogs (deepseek/anthropic/openai/google), if model isn't\n # known and looks foreign, already handled; if empty, use default.\n if not mid:\n mid = default_model\n # deepseek only has a tiny official list — force known ids when wrong\n if prov == \"deepseek\" and mid not in known and model_owner not in (None, \"deepseek\"):\n mid = default_model\n if prov in (\"openai\", \"anthropic\", \"google\", \"deepseek\") and model_owner and model_owner != prov:\n mid = default_model\n\n if not url:\n url = default_url\n if not mid:\n mid = default_model\n\n return prov, mid, url\n\n\ndef catalog_models_for_provider(provider: str) -> list[dict[str, Any]]:\n \"\"\"Return built-in model entries tagged with provider.\"\"\"\n prov = (provider or \"openai\").lower()\n cat = PROVIDER_CATALOG.get(prov) or PROVIDER_CATALOG.get(\"custom\") or {}\n out: list[dict[str, Any]] = []\n for m in cat.get(\"models\") or []:\n out.append(\n {\n \"id\": m[\"id\"],\n \"name\": m.get(\"name\", m[\"id\"]),\n \"provider\": prov,\n \"default\": False,\n }\n )\n return out\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_api_key = _resolve_str(\n config.get(\"llm_api_key\"),\n \"REMEDY_LLM_API_KEY\",\n \"\",\n )\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=_resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n ),\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# --- LLM Provider ---\n# Supported providers: openai, anthropic, google, deepseek, openrouter, ollama, custom\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n"}, "02dabfb68976b1f605a6e77366f8733d771baff1": {"t": 1785768536.4988167, "n": 7975, "h": "9064032b8ee88057", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n \"\"\"\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\n_LOCAL_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n\n\ndef _is_local_url(url: str) -> bool:\n \"\"\"Check if a base URL points to a local/loopback server.\"\"\"\n from urllib.parse import urlparse\n\n try:\n parsed = urlparse(url)\n host = (parsed.hostname or \"\").lower()\n return host in _LOCAL_HOSTS or host.endswith(\".local\")\n except Exception:\n return False\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n\n When llm_base_url points to a local server (localhost, 127.0.0.1, etc.)\n and no API key is configured, a dummy key is used automatically since\n local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't require one.\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n llm_base_url = _resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n )\n\n llm_api_key = _resolve_str(\n config.get(\"llm_api_key\"),\n \"REMEDY_LLM_API_KEY\",\n \"\",\n )\n\n # Local servers (Ollama, Kobold.cpp, LM Studio, etc.) don't need a real\n # API key. Supply a dummy value so the agent doesn't fall back to echo\n # mode when the user hasn't set one.\n if not llm_api_key and _is_local_url(llm_base_url):\n llm_api_key = \"local\"\n logger.info(\"No API key set for local LLM at %s — using dummy key\", llm_base_url)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=_resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n ),\n llm_api_key=llm_api_key,\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=llm_base_url,\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# --- LLM Provider ---\n# Supported providers: openai, anthropic, google, deepseek, openrouter, ollama, custom\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n"}, "ff976289dda4215d263c8f34c2e4102f21066200": {"t": 1785768538.241108, "n": 6949, "h": "307029273b30ad26", "c": "\"\"\"Remedy configuration system.\n\nLoads config from TOML/YAML files (e.g. ~/.remedy/config.toml),\nenvironment variable overrides, and CLI arguments. Provides a single\nmerged config for all subsystems.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\ntry:\n import tomllib\nexcept ImportError:\n import tomli as tomllib # type: ignore[no-redef]\n\nfrom remedy.models import AgentConfig, ChannelKind\n\nCONFIG_PATHS = [\n Path(\"~/.remedy/config.toml\"),\n Path(\"~/.remedy/config.yaml\"),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n]\n\nENV_PREFIX = \"REMEDY_\"\n\n\ndef load_config(path: Path | None = None) -> dict[str, Any]:\n \"\"\"Load config from a file, auto-detecting format (TOML or YAML).\"\"\"\n if path is None:\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n path = expanded\n break\n\n if path is None:\n return {}\n\n path = Path(path).expanduser().resolve()\n if not path.exists():\n return {}\n\n content = path.read_text(encoding=\"utf-8\")\n\n try:\n if path.suffix in (\".toml\", \".tml\"):\n return tomllib.loads(content)\n elif path.suffix in (\".yaml\", \".yml\") or \"---\" in content[:100] or path.suffix == \".yaml\":\n return yaml.safe_load(content) or {}\n else:\n return tomllib.loads(content)\n except Exception as exc:\n import logging\n logging.getLogger(__name__).error(\"Failed to parse config %s: %s\", path, exc)\n return {}\n\n\ndef load_env_overrides(base: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Apply environment variable overrides.\n\n Maps REMEDY_NAME=foo to config[\"name\"] = \"foo\"\n Maps REMEDY_LOG_LEVEL=DEBUG to config[\"log\"][\"level\"] = \"DEBUG\"\n \"\"\"\n result = dict(base)\n for key, value in os.environ.items():\n if not key.startswith(ENV_PREFIX):\n continue\n config_key = key[len(ENV_PREFIX):].lower()\n parts = config_key.split(\"__\")\n if len(parts) == 1:\n result[parts[0]] = _coerce(value)\n else:\n current = result\n for part in parts[:-1]:\n if part not in current:\n current[part] = {}\n current = current[part]\n current[parts[-1]] = _coerce(value)\n return result\n\n\ndef _coerce(value: str) -> Any:\n if value.lower() in (\"true\", \"yes\", \"1\"):\n return True\n if value.lower() in (\"false\", \"no\", \"0\"):\n return False\n try:\n return int(value)\n except ValueError:\n pass\n try:\n return float(value)\n except ValueError:\n pass\n return value\n\n\ndef resolve_config(\n config_path: Path | None = None,\n home_dir: str | None = None,\n env_overrides: bool = True,\n) -> dict[str, Any]:\n \"\"\"Load and resolve full configuration.\n\n Priority: env vars > config file > defaults.\n \"\"\"\n config = load_config(config_path)\n if env_overrides:\n config = load_env_overrides(config)\n if home_dir:\n config[\"home_dir\"] = home_dir\n return config\n\n\ndef _resolve_str(config_value: str | None, env_var: str, default: str) -> str:\n \"\"\"Resolve a config value, preferring non-empty config over env var over default.\"\"\"\n if config_value:\n return config_value\n env_value = os.environ.get(env_var, \"\").strip()\n if env_value:\n return env_value\n return default\n\n\ndef config_to_agent_config(config: dict[str, Any]) -> AgentConfig:\n \"\"\"Convert a config dict to a validated AgentConfig model.\n\n API key resolution order:\n non-empty config value > REMEDY_LLM_API_KEY env var > empty (fallback).\n \"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n channels_raw = config.get(\"enabled_channels\", [])\n if not isinstance(channels_raw, list):\n channels_raw = [channels_raw] if isinstance(channels_raw, str) else []\n\n channels: list[ChannelKind] = []\n for c in channels_raw:\n try:\n channels.append(ChannelKind(c))\n except ValueError:\n logger.warning(\"Ignoring unknown channel '%s' in config\", c)\n\n return AgentConfig(\n name=config.get(\"name\", \"Remedy\"),\n persona=config.get(\"persona\", \"default\"),\n home_dir=config.get(\"home_dir\", \"~/.remedy\"),\n skills_dir=config.get(\"skills_dir\", []),\n memory_db_path=config.get(\"memory_db_path\"),\n enabled_channels=channels,\n mcp_servers=config.get(\"mcp_servers\", []),\n allow_skill_creation=config.get(\"allow_skill_creation\", True),\n auto_approve_threshold=config.get(\"auto_approve_threshold\", 0.8),\n log_level=config.get(\"log_level\", \"INFO\"),\n sarcasm_mode=config.get(\"sarcasm_mode\", False),\n llm_provider=_resolve_str(\n config.get(\"llm_provider\"),\n \"REMEDY_LLM_PROVIDER\",\n \"openai\",\n ),\n llm_api_key=_resolve_str(\n config.get(\"llm_api_key\"),\n \"REMEDY_LLM_API_KEY\",\n \"\",\n ),\n llm_model=_resolve_str(\n config.get(\"llm_model\"),\n \"REMEDY_LLM_MODEL\",\n \"gpt-4o-mini\",\n ),\n llm_base_url=_resolve_str(\n config.get(\"llm_base_url\"),\n \"REMEDY_LLM_BASE_URL\",\n \"https://api.openai.com/v1\",\n ),\n )\n\n\ndef generate_default_config(home_dir: Path) -> str:\n \"\"\"Generate a default TOML config file content.\"\"\"\n return f\"\"\"# Remedy AI Configuration\n# Generated by remedy config init\n\nname = \"Remedy\"\npersona = \"default\"\nhome_dir = \"{home_dir.as_posix()}\"\n\n# --- LLM Provider ---\n# Supported providers: openai, anthropic, google, deepseek, openrouter, ollama, custom\nllm_provider = \"openai\"\nllm_model = \"gpt-4o-mini\"\nllm_base_url = \"https://api.openai.com/v1\"\n# llm_api_key - set via REMEDY_LLM_API_KEY env var or uncomment below:\n# llm_api_key = \"sk-...\"\n\n# Search paths for bundled + user skills\nskills_dir = []\n\n# SQLite memory database\nmemory_db_path = \"{home_dir.joinpath('memory.db').as_posix()}\"\n\n# Channels to enable: cli, telegram, discord, slack, web, api\nenabled_channels = [\"cli\"]\n\n# MCP server configurations\nmcp_servers = []\n\n# Learning loop\nallow_skill_creation = true\nauto_approve_threshold = 0.8\n\n# Logging\nlog_level = \"INFO\"\n\n[gateway]\nheartbeat_interval = 60\nrate_limit = 120\n\n[execution]\ndefault_timeout = 30\nmax_retries = 3\nretry_backoff = 1.0\n\n[telegram]\nbot_token = \"\"\n\n[discord]\nbot_token = \"\"\nchannel_id = \"\"\n\n[slack]\nbot_token = \"\"\nchannel_id = \"\"\n\"\"\"\n\n\ndef create_default_config(home_dir: Path | None = None) -> Path:\n \"\"\"Create a default config file in the home directory.\"\"\"\n hd = (home_dir or Path(\"~/.remedy\")).expanduser()\n hd.mkdir(parents=True, exist_ok=True)\n config_path = hd / \"config.toml\"\n if not config_path.exists():\n config_path.write_text(generate_default_config(hd), encoding=\"utf-8\")\n return config_path\n"}}, "src/remedy/interfaces/api_support.py": {"0fd1b05f88fa0a7cb81d91f349b80ec4f8f6719b": {"t": 1785747517.9432735, "n": 48386, "h": "9a91a062acf42da6", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/reset\",\n \"description\": (\n \"Full reset of this session (history, plans, brief, attachments) \"\n \"— stay in the same tab; like a new chat without switching\"\n ),\n \"aliases\": [\"/clear\"],\n \"arguments\": None,\n },\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|cancel|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/security-status\", \"description\": \"Show partner control settings (approval mode, web tools, timeouts)\", \"aliases\": [\"/security\", \"/secstatus\"], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/reset\", \"/clear\"):\n # Full in-place clean slate — same session id, no jump to another tab.\n if not session_id:\n return {\n \"text\": \"No active session to reset. Open a chat first, or use `/new`.\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available; cannot reset session.\"}\n try:\n from remedy.core.session_reset import full_reset_session\n\n stats = await full_reset_session(\n str(session_id), memory, runtime=runtime\n )\n except Exception as exc:\n return {\"text\": f\"Could not reset session: {exc}\"}\n if not stats.get(\"ok\", True):\n return {\n \"text\": f\"Could not reset session: {stats.get('error') or 'unknown error'}\",\n }\n n = int(stats.get(\"messages\") or 0)\n bits = [f\"{n} message{'s' if n != 1 else ''}\"]\n if stats.get(\"plans\"):\n bits.append(f\"{stats['plans']} plan(s)\")\n if stats.get(\"checkpoints\"):\n bits.append(f\"{stats['checkpoints']} checkpoint(s)\")\n if stats.get(\"attachments_purged\"):\n bits.append(\"attachments\")\n if stats.get(\"memory_entries\"):\n bits.append(f\"{stats['memory_entries']} session note(s)\")\n detail = \", \".join(bits)\n return {\n \"text\": (\n f\"Session fully reset ({detail}). \"\n \"Same session tab — history, plans, brief, goals buffer, and \"\n \"attachments cleared. Durable memory (/remember) kept. \"\n \"Send a message to start as if new.\"\n ),\n \"action\": \"reset_session\",\n \"cleared\": n,\n \"stats\": stats,\n }\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n meta = q.get(\"metabolism\") or {}\n if meta:\n quality_line += (\n f\"\\n\\n**Metabolism** · tier L{meta.get('last_tier', '?')} · \"\n f\"EU {meta.get('evidence_units', 0)} · DU {meta.get('decision_units', 0)} · \"\n f\"waste_tok {meta.get('waste_tokens', 0)} · \"\n f\"spread×{meta.get('force_spread_count', 0)} · \"\n f\"shadow {meta.get('shadow_catch_count', 0)} · \"\n f\"verify {meta.get('verify_catch_count', 0)} · \"\n f\"IR steps {meta.get('ir_step_count', 0)}\"\n )\n with contextlib.suppress(Exception):\n from remedy.core.metabolism.turn import metabolism_public_snapshot\n\n # Lean: last_actions are still present; skip recent/list thrash\n msnap = metabolism_public_snapshot(str(sid or \"\"), lean=True)\n gov = (msnap.get(\"governor\") or {}).get(\"last_actions\") or []\n if gov:\n quality_line += f\"\\nGovernor last: {', '.join(gov[:8])}\"\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n # Time Crystal life horizon (explicit pins)\n with contextlib.suppress(Exception):\n from remedy.core.metabolism.time_crystal import get_time_crystal\n\n sid_p = \"\"\n if runtime is not None:\n sid_p = str(getattr(runtime, \"_session_id\", \"\") or \"\")\n tc = get_time_crystal(sid_p or \"_default\")\n for f in touched[:8]:\n fact = getattr(f, \"fact\", None) or str(f)\n tc.admit(str(fact)[:400], horizon=\"life\", source=\"pin\")\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"cancel\"):\n pid = rest[6:].strip()\n plan = store.get(pid) if pid else store.latest_for_session(None, actionable_only=True)\n if plan is None and not pid:\n # Prefer newest non-terminal overall when no id\n plans = store.list_plans(limit=20)\n plan = next(\n (p for p in plans if p.status in (\"draft\", \"approved\", \"active\")),\n None,\n )\n if plan is None:\n return {\"text\": \"No plan to cancel. Save one first, or pass `/plan cancel <id>`.\"}\n cancelled = store.set_status(plan.id, \"cancelled\")\n if cancelled is None:\n return {\"text\": \"Failed to cancel plan.\"}\n return {\n \"text\": f\"Plan **cancelled**: {cancelled.title} (`{cancelled.id}`).\\n\\n\"\n \"It will no longer stick in the Plan banner. Start a new plan in **Plan** mode when ready.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n if stripped in (\"/security-status\", \"/security\", \"/secstatus\"):\n \"\"\"Report partner control / security settings from config and live runtime.\"\"\"\n cfg = load_config()\n lines = [\"## 🔐 Security Status\\n\"]\n\n # -- Approval mode\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n # Check live runtime override if available\n if runtime is not None and hasattr(runtime, \"_approval_mode\"):\n am = str(runtime._approval_mode).strip().lower()\n emoji = \"✅\" if am == \"auto\" else \"⛔\"\n lines.append(f\"{emoji} **Approval Mode:** `{am}` — {'Full owner power (auto-approve)' if am == 'auto' else 'Ask before high-impact actions'}\")\n\n # -- Web tools\n web = bool(cfg.get(\"web_tools_enabled\", False))\n emoji = \"🌐\" if web else \"🚫\"\n lines.append(f\"{emoji} **Web Tools:** `{'enabled' if web else 'disabled'}`\")\n\n # -- Access scope\n scope = str(cfg.get(\"access_scope\") or \"home\")\n lines.append(f\"📁 **Access Scope:** `{scope}`\")\n\n # -- HTTP bootstrap\n http = cfg.get(\"http_bootstrap\")\n if http is not None:\n hb = \"enabled\" if bool(http) else \"disabled (IPC-only)\"\n lines.append(f\"🔌 **HTTP Bootstrap:** `{hb}`\")\n\n # -- Auto-approve threshold\n aat = cfg.get(\"auto_approve_threshold\")\n if aat is not None:\n lines.append(f\"⚖️ **Auto-Approve Threshold:** `{aat}`\")\n\n # -- Thinking level\n tl = str(cfg.get(\"thinking_level\") or \"high\").strip().lower()\n lines.append(f\"🧠 **Thinking Level:** `{tl}`\")\n\n # -- Tool process visibility\n tp = cfg.get(\"tool_process\")\n if tp is not None:\n lines.append(f\"🔧 **Tool Process:** `{tp}`\")\n\n # -- Harness mode\n hm = str(cfg.get(\"harness_mode\") or \"auto\").strip().lower()\n lines.append(f\"⚙️ **Harness Mode:** `{hm}`\")\n\n # -- Execution timeout (if available in config or runtime)\n timeout = cfg.get(\"execution_timeout\") or cfg.get(\"tool_timeout\")\n if timeout is not None:\n lines.append(f\"⏱️ **Execution Timeout:** `{timeout}s`\")\n\n # -- Vision\n ve = bool(cfg.get(\"vision_enabled\", True))\n lines.append(f\"👁️ **Vision Decoder:** `{'enabled' if ve else 'disabled'}`\")\n\n # -- Skills budget\n sb = cfg.get(\"skills_active_budget\")\n if sb is not None:\n lines.append(f\"📦 **Skills Active Budget:** `{sb}`\")\n\n lines.append(\"\\nRun `/help` for all commands.\")\n return {\"text\": \"\\n\".join(lines)}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\n\n Honors ``REMEDY_HOME`` so tests and alternate homes never write into the\n real ``~/.remedy`` by accident.\n \"\"\"\n env_home = str(os.environ.get(\"REMEDY_HOME\") or \"\").strip()\n if env_home:\n return Path(env_home).expanduser().resolve() / \"config.toml\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n # When REMEDY_HOME is set (tests), never fall through to the real user home.\n if str(os.environ.get(\"REMEDY_HOME\") or \"\").strip():\n return None\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n provider_override: str | None = None,\n llm_only: bool = False,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n\n *provider_override* / *model_override*: per-session picks (status-bar switch).\n Without these, a session on Grok while global config is still DeepSeek would\n send ``model=grok-4.5`` to the DeepSeek base URL every turn.\n\n *llm_only*: when True (chat turns), only bind provider/model/url/key — do not\n thrash project_path / harness / approval_mode mid concurrent streams.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n cfg_provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n ).strip().lower()\n provider = str(\n (provider_override or \"\").strip()\n or cfg_provider\n or \"openai\"\n ).strip().lower()\n model = str(\n (model_override or \"\").strip()\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n # Only reuse global base_url when still on the same provider; otherwise\n # normalize_llm_settings must pick the provider's default API host.\n if provider == cfg_provider:\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n else:\n base_url = \"\"\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(\n provider, model, base_url or None\n )\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n if llm_only:\n # Chat/messenger turn: only LLM binding (safe under _llm_turn_lock).\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n # Full sync (settings save / cold start): partner trust + project + harness.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "550b0506ba50465e98560e5225794a642d934979": {"t": 1785768470.822547, "n": 48386, "h": "9a91a062acf42da6", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/reset\",\n \"description\": (\n \"Full reset of this session (history, plans, brief, attachments) \"\n \"— stay in the same tab; like a new chat without switching\"\n ),\n \"aliases\": [\"/clear\"],\n \"arguments\": None,\n },\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|cancel|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/security-status\", \"description\": \"Show partner control settings (approval mode, web tools, timeouts)\", \"aliases\": [\"/security\", \"/secstatus\"], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/reset\", \"/clear\"):\n # Full in-place clean slate — same session id, no jump to another tab.\n if not session_id:\n return {\n \"text\": \"No active session to reset. Open a chat first, or use `/new`.\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available; cannot reset session.\"}\n try:\n from remedy.core.session_reset import full_reset_session\n\n stats = await full_reset_session(\n str(session_id), memory, runtime=runtime\n )\n except Exception as exc:\n return {\"text\": f\"Could not reset session: {exc}\"}\n if not stats.get(\"ok\", True):\n return {\n \"text\": f\"Could not reset session: {stats.get('error') or 'unknown error'}\",\n }\n n = int(stats.get(\"messages\") or 0)\n bits = [f\"{n} message{'s' if n != 1 else ''}\"]\n if stats.get(\"plans\"):\n bits.append(f\"{stats['plans']} plan(s)\")\n if stats.get(\"checkpoints\"):\n bits.append(f\"{stats['checkpoints']} checkpoint(s)\")\n if stats.get(\"attachments_purged\"):\n bits.append(\"attachments\")\n if stats.get(\"memory_entries\"):\n bits.append(f\"{stats['memory_entries']} session note(s)\")\n detail = \", \".join(bits)\n return {\n \"text\": (\n f\"Session fully reset ({detail}). \"\n \"Same session tab — history, plans, brief, goals buffer, and \"\n \"attachments cleared. Durable memory (/remember) kept. \"\n \"Send a message to start as if new.\"\n ),\n \"action\": \"reset_session\",\n \"cleared\": n,\n \"stats\": stats,\n }\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n meta = q.get(\"metabolism\") or {}\n if meta:\n quality_line += (\n f\"\\n\\n**Metabolism** · tier L{meta.get('last_tier', '?')} · \"\n f\"EU {meta.get('evidence_units', 0)} · DU {meta.get('decision_units', 0)} · \"\n f\"waste_tok {meta.get('waste_tokens', 0)} · \"\n f\"spread×{meta.get('force_spread_count', 0)} · \"\n f\"shadow {meta.get('shadow_catch_count', 0)} · \"\n f\"verify {meta.get('verify_catch_count', 0)} · \"\n f\"IR steps {meta.get('ir_step_count', 0)}\"\n )\n with contextlib.suppress(Exception):\n from remedy.core.metabolism.turn import metabolism_public_snapshot\n\n # Lean: last_actions are still present; skip recent/list thrash\n msnap = metabolism_public_snapshot(str(sid or \"\"), lean=True)\n gov = (msnap.get(\"governor\") or {}).get(\"last_actions\") or []\n if gov:\n quality_line += f\"\\nGovernor last: {', '.join(gov[:8])}\"\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n # Time Crystal life horizon (explicit pins)\n with contextlib.suppress(Exception):\n from remedy.core.metabolism.time_crystal import get_time_crystal\n\n sid_p = \"\"\n if runtime is not None:\n sid_p = str(getattr(runtime, \"_session_id\", \"\") or \"\")\n tc = get_time_crystal(sid_p or \"_default\")\n for f in touched[:8]:\n fact = getattr(f, \"fact\", None) or str(f)\n tc.admit(str(fact)[:400], horizon=\"life\", source=\"pin\")\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"cancel\"):\n pid = rest[6:].strip()\n plan = store.get(pid) if pid else store.latest_for_session(None, actionable_only=True)\n if plan is None and not pid:\n # Prefer newest non-terminal overall when no id\n plans = store.list_plans(limit=20)\n plan = next(\n (p for p in plans if p.status in (\"draft\", \"approved\", \"active\")),\n None,\n )\n if plan is None:\n return {\"text\": \"No plan to cancel. Save one first, or pass `/plan cancel <id>`.\"}\n cancelled = store.set_status(plan.id, \"cancelled\")\n if cancelled is None:\n return {\"text\": \"Failed to cancel plan.\"}\n return {\n \"text\": f\"Plan **cancelled**: {cancelled.title} (`{cancelled.id}`).\\n\\n\"\n \"It will no longer stick in the Plan banner. Start a new plan in **Plan** mode when ready.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n if stripped in (\"/security-status\", \"/security\", \"/secstatus\"):\n \"\"\"Report partner control / security settings from config and live runtime.\"\"\"\n cfg = load_config()\n lines = [\"## 🔐 Security Status\\n\"]\n\n # -- Approval mode\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n # Check live runtime override if available\n if runtime is not None and hasattr(runtime, \"_approval_mode\"):\n am = str(runtime._approval_mode).strip().lower()\n emoji = \"✅\" if am == \"auto\" else \"⛔\"\n lines.append(f\"{emoji} **Approval Mode:** `{am}` — {'Full owner power (auto-approve)' if am == 'auto' else 'Ask before high-impact actions'}\")\n\n # -- Web tools\n web = bool(cfg.get(\"web_tools_enabled\", False))\n emoji = \"🌐\" if web else \"🚫\"\n lines.append(f\"{emoji} **Web Tools:** `{'enabled' if web else 'disabled'}`\")\n\n # -- Access scope\n scope = str(cfg.get(\"access_scope\") or \"home\")\n lines.append(f\"📁 **Access Scope:** `{scope}`\")\n\n # -- HTTP bootstrap\n http = cfg.get(\"http_bootstrap\")\n if http is not None:\n hb = \"enabled\" if bool(http) else \"disabled (IPC-only)\"\n lines.append(f\"🔌 **HTTP Bootstrap:** `{hb}`\")\n\n # -- Auto-approve threshold\n aat = cfg.get(\"auto_approve_threshold\")\n if aat is not None:\n lines.append(f\"⚖️ **Auto-Approve Threshold:** `{aat}`\")\n\n # -- Thinking level\n tl = str(cfg.get(\"thinking_level\") or \"high\").strip().lower()\n lines.append(f\"🧠 **Thinking Level:** `{tl}`\")\n\n # -- Tool process visibility\n tp = cfg.get(\"tool_process\")\n if tp is not None:\n lines.append(f\"🔧 **Tool Process:** `{tp}`\")\n\n # -- Harness mode\n hm = str(cfg.get(\"harness_mode\") or \"auto\").strip().lower()\n lines.append(f\"⚙️ **Harness Mode:** `{hm}`\")\n\n # -- Execution timeout (if available in config or runtime)\n timeout = cfg.get(\"execution_timeout\") or cfg.get(\"tool_timeout\")\n if timeout is not None:\n lines.append(f\"⏱️ **Execution Timeout:** `{timeout}s`\")\n\n # -- Vision\n ve = bool(cfg.get(\"vision_enabled\", True))\n lines.append(f\"👁️ **Vision Decoder:** `{'enabled' if ve else 'disabled'}`\")\n\n # -- Skills budget\n sb = cfg.get(\"skills_active_budget\")\n if sb is not None:\n lines.append(f\"📦 **Skills Active Budget:** `{sb}`\")\n\n lines.append(\"\\nRun `/help` for all commands.\")\n return {\"text\": \"\\n\".join(lines)}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\n\n Honors ``REMEDY_HOME`` so tests and alternate homes never write into the\n real ``~/.remedy`` by accident.\n \"\"\"\n env_home = str(os.environ.get(\"REMEDY_HOME\") or \"\").strip()\n if env_home:\n return Path(env_home).expanduser().resolve() / \"config.toml\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n # When REMEDY_HOME is set (tests), never fall through to the real user home.\n if str(os.environ.get(\"REMEDY_HOME\") or \"\").strip():\n return None\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n provider_override: str | None = None,\n llm_only: bool = False,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n\n *provider_override* / *model_override*: per-session picks (status-bar switch).\n Without these, a session on Grok while global config is still DeepSeek would\n send ``model=grok-4.5`` to the DeepSeek base URL every turn.\n\n *llm_only*: when True (chat turns), only bind provider/model/url/key — do not\n thrash project_path / harness / approval_mode mid concurrent streams.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n cfg_provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n ).strip().lower()\n provider = str(\n (provider_override or \"\").strip()\n or cfg_provider\n or \"openai\"\n ).strip().lower()\n model = str(\n (model_override or \"\").strip()\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n # Only reuse global base_url when still on the same provider; otherwise\n # normalize_llm_settings must pick the provider's default API host.\n if provider == cfg_provider:\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n else:\n base_url = \"\"\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(\n provider, model, base_url or None\n )\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n if llm_only:\n # Chat/messenger turn: only LLM binding (safe under _llm_turn_lock).\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n # Full sync (settings save / cold start): partner trust + project + harness.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "374678e74c11e37c2483d410f32f121c3555b4fa": {"t": 1785768473.0420992, "n": 48387, "h": "85ecae035f6241eb", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/reset\",\n \"description\": (\n \"Full reset of this session (history, plans, brief, attachments) \"\n \"— stay in the same tab; like a new chat without switching\"\n ),\n \"aliases\": [\"/clear\"],\n \"arguments\": None,\n },\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|cancel|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/security-status\", \"description\": \"Show partner control settings (approval mode, web tools, timeouts)\", \"aliases\": [\"/security\", \"/secstatus\"], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/reset\", \"/clear\"):\n # Full in-place clean slate — same session id, no jump to another tab.\n if not session_id:\n return {\n \"text\": \"No active session to reset. Open a chat first, or use `/new`.\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available; cannot reset session.\"}\n try:\n from remedy.core.session_reset import full_reset_session\n\n stats = await full_reset_session(\n str(session_id), memory, runtime=runtime\n )\n except Exception as exc:\n return {\"text\": f\"Could not reset session: {exc}\"}\n if not stats.get(\"ok\", True):\n return {\n \"text\": f\"Could not reset session: {stats.get('error') or 'unknown error'}\",\n }\n n = int(stats.get(\"messages\") or 0)\n bits = [f\"{n} message{'s' if n != 1 else ''}\"]\n if stats.get(\"plans\"):\n bits.append(f\"{stats['plans']} plan(s)\")\n if stats.get(\"checkpoints\"):\n bits.append(f\"{stats['checkpoints']} checkpoint(s)\")\n if stats.get(\"attachments_purged\"):\n bits.append(\"attachments\")\n if stats.get(\"memory_entries\"):\n bits.append(f\"{stats['memory_entries']} session note(s)\")\n detail = \", \".join(bits)\n return {\n \"text\": (\n f\"Session fully reset ({detail}). \"\n \"Same session tab — history, plans, brief, goals buffer, and \"\n \"attachments cleared. Durable memory (/remember) kept. \"\n \"Send a message to start as if new.\"\n ),\n \"action\": \"reset_session\",\n \"cleared\": n,\n \"stats\": stats,\n }\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n meta = q.get(\"metabolism\") or {}\n if meta:\n quality_line += (\n f\"\\n\\n**Metabolism** · tier L{meta.get('last_tier', '?')} · \"\n f\"EU {meta.get('evidence_units', 0)} · DU {meta.get('decision_units', 0)} · \"\n f\"waste_tok {meta.get('waste_tokens', 0)} · \"\n f\"spread×{meta.get('force_spread_count', 0)} · \"\n f\"shadow {meta.get('shadow_catch_count', 0)} · \"\n f\"verify {meta.get('verify_catch_count', 0)} · \"\n f\"IR steps {meta.get('ir_step_count', 0)}\"\n )\n with contextlib.suppress(Exception):\n from remedy.core.metabolism.turn import metabolism_public_snapshot\n\n # Lean: last_actions are still present; skip recent/list thrash\n msnap = metabolism_public_snapshot(str(sid or \"\"), lean=True)\n gov = (msnap.get(\"governor\") or {}).get(\"last_actions\") or []\n if gov:\n quality_line += f\"\\nGovernor last: {', '.join(gov[:8])}\"\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n # Time Crystal life horizon (explicit pins)\n with contextlib.suppress(Exception):\n from remedy.core.metabolism.time_crystal import get_time_crystal\n\n sid_p = \"\"\n if runtime is not None:\n sid_p = str(getattr(runtime, \"_session_id\", \"\") or \"\")\n tc = get_time_crystal(sid_p or \"_default\")\n for f in touched[:8]:\n fact = getattr(f, \"fact\", None) or str(f)\n tc.admit(str(fact)[:400], horizon=\"life\", source=\"pin\")\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"cancel\"):\n pid = rest[6:].strip()\n plan = store.get(pid) if pid else store.latest_for_session(None, actionable_only=True)\n if plan is None and not pid:\n # Prefer newest non-terminal overall when no id\n plans = store.list_plans(limit=20)\n plan = next(\n (p for p in plans if p.status in (\"draft\", \"approved\", \"active\")),\n None,\n )\n if plan is None:\n return {\"text\": \"No plan to cancel. Save one first, or pass `/plan cancel <id>`.\"}\n cancelled = store.set_status(plan.id, \"cancelled\")\n if cancelled is None:\n return {\"text\": \"Failed to cancel plan.\"}\n return {\n \"text\": f\"Plan **cancelled**: {cancelled.title} (`{cancelled.id}`).\\n\\n\"\n \"It will no longer stick in the Plan banner. Start a new plan in **Plan** mode when ready.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n if stripped in (\"/security-status\", \"/security\", \"/secstatus\"):\n \"\"\"Report partner control / security settings from config and live runtime.\"\"\"\n cfg = load_config()\n lines = [\"## 🔐 Security Status\\n\"]\n\n # -- Approval mode\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n # Check live runtime override if available\n if runtime is not None and hasattr(runtime, \"_approval_mode\"):\n am = str(runtime._approval_mode).strip().lower()\n emoji = \"✅\" if am == \"auto\" else \"⛔\"\n lines.append(f\"{emoji} **Approval Mode:** `{am}` — {'Full owner power (auto-approve)' if am == 'auto' else 'Ask before high-impact actions'}\")\n\n # -- Web tools\n web = bool(cfg.get(\"web_tools_enabled\", False))\n emoji = \"🌐\" if web else \"🚫\"\n lines.append(f\"{emoji} **Web Tools:** `{'enabled' if web else 'disabled'}`\")\n\n # -- Access scope\n scope = str(cfg.get(\"access_scope\") or \"home\")\n lines.append(f\"📁 **Access Scope:** `{scope}`\")\n\n # -- HTTP bootstrap\n http = cfg.get(\"http_bootstrap\")\n if http is not None:\n hb = \"enabled\" if bool(http) else \"disabled (IPC-only)\"\n lines.append(f\"🔌 **HTTP Bootstrap:** `{hb}`\")\n\n # -- Auto-approve threshold\n aat = cfg.get(\"auto_approve_threshold\")\n if aat is not None:\n lines.append(f\"⚖️ **Auto-Approve Threshold:** `{aat}`\")\n\n # -- Thinking level\n tl = str(cfg.get(\"thinking_level\") or \"high\").strip().lower()\n lines.append(f\"🧠 **Thinking Level:** `{tl}`\")\n\n # -- Tool process visibility\n tp = cfg.get(\"tool_process\")\n if tp is not None:\n lines.append(f\"🔧 **Tool Process:** `{tp}`\")\n\n # -- Harness mode\n hm = str(cfg.get(\"harness_mode\") or \"auto\").strip().lower()\n lines.append(f\"⚙️ **Harness Mode:** `{hm}`\")\n\n # -- Execution timeout (if available in config or runtime)\n timeout = cfg.get(\"execution_timeout\") or cfg.get(\"tool_timeout\")\n if timeout is not None:\n lines.append(f\"⏱️ **Execution Timeout:** `{timeout}s`\")\n\n # -- Vision\n ve = bool(cfg.get(\"vision_enabled\", True))\n lines.append(f\"👁️ **Vision Decoder:** `{'enabled' if ve else 'disabled'}`\")\n\n # -- Skills budget\n sb = cfg.get(\"skills_active_budget\")\n if sb is not None:\n lines.append(f\"📦 **Skills Active Budget:** `{sb}`\")\n\n lines.append(f\"\\nRun `/help` for all commands.\")\n return {\"text\": \"\\n\".join(lines)}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\n\n Honors ``REMEDY_HOME`` so tests and alternate homes never write into the\n real ``~/.remedy`` by accident.\n \"\"\"\n env_home = str(os.environ.get(\"REMEDY_HOME\") or \"\").strip()\n if env_home:\n return Path(env_home).expanduser().resolve() / \"config.toml\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n # When REMEDY_HOME is set (tests), never fall through to the real user home.\n if str(os.environ.get(\"REMEDY_HOME\") or \"\").strip():\n return None\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n provider_override: str | None = None,\n llm_only: bool = False,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n\n *provider_override* / *model_override*: per-session picks (status-bar switch).\n Without these, a session on Grok while global config is still DeepSeek would\n send ``model=grok-4.5`` to the DeepSeek base URL every turn.\n\n *llm_only*: when True (chat turns), only bind provider/model/url/key — do not\n thrash project_path / harness / approval_mode mid concurrent streams.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n cfg_provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n ).strip().lower()\n provider = str(\n (provider_override or \"\").strip()\n or cfg_provider\n or \"openai\"\n ).strip().lower()\n model = str(\n (model_override or \"\").strip()\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n # Only reuse global base_url when still on the same provider; otherwise\n # normalize_llm_settings must pick the provider's default API host.\n if provider == cfg_provider:\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n else:\n base_url = \"\"\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(\n provider, model, base_url or None\n )\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n if llm_only:\n # Chat/messenger turn: only LLM binding (safe under _llm_turn_lock).\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n # Full sync (settings save / cold start): partner trust + project + harness.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "d1a0251119ae1bc833e0c9ada2746d97ef0a1326": {"t": 1785768474.9110153, "n": 48292, "h": "d37bbbaef1653af4", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/reset\",\n \"description\": (\n \"Full reset of this session (history, plans, brief, attachments) \"\n \"— stay in the same tab; like a new chat without switching\"\n ),\n \"aliases\": [\"/clear\"],\n \"arguments\": None,\n },\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|cancel|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/security-status\", \"description\": \"Show partner control settings (approval mode, web tools, timeouts)\", \"aliases\": [\"/security\", \"/secstatus\"], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/reset\", \"/clear\"):\n # Full in-place clean slate — same session id, no jump to another tab.\n if not session_id:\n return {\n \"text\": \"No active session to reset. Open a chat first, or use `/new`.\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available; cannot reset session.\"}\n try:\n from remedy.core.session_reset import full_reset_session\n\n stats = await full_reset_session(\n str(session_id), memory, runtime=runtime\n )\n except Exception as exc:\n return {\"text\": f\"Could not reset session: {exc}\"}\n if not stats.get(\"ok\", True):\n return {\n \"text\": f\"Could not reset session: {stats.get('error') or 'unknown error'}\",\n }\n n = int(stats.get(\"messages\") or 0)\n bits = [f\"{n} message{'s' if n != 1 else ''}\"]\n if stats.get(\"plans\"):\n bits.append(f\"{stats['plans']} plan(s)\")\n if stats.get(\"checkpoints\"):\n bits.append(f\"{stats['checkpoints']} checkpoint(s)\")\n if stats.get(\"attachments_purged\"):\n bits.append(\"attachments\")\n if stats.get(\"memory_entries\"):\n bits.append(f\"{stats['memory_entries']} session note(s)\")\n detail = \", \".join(bits)\n return {\n \"text\": (\n f\"Session fully reset ({detail}). \"\n \"Same session tab — history, plans, brief, goals buffer, and \"\n \"attachments cleared. Durable memory (/remember) kept. \"\n \"Send a message to start as if new.\"\n ),\n \"action\": \"reset_session\",\n \"cleared\": n,\n \"stats\": stats,\n }\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n meta = q.get(\"metabolism\") or {}\n if meta:\n quality_line += (\n f\"\\n\\n**Metabolism** · tier L{meta.get('last_tier', '?')} · \"\n f\"EU {meta.get('evidence_units', 0)} · DU {meta.get('decision_units', 0)} · \"\n f\"waste_tok {meta.get('waste_tokens', 0)} · \"\n f\"spread×{meta.get('force_spread_count', 0)} · \"\n f\"shadow {meta.get('shadow_catch_count', 0)} · \"\n f\"verify {meta.get('verify_catch_count', 0)} · \"\n f\"IR steps {meta.get('ir_step_count', 0)}\"\n )\n with contextlib.suppress(Exception):\n from remedy.core.metabolism.turn import metabolism_public_snapshot\n\n msnap = metabolism_public_snapshot(str(sid or \"\"))\n gov = (msnap.get(\"governor\") or {}).get(\"last_actions\") or []\n if gov:\n quality_line += f\"\\nGovernor last: {', '.join(gov[:8])}\"\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n # Time Crystal life horizon (explicit pins)\n with contextlib.suppress(Exception):\n from remedy.core.metabolism.time_crystal import get_time_crystal\n\n sid_p = \"\"\n if runtime is not None:\n sid_p = str(getattr(runtime, \"_session_id\", \"\") or \"\")\n tc = get_time_crystal(sid_p or \"_default\")\n for f in touched[:8]:\n fact = getattr(f, \"fact\", None) or str(f)\n tc.admit(str(fact)[:400], horizon=\"life\", source=\"pin\")\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"cancel\"):\n pid = rest[6:].strip()\n plan = store.get(pid) if pid else store.latest_for_session(None, actionable_only=True)\n if plan is None and not pid:\n # Prefer newest non-terminal overall when no id\n plans = store.list_plans(limit=20)\n plan = next(\n (p for p in plans if p.status in (\"draft\", \"approved\", \"active\")),\n None,\n )\n if plan is None:\n return {\"text\": \"No plan to cancel. Save one first, or pass `/plan cancel <id>`.\"}\n cancelled = store.set_status(plan.id, \"cancelled\")\n if cancelled is None:\n return {\"text\": \"Failed to cancel plan.\"}\n return {\n \"text\": f\"Plan **cancelled**: {cancelled.title} (`{cancelled.id}`).\\n\\n\"\n \"It will no longer stick in the Plan banner. Start a new plan in **Plan** mode when ready.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n if stripped in (\"/security-status\", \"/security\", \"/secstatus\"):\n \"\"\"Report partner control / security settings from config and live runtime.\"\"\"\n cfg = load_config()\n lines = [\"## 🔐 Security Status\\n\"]\n\n # -- Approval mode\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n # Check live runtime override if available\n if runtime is not None and hasattr(runtime, \"_approval_mode\"):\n am = str(runtime._approval_mode).strip().lower()\n emoji = \"✅\" if am == \"auto\" else \"⛔\"\n lines.append(f\"{emoji} **Approval Mode:** `{am}` — {'Full owner power (auto-approve)' if am == 'auto' else 'Ask before high-impact actions'}\")\n\n # -- Web tools\n web = bool(cfg.get(\"web_tools_enabled\", False))\n emoji = \"🌐\" if web else \"🚫\"\n lines.append(f\"{emoji} **Web Tools:** `{'enabled' if web else 'disabled'}`\")\n\n # -- Access scope\n scope = str(cfg.get(\"access_scope\") or \"home\")\n lines.append(f\"📁 **Access Scope:** `{scope}`\")\n\n # -- HTTP bootstrap\n http = cfg.get(\"http_bootstrap\")\n if http is not None:\n hb = \"enabled\" if bool(http) else \"disabled (IPC-only)\"\n lines.append(f\"🔌 **HTTP Bootstrap:** `{hb}`\")\n\n # -- Auto-approve threshold\n aat = cfg.get(\"auto_approve_threshold\")\n if aat is not None:\n lines.append(f\"⚖️ **Auto-Approve Threshold:** `{aat}`\")\n\n # -- Thinking level\n tl = str(cfg.get(\"thinking_level\") or \"high\").strip().lower()\n lines.append(f\"🧠 **Thinking Level:** `{tl}`\")\n\n # -- Tool process visibility\n tp = cfg.get(\"tool_process\")\n if tp is not None:\n lines.append(f\"🔧 **Tool Process:** `{tp}`\")\n\n # -- Harness mode\n hm = str(cfg.get(\"harness_mode\") or \"auto\").strip().lower()\n lines.append(f\"⚙️ **Harness Mode:** `{hm}`\")\n\n # -- Execution timeout (if available in config or runtime)\n timeout = cfg.get(\"execution_timeout\") or cfg.get(\"tool_timeout\")\n if timeout is not None:\n lines.append(f\"⏱️ **Execution Timeout:** `{timeout}s`\")\n\n # -- Vision\n ve = bool(cfg.get(\"vision_enabled\", True))\n lines.append(f\"👁️ **Vision Decoder:** `{'enabled' if ve else 'disabled'}`\")\n\n # -- Skills budget\n sb = cfg.get(\"skills_active_budget\")\n if sb is not None:\n lines.append(f\"📦 **Skills Active Budget:** `{sb}`\")\n\n lines.append(f\"\\nRun `/help` for all commands.\")\n return {\"text\": \"\\n\".join(lines)}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\n\n Honors ``REMEDY_HOME`` so tests and alternate homes never write into the\n real ``~/.remedy`` by accident.\n \"\"\"\n env_home = str(os.environ.get(\"REMEDY_HOME\") or \"\").strip()\n if env_home:\n return Path(env_home).expanduser().resolve() / \"config.toml\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n # When REMEDY_HOME is set (tests), never fall through to the real user home.\n if str(os.environ.get(\"REMEDY_HOME\") or \"\").strip():\n return None\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n provider_override: str | None = None,\n llm_only: bool = False,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n\n *provider_override* / *model_override*: per-session picks (status-bar switch).\n Without these, a session on Grok while global config is still DeepSeek would\n send ``model=grok-4.5`` to the DeepSeek base URL every turn.\n\n *llm_only*: when True (chat turns), only bind provider/model/url/key — do not\n thrash project_path / harness / approval_mode mid concurrent streams.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n cfg_provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n ).strip().lower()\n provider = str(\n (provider_override or \"\").strip()\n or cfg_provider\n or \"openai\"\n ).strip().lower()\n model = str(\n (model_override or \"\").strip()\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n # Only reuse global base_url when still on the same provider; otherwise\n # normalize_llm_settings must pick the provider's default API host.\n if provider == cfg_provider:\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n else:\n base_url = \"\"\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(\n provider, model, base_url or None\n )\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n if llm_only:\n # Chat/messenger turn: only LLM binding (safe under _llm_turn_lock).\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n # Full sync (settings save / cold start): partner trust + project + harness.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "711cc4a1c739df97e7595fa0ef84f290d4e25240": {"t": 1785768477.4190187, "n": 47728, "h": "8fe59b1dd4797030", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/reset\",\n \"description\": (\n \"Full reset of this session (history, plans, brief, attachments) \"\n \"— stay in the same tab; like a new chat without switching\"\n ),\n \"aliases\": [\"/clear\"],\n \"arguments\": None,\n },\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|cancel|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/security-status\", \"description\": \"Show partner control settings (approval mode, web tools, timeouts)\", \"aliases\": [\"/security\", \"/secstatus\"], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/reset\", \"/clear\"):\n # Full in-place clean slate — same session id, no jump to another tab.\n if not session_id:\n return {\n \"text\": \"No active session to reset. Open a chat first, or use `/new`.\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available; cannot reset session.\"}\n try:\n from remedy.core.session_reset import full_reset_session\n\n stats = await full_reset_session(\n str(session_id), memory, runtime=runtime\n )\n except Exception as exc:\n return {\"text\": f\"Could not reset session: {exc}\"}\n if not stats.get(\"ok\", True):\n return {\n \"text\": f\"Could not reset session: {stats.get('error') or 'unknown error'}\",\n }\n n = int(stats.get(\"messages\") or 0)\n bits = [f\"{n} message{'s' if n != 1 else ''}\"]\n if stats.get(\"plans\"):\n bits.append(f\"{stats['plans']} plan(s)\")\n if stats.get(\"checkpoints\"):\n bits.append(f\"{stats['checkpoints']} checkpoint(s)\")\n if stats.get(\"attachments_purged\"):\n bits.append(\"attachments\")\n if stats.get(\"memory_entries\"):\n bits.append(f\"{stats['memory_entries']} session note(s)\")\n detail = \", \".join(bits)\n return {\n \"text\": (\n f\"Session fully reset ({detail}). \"\n \"Same session tab — history, plans, brief, goals buffer, and \"\n \"attachments cleared. Durable memory (/remember) kept. \"\n \"Send a message to start as if new.\"\n ),\n \"action\": \"reset_session\",\n \"cleared\": n,\n \"stats\": stats,\n }\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n meta = q.get(\"metabolism\") or {}\n if meta:\n quality_line += (\n f\"\\n\\n**Metabolism** · tier L{meta.get('last_tier', '?')} · \"\n f\"EU {meta.get('evidence_units', 0)} · DU {meta.get('decision_units', 0)} · \"\n f\"waste_tok {meta.get('waste_tokens', 0)} · \"\n f\"spread×{meta.get('force_spread_count', 0)} · \"\n f\"shadow {meta.get('shadow_catch_count', 0)} · \"\n f\"verify {meta.get('verify_catch_count', 0)} · \"\n f\"IR steps {meta.get('ir_step_count', 0)}\"\n )\n with contextlib.suppress(Exception):\n from remedy.core.metabolism.turn import metabolism_public_snapshot\n\n msnap = metabolism_public_snapshot(str(sid or \"\"))\n gov = (msnap.get(\"governor\") or {}).get(\"last_actions\") or []\n if gov:\n quality_line += f\"\\nGovernor last: {', '.join(gov[:8])}\"\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"cancel\"):\n pid = rest[6:].strip()\n plan = store.get(pid) if pid else store.latest_for_session(None, actionable_only=True)\n if plan is None and not pid:\n # Prefer newest non-terminal overall when no id\n plans = store.list_plans(limit=20)\n plan = next(\n (p for p in plans if p.status in (\"draft\", \"approved\", \"active\")),\n None,\n )\n if plan is None:\n return {\"text\": \"No plan to cancel. Save one first, or pass `/plan cancel <id>`.\"}\n cancelled = store.set_status(plan.id, \"cancelled\")\n if cancelled is None:\n return {\"text\": \"Failed to cancel plan.\"}\n return {\n \"text\": f\"Plan **cancelled**: {cancelled.title} (`{cancelled.id}`).\\n\\n\"\n \"It will no longer stick in the Plan banner. Start a new plan in **Plan** mode when ready.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n if stripped in (\"/security-status\", \"/security\", \"/secstatus\"):\n \"\"\"Report partner control / security settings from config and live runtime.\"\"\"\n cfg = load_config()\n lines = [\"## 🔐 Security Status\\n\"]\n\n # -- Approval mode\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n # Check live runtime override if available\n if runtime is not None and hasattr(runtime, \"_approval_mode\"):\n am = str(runtime._approval_mode).strip().lower()\n emoji = \"✅\" if am == \"auto\" else \"⛔\"\n lines.append(f\"{emoji} **Approval Mode:** `{am}` — {'Full owner power (auto-approve)' if am == 'auto' else 'Ask before high-impact actions'}\")\n\n # -- Web tools\n web = bool(cfg.get(\"web_tools_enabled\", False))\n emoji = \"🌐\" if web else \"🚫\"\n lines.append(f\"{emoji} **Web Tools:** `{'enabled' if web else 'disabled'}`\")\n\n # -- Access scope\n scope = str(cfg.get(\"access_scope\") or \"home\")\n lines.append(f\"📁 **Access Scope:** `{scope}`\")\n\n # -- HTTP bootstrap\n http = cfg.get(\"http_bootstrap\")\n if http is not None:\n hb = \"enabled\" if bool(http) else \"disabled (IPC-only)\"\n lines.append(f\"🔌 **HTTP Bootstrap:** `{hb}`\")\n\n # -- Auto-approve threshold\n aat = cfg.get(\"auto_approve_threshold\")\n if aat is not None:\n lines.append(f\"⚖️ **Auto-Approve Threshold:** `{aat}`\")\n\n # -- Thinking level\n tl = str(cfg.get(\"thinking_level\") or \"high\").strip().lower()\n lines.append(f\"🧠 **Thinking Level:** `{tl}`\")\n\n # -- Tool process visibility\n tp = cfg.get(\"tool_process\")\n if tp is not None:\n lines.append(f\"🔧 **Tool Process:** `{tp}`\")\n\n # -- Harness mode\n hm = str(cfg.get(\"harness_mode\") or \"auto\").strip().lower()\n lines.append(f\"⚙️ **Harness Mode:** `{hm}`\")\n\n # -- Execution timeout (if available in config or runtime)\n timeout = cfg.get(\"execution_timeout\") or cfg.get(\"tool_timeout\")\n if timeout is not None:\n lines.append(f\"⏱️ **Execution Timeout:** `{timeout}s`\")\n\n # -- Vision\n ve = bool(cfg.get(\"vision_enabled\", True))\n lines.append(f\"👁️ **Vision Decoder:** `{'enabled' if ve else 'disabled'}`\")\n\n # -- Skills budget\n sb = cfg.get(\"skills_active_budget\")\n if sb is not None:\n lines.append(f\"📦 **Skills Active Budget:** `{sb}`\")\n\n lines.append(f\"\\nRun `/help` for all commands.\")\n return {\"text\": \"\\n\".join(lines)}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\n\n Honors ``REMEDY_HOME`` so tests and alternate homes never write into the\n real ``~/.remedy`` by accident.\n \"\"\"\n env_home = str(os.environ.get(\"REMEDY_HOME\") or \"\").strip()\n if env_home:\n return Path(env_home).expanduser().resolve() / \"config.toml\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n # When REMEDY_HOME is set (tests), never fall through to the real user home.\n if str(os.environ.get(\"REMEDY_HOME\") or \"\").strip():\n return None\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n provider_override: str | None = None,\n llm_only: bool = False,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n\n *provider_override* / *model_override*: per-session picks (status-bar switch).\n Without these, a session on Grok while global config is still DeepSeek would\n send ``model=grok-4.5`` to the DeepSeek base URL every turn.\n\n *llm_only*: when True (chat turns), only bind provider/model/url/key — do not\n thrash project_path / harness / approval_mode mid concurrent streams.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n cfg_provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n ).strip().lower()\n provider = str(\n (provider_override or \"\").strip()\n or cfg_provider\n or \"openai\"\n ).strip().lower()\n model = str(\n (model_override or \"\").strip()\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n # Only reuse global base_url when still on the same provider; otherwise\n # normalize_llm_settings must pick the provider's default API host.\n if provider == cfg_provider:\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n else:\n base_url = \"\"\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(\n provider, model, base_url or None\n )\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n if llm_only:\n # Chat/messenger turn: only LLM binding (safe under _llm_turn_lock).\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n # Full sync (settings save / cold start): partner trust + project + harness.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "146ea0f4f64c9a78e765cbaac91825c5fbed1e57": {"t": 1785768479.1065447, "n": 46647, "h": "3778d3ed041e0c74", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/reset\",\n \"description\": (\n \"Full reset of this session (history, plans, brief, attachments) \"\n \"— stay in the same tab; like a new chat without switching\"\n ),\n \"aliases\": [\"/clear\"],\n \"arguments\": None,\n },\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|cancel|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/security-status\", \"description\": \"Show partner control settings (approval mode, web tools, timeouts)\", \"aliases\": [\"/security\", \"/secstatus\"], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/reset\", \"/clear\"):\n # Full in-place clean slate — same session id, no jump to another tab.\n if not session_id:\n return {\n \"text\": \"No active session to reset. Open a chat first, or use `/new`.\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available; cannot reset session.\"}\n try:\n from remedy.core.session_reset import full_reset_session\n\n stats = await full_reset_session(\n str(session_id), memory, runtime=runtime\n )\n except Exception as exc:\n return {\"text\": f\"Could not reset session: {exc}\"}\n if not stats.get(\"ok\", True):\n return {\n \"text\": f\"Could not reset session: {stats.get('error') or 'unknown error'}\",\n }\n n = int(stats.get(\"messages\") or 0)\n bits = [f\"{n} message{'s' if n != 1 else ''}\"]\n if stats.get(\"plans\"):\n bits.append(f\"{stats['plans']} plan(s)\")\n if stats.get(\"checkpoints\"):\n bits.append(f\"{stats['checkpoints']} checkpoint(s)\")\n if stats.get(\"attachments_purged\"):\n bits.append(\"attachments\")\n if stats.get(\"memory_entries\"):\n bits.append(f\"{stats['memory_entries']} session note(s)\")\n detail = \", \".join(bits)\n return {\n \"text\": (\n f\"Session fully reset ({detail}). \"\n \"Same session tab — history, plans, brief, goals buffer, and \"\n \"attachments cleared. Durable memory (/remember) kept. \"\n \"Send a message to start as if new.\"\n ),\n \"action\": \"reset_session\",\n \"cleared\": n,\n \"stats\": stats,\n }\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"cancel\"):\n pid = rest[6:].strip()\n plan = store.get(pid) if pid else store.latest_for_session(None, actionable_only=True)\n if plan is None and not pid:\n # Prefer newest non-terminal overall when no id\n plans = store.list_plans(limit=20)\n plan = next(\n (p for p in plans if p.status in (\"draft\", \"approved\", \"active\")),\n None,\n )\n if plan is None:\n return {\"text\": \"No plan to cancel. Save one first, or pass `/plan cancel <id>`.\"}\n cancelled = store.set_status(plan.id, \"cancelled\")\n if cancelled is None:\n return {\"text\": \"Failed to cancel plan.\"}\n return {\n \"text\": f\"Plan **cancelled**: {cancelled.title} (`{cancelled.id}`).\\n\\n\"\n \"It will no longer stick in the Plan banner. Start a new plan in **Plan** mode when ready.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n if stripped in (\"/security-status\", \"/security\", \"/secstatus\"):\n \"\"\"Report partner control / security settings from config and live runtime.\"\"\"\n cfg = load_config()\n lines = [\"## 🔐 Security Status\\n\"]\n\n # -- Approval mode\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n # Check live runtime override if available\n if runtime is not None and hasattr(runtime, \"_approval_mode\"):\n am = str(runtime._approval_mode).strip().lower()\n emoji = \"✅\" if am == \"auto\" else \"⛔\"\n lines.append(f\"{emoji} **Approval Mode:** `{am}` — {'Full owner power (auto-approve)' if am == 'auto' else 'Ask before high-impact actions'}\")\n\n # -- Web tools\n web = bool(cfg.get(\"web_tools_enabled\", False))\n emoji = \"🌐\" if web else \"🚫\"\n lines.append(f\"{emoji} **Web Tools:** `{'enabled' if web else 'disabled'}`\")\n\n # -- Access scope\n scope = str(cfg.get(\"access_scope\") or \"home\")\n lines.append(f\"📁 **Access Scope:** `{scope}`\")\n\n # -- HTTP bootstrap\n http = cfg.get(\"http_bootstrap\")\n if http is not None:\n hb = \"enabled\" if bool(http) else \"disabled (IPC-only)\"\n lines.append(f\"🔌 **HTTP Bootstrap:** `{hb}`\")\n\n # -- Auto-approve threshold\n aat = cfg.get(\"auto_approve_threshold\")\n if aat is not None:\n lines.append(f\"⚖️ **Auto-Approve Threshold:** `{aat}`\")\n\n # -- Thinking level\n tl = str(cfg.get(\"thinking_level\") or \"high\").strip().lower()\n lines.append(f\"🧠 **Thinking Level:** `{tl}`\")\n\n # -- Tool process visibility\n tp = cfg.get(\"tool_process\")\n if tp is not None:\n lines.append(f\"🔧 **Tool Process:** `{tp}`\")\n\n # -- Harness mode\n hm = str(cfg.get(\"harness_mode\") or \"auto\").strip().lower()\n lines.append(f\"⚙️ **Harness Mode:** `{hm}`\")\n\n # -- Execution timeout (if available in config or runtime)\n timeout = cfg.get(\"execution_timeout\") or cfg.get(\"tool_timeout\")\n if timeout is not None:\n lines.append(f\"⏱️ **Execution Timeout:** `{timeout}s`\")\n\n # -- Vision\n ve = bool(cfg.get(\"vision_enabled\", True))\n lines.append(f\"👁️ **Vision Decoder:** `{'enabled' if ve else 'disabled'}`\")\n\n # -- Skills budget\n sb = cfg.get(\"skills_active_budget\")\n if sb is not None:\n lines.append(f\"📦 **Skills Active Budget:** `{sb}`\")\n\n lines.append(f\"\\nRun `/help` for all commands.\")\n return {\"text\": \"\\n\".join(lines)}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\n\n Honors ``REMEDY_HOME`` so tests and alternate homes never write into the\n real ``~/.remedy`` by accident.\n \"\"\"\n env_home = str(os.environ.get(\"REMEDY_HOME\") or \"\").strip()\n if env_home:\n return Path(env_home).expanduser().resolve() / \"config.toml\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n # When REMEDY_HOME is set (tests), never fall through to the real user home.\n if str(os.environ.get(\"REMEDY_HOME\") or \"\").strip():\n return None\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n provider_override: str | None = None,\n llm_only: bool = False,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n\n *provider_override* / *model_override*: per-session picks (status-bar switch).\n Without these, a session on Grok while global config is still DeepSeek would\n send ``model=grok-4.5`` to the DeepSeek base URL every turn.\n\n *llm_only*: when True (chat turns), only bind provider/model/url/key — do not\n thrash project_path / harness / approval_mode mid concurrent streams.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n cfg_provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n ).strip().lower()\n provider = str(\n (provider_override or \"\").strip()\n or cfg_provider\n or \"openai\"\n ).strip().lower()\n model = str(\n (model_override or \"\").strip()\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n # Only reuse global base_url when still on the same provider; otherwise\n # normalize_llm_settings must pick the provider's default API host.\n if provider == cfg_provider:\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n else:\n base_url = \"\"\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(\n provider, model, base_url or None\n )\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n if llm_only:\n # Chat/messenger turn: only LLM binding (safe under _llm_turn_lock).\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n # Full sync (settings save / cold start): partner trust + project + harness.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "81672813a616a4b3792841e664e42c1125b98fa2": {"t": 1785768481.882558, "n": 44596, "h": "277727a5d5ec3c33", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|cancel|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/security-status\", \"description\": \"Show partner control settings (approval mode, web tools, timeouts)\", \"aliases\": [\"/security\", \"/secstatus\"], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"cancel\"):\n pid = rest[6:].strip()\n plan = store.get(pid) if pid else store.latest_for_session(None, actionable_only=True)\n if plan is None and not pid:\n # Prefer newest non-terminal overall when no id\n plans = store.list_plans(limit=20)\n plan = next(\n (p for p in plans if p.status in (\"draft\", \"approved\", \"active\")),\n None,\n )\n if plan is None:\n return {\"text\": \"No plan to cancel. Save one first, or pass `/plan cancel <id>`.\"}\n cancelled = store.set_status(plan.id, \"cancelled\")\n if cancelled is None:\n return {\"text\": \"Failed to cancel plan.\"}\n return {\n \"text\": f\"Plan **cancelled**: {cancelled.title} (`{cancelled.id}`).\\n\\n\"\n \"It will no longer stick in the Plan banner. Start a new plan in **Plan** mode when ready.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n if stripped in (\"/security-status\", \"/security\", \"/secstatus\"):\n \"\"\"Report partner control / security settings from config and live runtime.\"\"\"\n cfg = load_config()\n lines = [\"## 🔐 Security Status\\n\"]\n\n # -- Approval mode\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n # Check live runtime override if available\n if runtime is not None and hasattr(runtime, \"_approval_mode\"):\n am = str(runtime._approval_mode).strip().lower()\n emoji = \"✅\" if am == \"auto\" else \"⛔\"\n lines.append(f\"{emoji} **Approval Mode:** `{am}` — {'Full owner power (auto-approve)' if am == 'auto' else 'Ask before high-impact actions'}\")\n\n # -- Web tools\n web = bool(cfg.get(\"web_tools_enabled\", False))\n emoji = \"🌐\" if web else \"🚫\"\n lines.append(f\"{emoji} **Web Tools:** `{'enabled' if web else 'disabled'}`\")\n\n # -- Access scope\n scope = str(cfg.get(\"access_scope\") or \"home\")\n lines.append(f\"📁 **Access Scope:** `{scope}`\")\n\n # -- HTTP bootstrap\n http = cfg.get(\"http_bootstrap\")\n if http is not None:\n hb = \"enabled\" if bool(http) else \"disabled (IPC-only)\"\n lines.append(f\"🔌 **HTTP Bootstrap:** `{hb}`\")\n\n # -- Auto-approve threshold\n aat = cfg.get(\"auto_approve_threshold\")\n if aat is not None:\n lines.append(f\"⚖️ **Auto-Approve Threshold:** `{aat}`\")\n\n # -- Thinking level\n tl = str(cfg.get(\"thinking_level\") or \"high\").strip().lower()\n lines.append(f\"🧠 **Thinking Level:** `{tl}`\")\n\n # -- Tool process visibility\n tp = cfg.get(\"tool_process\")\n if tp is not None:\n lines.append(f\"🔧 **Tool Process:** `{tp}`\")\n\n # -- Harness mode\n hm = str(cfg.get(\"harness_mode\") or \"auto\").strip().lower()\n lines.append(f\"⚙️ **Harness Mode:** `{hm}`\")\n\n # -- Execution timeout (if available in config or runtime)\n timeout = cfg.get(\"execution_timeout\") or cfg.get(\"tool_timeout\")\n if timeout is not None:\n lines.append(f\"⏱️ **Execution Timeout:** `{timeout}s`\")\n\n # -- Vision\n ve = bool(cfg.get(\"vision_enabled\", True))\n lines.append(f\"👁️ **Vision Decoder:** `{'enabled' if ve else 'disabled'}`\")\n\n # -- Skills budget\n sb = cfg.get(\"skills_active_budget\")\n if sb is not None:\n lines.append(f\"📦 **Skills Active Budget:** `{sb}`\")\n\n lines.append(f\"\\nRun `/help` for all commands.\")\n return {\"text\": \"\\n\".join(lines)}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\n\n Honors ``REMEDY_HOME`` so tests and alternate homes never write into the\n real ``~/.remedy`` by accident.\n \"\"\"\n env_home = str(os.environ.get(\"REMEDY_HOME\") or \"\").strip()\n if env_home:\n return Path(env_home).expanduser().resolve() / \"config.toml\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n # When REMEDY_HOME is set (tests), never fall through to the real user home.\n if str(os.environ.get(\"REMEDY_HOME\") or \"\").strip():\n return None\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n provider_override: str | None = None,\n llm_only: bool = False,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n\n *provider_override* / *model_override*: per-session picks (status-bar switch).\n Without these, a session on Grok while global config is still DeepSeek would\n send ``model=grok-4.5`` to the DeepSeek base URL every turn.\n\n *llm_only*: when True (chat turns), only bind provider/model/url/key — do not\n thrash project_path / harness / approval_mode mid concurrent streams.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n cfg_provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n ).strip().lower()\n provider = str(\n (provider_override or \"\").strip()\n or cfg_provider\n or \"openai\"\n ).strip().lower()\n model = str(\n (model_override or \"\").strip()\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n # Only reuse global base_url when still on the same provider; otherwise\n # normalize_llm_settings must pick the provider's default API host.\n if provider == cfg_provider:\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n else:\n base_url = \"\"\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(\n provider, model, base_url or None\n )\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n if llm_only:\n # Chat/messenger turn: only LLM binding (safe under _llm_turn_lock).\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n # Full sync (settings save / cold start): partner trust + project + harness.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "eeaadfe490cd074b4efcf81ad1893f79a076e6f8": {"t": 1785768483.4699335, "n": 41790, "h": "bd02daa8c2d2d772", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|cancel|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"cancel\"):\n pid = rest[6:].strip()\n plan = store.get(pid) if pid else store.latest_for_session(None, actionable_only=True)\n if plan is None and not pid:\n # Prefer newest non-terminal overall when no id\n plans = store.list_plans(limit=20)\n plan = next(\n (p for p in plans if p.status in (\"draft\", \"approved\", \"active\")),\n None,\n )\n if plan is None:\n return {\"text\": \"No plan to cancel. Save one first, or pass `/plan cancel <id>`.\"}\n cancelled = store.set_status(plan.id, \"cancelled\")\n if cancelled is None:\n return {\"text\": \"Failed to cancel plan.\"}\n return {\n \"text\": f\"Plan **cancelled**: {cancelled.title} (`{cancelled.id}`).\\n\\n\"\n \"It will no longer stick in the Plan banner. Start a new plan in **Plan** mode when ready.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\n\n Honors ``REMEDY_HOME`` so tests and alternate homes never write into the\n real ``~/.remedy`` by accident.\n \"\"\"\n env_home = str(os.environ.get(\"REMEDY_HOME\") or \"\").strip()\n if env_home:\n return Path(env_home).expanduser().resolve() / \"config.toml\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n # When REMEDY_HOME is set (tests), never fall through to the real user home.\n if str(os.environ.get(\"REMEDY_HOME\") or \"\").strip():\n return None\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n provider_override: str | None = None,\n llm_only: bool = False,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n\n *provider_override* / *model_override*: per-session picks (status-bar switch).\n Without these, a session on Grok while global config is still DeepSeek would\n send ``model=grok-4.5`` to the DeepSeek base URL every turn.\n\n *llm_only*: when True (chat turns), only bind provider/model/url/key — do not\n thrash project_path / harness / approval_mode mid concurrent streams.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n cfg_provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n ).strip().lower()\n provider = str(\n (provider_override or \"\").strip()\n or cfg_provider\n or \"openai\"\n ).strip().lower()\n model = str(\n (model_override or \"\").strip()\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n # Only reuse global base_url when still on the same provider; otherwise\n # normalize_llm_settings must pick the provider's default API host.\n if provider == cfg_provider:\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n else:\n base_url = \"\"\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(\n provider, model, base_url or None\n )\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n if llm_only:\n # Chat/messenger turn: only LLM binding (safe under _llm_turn_lock).\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n # Full sync (settings save / cold start): partner trust + project + harness.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "ebaac816d5a0ba239544c2665acef1d5a7b1ab08": {"t": 1785768485.1302485, "n": 40772, "h": "cfc418472dc0c705", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\n\n Honors ``REMEDY_HOME`` so tests and alternate homes never write into the\n real ``~/.remedy`` by accident.\n \"\"\"\n env_home = str(os.environ.get(\"REMEDY_HOME\") or \"\").strip()\n if env_home:\n return Path(env_home).expanduser().resolve() / \"config.toml\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n # When REMEDY_HOME is set (tests), never fall through to the real user home.\n if str(os.environ.get(\"REMEDY_HOME\") or \"\").strip():\n return None\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n provider_override: str | None = None,\n llm_only: bool = False,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n\n *provider_override* / *model_override*: per-session picks (status-bar switch).\n Without these, a session on Grok while global config is still DeepSeek would\n send ``model=grok-4.5`` to the DeepSeek base URL every turn.\n\n *llm_only*: when True (chat turns), only bind provider/model/url/key — do not\n thrash project_path / harness / approval_mode mid concurrent streams.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n cfg_provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n ).strip().lower()\n provider = str(\n (provider_override or \"\").strip()\n or cfg_provider\n or \"openai\"\n ).strip().lower()\n model = str(\n (model_override or \"\").strip()\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n # Only reuse global base_url when still on the same provider; otherwise\n # normalize_llm_settings must pick the provider's default API host.\n if provider == cfg_provider:\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n else:\n base_url = \"\"\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(\n provider, model, base_url or None\n )\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n if llm_only:\n # Chat/messenger turn: only LLM binding (safe under _llm_turn_lock).\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n # Full sync (settings save / cold start): partner trust + project + harness.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "78bfd5b63df63d9bf2eda2e9d3a36bef172ca6f9": {"t": 1785768486.5023808, "n": 40371, "h": "e62e02230749e083", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\n\n Honors ``REMEDY_HOME`` so tests and alternate homes never write into the\n real ``~/.remedy`` by accident.\n \"\"\"\n env_home = str(os.environ.get(\"REMEDY_HOME\") or \"\").strip()\n if env_home:\n return Path(env_home).expanduser().resolve() / \"config.toml\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n # When REMEDY_HOME is set (tests), never fall through to the real user home.\n if str(os.environ.get(\"REMEDY_HOME\") or \"\").strip():\n return None\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n provider_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n\n *provider_override* / *model_override*: per-session picks (status-bar switch).\n Without these, a session on Grok while global config is still DeepSeek would\n send ``model=grok-4.5`` to the DeepSeek base URL every turn.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n cfg_provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n ).strip().lower()\n provider = str(\n (provider_override or \"\").strip()\n or cfg_provider\n or \"openai\"\n ).strip().lower()\n model = str(\n (model_override or \"\").strip()\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n # Only reuse global base_url when still on the same provider; otherwise\n # normalize_llm_settings must pick the provider's default API host.\n if provider == cfg_provider:\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n else:\n base_url = \"\"\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(\n provider, model, base_url or None\n )\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n # Partner trust: keep APPROVALS + runtime aligned with config on every turn.\n # Without this, thumbs-up (auto) in Settings/UI can show while tools still\n # emit APPROVAL_REQUIRED because the process started on default ask.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "8627544664c8aaaaad83b1a008aaef207ea614d9": {"t": 1785768487.8885875, "n": 39668, "h": "53e876ef9acd4e28", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\n\n Honors ``REMEDY_HOME`` so tests and alternate homes never write into the\n real ``~/.remedy`` by accident.\n \"\"\"\n env_home = str(os.environ.get(\"REMEDY_HOME\") or \"\").strip()\n if env_home:\n return Path(env_home).expanduser().resolve() / \"config.toml\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n # When REMEDY_HOME is set (tests), never fall through to the real user home.\n if str(os.environ.get(\"REMEDY_HOME\") or \"\").strip():\n return None\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(provider, model, base_url)\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n # Partner trust: keep APPROVALS + runtime aligned with config on every turn.\n # Without this, thumbs-up (auto) in Settings/UI can show while tools still\n # emit APPROVAL_REQUIRED because the process started on default ask.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "f97432728fe20905f8cc2187b2d6bbe638c7f9a5": {"t": 1785768489.4104452, "n": 39241, "h": "8273107d59c78b1d", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(provider, model, base_url)\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n # Partner trust: keep APPROVALS + runtime aligned with config on every turn.\n # Without this, thumbs-up (auto) in Settings/UI can show while tools still\n # emit APPROVAL_REQUIRED because the process started on default ask.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "617d907b8420f4ac351487444dffa16ac1f69752": {"t": 1785768491.804394, "n": 39975, "h": "539a48ea0c9c270c", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n # Advanced internals only when tool process is full+\n advanced = \"\"\n try:\n from remedy.interfaces.api_support import load_config\n\n tp = str((load_config() or {}).get(\"tool_process\") or \"off\").lower()\n if tp in (\"full+\", \"fullplus\", \"debug\"):\n from remedy.nanoswarm import get_swarm\n\n st = get_swarm().status()\n advanced = (\n f\"\\n\\n**Advanced (Full+)** · continuity events {st.get('event_count')} · \"\n f\"local model `{st.get('local_model_id')}`\"\n )\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n f\"{advanced}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(provider, model, base_url)\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n # Partner trust: keep APPROVALS + runtime aligned with config on every turn.\n # Without this, thumbs-up (auto) in Settings/UI can show while tools still\n # emit APPROVAL_REQUIRED because the process started on default ask.\n am = str(cfg.get(\"approval_mode\") or \"ask\").strip().lower()\n if am not in (\"ask\", \"auto\"):\n am = \"ask\"\n scope = cfg.get(\"access_scope\")\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n project_path=cfg.get(\"project_path\"),\n access_scope=str(scope) if scope is not None else None,\n harness_mode=cfg.get(\"harness_mode\"),\n thinking_level=cfg.get(\"thinking_level\"),\n approval_mode=am,\n )\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.sync_from_config(cfg)\n except Exception:\n pass\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "a5f295eb468ae84dc33609e9c591f316d0150842": {"t": 1785768493.2713068, "n": 39220, "h": "c415946a8eadc58f", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/forget\", \"description\": \"Remove a remembered fact: /forget <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/pin\", \"description\": \"Pin a fact so it always injects: /pin <text>\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n # Advanced internals only when tool process is full+\n advanced = \"\"\n try:\n from remedy.interfaces.api_support import load_config\n\n tp = str((load_config() or {}).get(\"tool_process\") or \"off\").lower()\n if tp in (\"full+\", \"fullplus\", \"debug\"):\n from remedy.nanoswarm import get_swarm\n\n st = get_swarm().status()\n advanced = (\n f\"\\n\\n**Advanced (Full+)** · continuity events {st.get('event_count')} · \"\n f\"local model `{st.get('local_model_id')}`\"\n )\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n f\"{advanced}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import looks_like_secret, upsert_profile_fact\n from remedy.models import MemoryEntry, MemoryEntryType\n\n if looks_like_secret(text):\n return {\n \"text\": (\n \"That looks like a secret (API key/password). \"\n \"I won’t store it in Partner Memory — use a secret store or env var.\"\n )\n }\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n )\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped.startswith(\"/forget\"):\n text = raw[len(\"/forget\") :].strip()\n if not text:\n return {\"text\": \"Usage: /forget <text matching a fact to remove>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import forget_facts\n\n profile = await memory.get_or_create_profile()\n removed = forget_facts(profile, text)\n await memory.save_user_profile(profile)\n if not removed:\n return {\n \"text\": (\n f\"No matching facts for “{text[:120]}”. \"\n \"Try `/whoami` to see what I know.\"\n )\n }\n lines = [f\"Forgot {len(removed)} fact(s):\"]\n for f in removed[:8]:\n lines.append(f\"- {f.fact}\")\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Could not forget: {e}\"}\n\n if stripped.startswith(\"/pin\"):\n text = raw[len(\"/pin\") :].strip()\n if not text:\n return {\"text\": \"Usage: /pin <text matching a fact to keep always ready>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import pin_facts, upsert_profile_fact\n\n profile = await memory.get_or_create_profile()\n touched = pin_facts(profile, text, pinned=True)\n if not touched:\n # Create + pin if no match\n uf, _ = upsert_profile_fact(\n profile,\n text,\n category=\"general\",\n confidence=0.95,\n source=\"explicit\",\n force=True,\n pinned=True,\n )\n if uf is not None:\n touched = [uf]\n await memory.save_user_profile(profile)\n if not touched:\n return {\"text\": f\"Could not pin “{text[:120]}”.\"}\n return {\n \"text\": \"Pinned:\\n\"\n + \"\\n\".join(f\"- {f.fact}\" for f in touched[:8])\n }\n except Exception as e:\n return {\"text\": f\"Could not pin: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.memory.partner_memory import format_whoami\n\n profile = await memory.get_or_create_profile()\n return {\"text\": format_whoami(profile)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(provider, model, base_url)\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "a245718fee5164d6546b0bde440a0d3278c1bfd6": {"t": 1785768494.9930105, "n": 36567, "h": "d1cb4fc43e9a24e0", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n parts = raw.split(maxsplit=1)\n arg = parts[1].strip() if len(parts) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n # Advanced internals only when tool process is full+\n advanced = \"\"\n try:\n from remedy.interfaces.api_support import load_config\n\n tp = str((load_config() or {}).get(\"tool_process\") or \"off\").lower()\n if tp in (\"full+\", \"fullplus\", \"debug\"):\n from remedy.nanoswarm import get_swarm\n\n st = get_swarm().status()\n advanced = (\n f\"\\n\\n**Advanced (Full+)** · continuity events {st.get('event_count')} · \"\n f\"local model `{st.get('local_model_id')}`\"\n )\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n f\"{advanced}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(provider, model, base_url)\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "b323d0ed309fd5de0d61c56e6a9de12440e2242e": {"t": 1785768497.0796869, "n": 36564, "h": "bff630532e1a8113", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/helper\",\n \"description\": \"Offline help tips, or /helper error <text>\",\n \"aliases\": [\"/tip\"],\n \"arguments\": \"topic | error <text>\",\n },\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped.startswith(\"/helper\") or stripped.startswith(\"/tip\"):\n try:\n from remedy.nanoswarm import get_swarm\n\n rest = raw.split(maxsplit=1)\n arg = rest[1].strip() if len(rest) > 1 else \"\"\n low = arg.lower()\n if low.startswith(\"error \") or low.startswith(\"err \"):\n err = arg.split(maxsplit=1)[1] if \" \" in arg else arg\n out = get_swarm().helper.explain_error(err)\n else:\n out = get_swarm().helper.draft_help(arg)\n return {\"text\": out.get(\"markdown\") or out.get(\"error\") or \"No help available.\"}\n except Exception as e:\n return {\"text\": f\"Helper error: {e}\"}\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n # Advanced internals only when tool process is full+\n advanced = \"\"\n try:\n from remedy.interfaces.api_support import load_config\n\n tp = str((load_config() or {}).get(\"tool_process\") or \"off\").lower()\n if tp in (\"full+\", \"fullplus\", \"debug\"):\n from remedy.nanoswarm import get_swarm\n\n st = get_swarm().status()\n advanced = (\n f\"\\n\\n**Advanced (Full+)** · continuity events {st.get('event_count')} · \"\n f\"local model `{st.get('local_model_id')}`\"\n )\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n f\"{advanced}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(provider, model, base_url)\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "7b1ab22ac117bd41e631944b816239b400108542": {"t": 1785768498.8249764, "n": 35669, "h": "463d7afec3bce0d2", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n # Advanced internals only when tool process is full+\n advanced = \"\"\n try:\n from remedy.interfaces.api_support import load_config\n\n tp = str((load_config() or {}).get(\"tool_process\") or \"off\").lower()\n if tp in (\"full+\", \"fullplus\", \"debug\"):\n from remedy.nanoswarm import get_swarm\n\n st = get_swarm().status()\n advanced = (\n f\"\\n\\n**Advanced (Full+)** · continuity events {st.get('event_count')} · \"\n f\"local model `{st.get('local_model_id')}`\"\n )\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n f\"{advanced}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Migrate retired model ids (deepseek-chat → v4, old grok-3 → current, …)\n # and align base_url with provider so chat works without a settings re-save.\n try:\n from remedy.interfaces.config import normalize_llm_settings\n\n provider, model, base_url = normalize_llm_settings(provider, model, base_url)\n except Exception as exc:\n logger.debug(\"normalize_llm_settings in runtime sync failed: %s\", exc)\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "349c6b157c1b3b550e5e97d1d9a900855d45e432": {"t": 1785768500.372167, "n": 35238, "h": "5a7b592473ccb86b", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped in (\"/harness\", \"/brief\", \"/nanoswarm\", \"/swarm\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n quality_line = \"\"\n try:\n from remedy.core.session_quality import get_session_quality\n\n sid = getattr(agent, \"_session_id\", None) if agent is not None else None\n q = get_session_quality(str(sid or \"\")).snapshot()\n last = q.get(\"last_compress\") or {}\n quality_line = (\n f\"\\n\\n**Session quality** · turns {q.get('turns')} · \"\n f\"tokens in/out {q.get('tokens_in')}/{q.get('tokens_out')} · \"\n f\"saved by compress ~{q.get('tokens_saved_by_compress')} · \"\n f\"stuck rate {q.get('stuck_rate')} · \"\n f\"re-explain rate {q.get('re_explain_rate')}\"\n )\n if last:\n quality_line += (\n f\"\\nLast compress: {last.get('tokens_before')}→{last.get('tokens_after')} \"\n f\"(quality {last.get('quality_score')}, \"\n f\"paths kept {last.get('paths_kept')}, lost {last.get('paths_lost')})\"\n )\n if q.get(\"avg_compress_quality\") is not None:\n quality_line += f\"\\nAvg compress quality: {q.get('avg_compress_quality')}\"\n except Exception:\n pass\n # Advanced internals only when tool process is full+\n advanced = \"\"\n try:\n from remedy.interfaces.api_support import load_config\n\n tp = str((load_config() or {}).get(\"tool_process\") or \"off\").lower()\n if tp in (\"full+\", \"fullplus\", \"debug\"):\n from remedy.nanoswarm import get_swarm\n\n st = get_swarm().status()\n advanced = (\n f\"\\n\\n**Advanced (Full+)** · continuity events {st.get('event_count')} · \"\n f\"local model `{st.get('local_model_id')}`\"\n )\n except Exception:\n pass\n return {\n \"text\": (\n f\"**Memory** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n f\"{quality_line}\"\n f\"{advanced}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "578c05128898b18deac7909003d7e8232a947a21": {"t": 1785768501.888055, "n": 33149, "h": "20a31ac1e68b6a4d", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped in (\"/harness\", \"/brief\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n return {\n \"text\": (\n f\"**Memory Harness** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n \"\"\"Load config.toml with mtime cache (routes hit this constantly).\n\n Returns a shallow copy so callers can mutate without poisoning the cache.\n \"\"\"\n return _load_config_cached()\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef invalidate_config_cache() -> None:\n \"\"\"Force next load_config() to re-read from disk.\"\"\"\n _config_cache[\"path\"] = None\n _config_cache[\"mtime\"] = None\n _config_cache[\"size\"] = None\n _config_cache[\"data\"] = None\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every request.\n\n Always returns a shallow copy so route handlers can mutate safely.\n \"\"\"\n path = _find_config_path()\n if path is None:\n return {}\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n try:\n return dict(_load_toml_config(path) or {})\n except Exception:\n return {}\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return dict(_config_cache[\"data\"])\n data = _load_toml_config(path) or {}\n if not isinstance(data, dict):\n data = {}\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return dict(data)\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n # Drop mtime cache so the next GET sees the write immediately.\n invalidate_config_cache()\n # Seed cache with what we wrote (skip another parse).\n try:\n st = path.stat()\n _config_cache.update(\n {\n \"path\": str(path),\n \"mtime\": st.st_mtime,\n \"size\": st.st_size,\n \"data\": dict(safe),\n }\n )\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "810b979aae002887653287551181085f934753a6": {"t": 1785768504.3608563, "n": 32521, "h": "a90afcaa77cdb14d", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped in (\"/harness\", \"/brief\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n return {\n \"text\": (\n f\"**Memory Harness** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n approved = store.set_status(plan.id, \"approved\")\n if approved is None:\n return {\"text\": \"Failed to approve plan.\"}\n return {\n \"text\": f\"Plan **approved**: {approved.title} (`{approved.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n path = _find_config_path()\n if path is None:\n return {}\n return _load_toml_config(path)\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every message.\"\"\"\n from pathlib import Path\n\n candidates = [\n Path(\"~/.remedy/config.toml\").expanduser(),\n Path(\"~/.remedy/config.yaml\").expanduser(),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n ]\n path = next((p for p in candidates if p.exists()), None)\n if path is None:\n return load_config()\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n return load_config()\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return _config_cache[\"data\"]\n # Prefer explicit path when known; load_config() also scans defaults.\n data = _load_toml_config(path) if path else load_config()\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return data\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "cea979097963f329cbbc64d3880e08da12190469": {"t": 1785768506.0421004, "n": 32417, "h": "ce7b07193be76447", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/plans\", \"description\": \"List structured task plans\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/plan\",\n \"description\": \"Show latest plan, or /plan approve|new <title>\",\n \"aliases\": [],\n \"arguments\": \"approve|new <title>\",\n },\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped in (\"/harness\", \"/brief\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n return {\n \"text\": (\n f\"**Memory Harness** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped in (\"/plans\", \"/plan\") or stripped.startswith(\"/plan \"):\n from pathlib import Path\n\n from remedy.core.plan_store import PlanStore\n\n home = None\n if runtime is not None:\n home = getattr(getattr(runtime, \"config\", None), \"home_dir\", None)\n if not home:\n try:\n from remedy.interfaces.config import load_config\n\n home = load_config().get(\"home_dir\")\n except Exception:\n home = None\n store = PlanStore(home or Path.home() / \".remedy\")\n rest = raw[len(\"/plan\") :].strip() if stripped.startswith(\"/plan\") else \"\"\n if stripped == \"/plans\" or rest.lower() in (\"\", \"list\"):\n plans = store.list_plans(limit=20)\n if not plans:\n return {\n \"text\": \"No structured plans yet. Use **Plan** mode (Ctrl+B) and ask \"\n \"Remedy to save a plan, or `/plan new <title>`.\"\n }\n lines = [\n f\"- [{p.status}] **{p.title}** (`{p.id}`, {len(p.steps)} steps)\"\n for p in plans\n ]\n return {\"text\": \"**Plans**\\n\" + \"\\n\".join(lines)}\n if rest.lower().startswith(\"approve\"):\n pid = rest[7:].strip()\n plan = store.get(pid) if pid else store.list_plans(limit=1)\n if isinstance(plan, list):\n plan = plan[0] if plan else None\n if plan is None:\n return {\"text\": \"No plan to approve. Save one first.\"}\n plan = store.set_status(plan.id, \"approved\")\n return {\n \"text\": f\"Plan **approved**: {plan.title} (`{plan.id}`)\\n\\n\"\n \"Switch to **Build** mode to execute.\"\n }\n if rest.lower().startswith(\"new \"):\n title = rest[4:].strip()\n if not title:\n return {\"text\": \"Usage: /plan new <title>\"}\n plan = store.create(title, goal=title, steps=[], status=\"draft\")\n return {\n \"text\": f\"Draft plan created: **{plan.title}** (`{plan.id}`). \"\n \"In Plan mode, ask Remedy to fill steps with `plan_save`.\"\n }\n # Show latest or by id\n plan = store.get(rest) if rest and not rest.lower().startswith(\"new\") else None\n if plan is None:\n plans = store.list_plans(limit=1)\n plan = plans[0] if plans else None\n if plan is None:\n return {\"text\": \"No plans yet. `/plan new <title>` or use Plan mode.\"}\n return {\"text\": plan.summary_markdown()}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n path = _find_config_path()\n if path is None:\n return {}\n return _load_toml_config(path)\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every message.\"\"\"\n from pathlib import Path\n\n candidates = [\n Path(\"~/.remedy/config.toml\").expanduser(),\n Path(\"~/.remedy/config.yaml\").expanduser(),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n ]\n path = next((p for p in candidates if p.exists()), None)\n if path is None:\n return load_config()\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n return load_config()\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return _config_cache[\"data\"]\n # Prefer explicit path when known; load_config() also scans defaults.\n data = _load_toml_config(path) if path else load_config()\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return data\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "3d99bf8bb1a8be99513d444e2179a1a9245f088c": {"t": 1785768507.6790268, "n": 29541, "h": "08b60fc022b087be", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — Full Help wiki (owner's manual)\\n\"\n \" Escape — Close panels, Help, and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · **F1** opens the offline Help wiki (searchable owner's manual).\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in ~/.remedy on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped in (\"/harness\", \"/brief\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n return {\n \"text\": (\n f\"**Memory Harness** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n path = _find_config_path()\n if path is None:\n return {}\n return _load_toml_config(path)\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every message.\"\"\"\n from pathlib import Path\n\n candidates = [\n Path(\"~/.remedy/config.toml\").expanduser(),\n Path(\"~/.remedy/config.yaml\").expanduser(),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n ]\n path = next((p for p in candidates if p.exists()), None)\n if path is None:\n return load_config()\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n return load_config()\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return _config_cache[\"data\"]\n # Prefer explicit path when known; load_config() also scans defaults.\n data = _load_toml_config(path) if path else load_config()\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return data\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "c139fe275f0e456966de29abde0a3fe64197ed91": {"t": 1785768509.1644998, "n": 29449, "h": "2a3a228bd77215a7", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — This help / shortcuts\\n\"\n \" Escape — Close panels and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in your Remedy folder on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped in (\"/harness\", \"/brief\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n return {\n \"text\": (\n f\"**Memory Harness** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n path = _find_config_path()\n if path is None:\n return {}\n return _load_toml_config(path)\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every message.\"\"\"\n from pathlib import Path\n\n candidates = [\n Path(\"~/.remedy/config.toml\").expanduser(),\n Path(\"~/.remedy/config.yaml\").expanduser(),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n ]\n path = next((p for p in candidates if p.exists()), None)\n if path is None:\n return load_config()\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n return load_config()\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return _config_cache[\"data\"]\n # Prefer explicit path when known; load_config() also scans defaults.\n data = _load_toml_config(path) if path else load_config()\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return data\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\n\n TOML rule: all root keys must appear *before* any ``[table]`` section.\n Writing scalars after tables makes them part of the last table and can\n duplicate keys (``Cannot overwrite a value``) — which made load_config\n return {{}} and setup/save look broken on first run.\n \"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n scalars: list[tuple[str, Any]] = []\n tables: list[tuple[str, dict[str, Any]]] = []\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if value is None:\n continue\n if isinstance(value, dict):\n tables.append((key, value))\n else:\n scalars.append((key, value))\n for key, value in scalars:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n if scalars and tables:\n lines.append(\"\\n\")\n for key, value in tables:\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n if v is None:\n continue\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n content = \"\".join(lines)\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "eb1c66a02a66f567490491abe142cbc85b576438": {"t": 1785768511.333045, "n": 28746, "h": "1137e80d4dafa24e", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy.interfaces.config import (\n CONFIG_PATHS,\n PROVIDER_CATALOG,\n _is_local_url,\n)\nfrom remedy.interfaces.config import (\n load_config as _load_toml_config,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — This help / shortcuts\\n\"\n \" Escape — Close panels and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in your Remedy folder on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped in (\"/harness\", \"/brief\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n return {\n \"text\": (\n f\"**Memory Harness** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n path = _find_config_path()\n if path is None:\n return {}\n return _load_toml_config(path)\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every message.\"\"\"\n from pathlib import Path\n\n candidates = [\n Path(\"~/.remedy/config.toml\").expanduser(),\n Path(\"~/.remedy/config.yaml\").expanduser(),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n ]\n path = next((p for p in candidates if p.exists()), None)\n if path is None:\n return load_config()\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n return load_config()\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return _config_cache[\"data\"]\n # Prefer explicit path when known; load_config() also scans defaults.\n data = _load_toml_config(path) if path else load_config()\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return data\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if isinstance(value, dict):\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n else:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n content = \"\".join(lines)\n path.write_text(content, encoding=\"utf-8\")\n with contextlib.suppress(OSError):\n path.chmod(0o600)\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "e6cf30f7ce2a26ddbec9e7ce95f6bb6136190d43": {"t": 1785768512.7344184, "n": 28804, "h": "ec0bbe8195c11acc", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy import __version__ as _remedy_version\nfrom remedy.interfaces.config import CONFIG_PATHS\nfrom remedy.interfaces.config import (\n PROVIDER_CATALOG,\n load_config as _load_toml_config,\n)\nfrom remedy.interfaces.config import _is_local_url\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/export\", \"description\": \"Export this session as a .txt file (desktop)\", \"aliases\": [], \"arguments\": None},\n {\n \"name\": \"/import-session\",\n \"description\": \"Import a session from .txt/.md (path or desktop file picker)\",\n \"aliases\": [\"/session-import\"],\n \"arguments\": \"path\",\n },\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — This help / shortcuts\\n\"\n \" Escape — Close panels and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in your Remedy folder on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped in (\"/harness\", \"/brief\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n return {\n \"text\": (\n f\"**Memory Harness** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped in (\"/export\", \"/export-session\"):\n # Desktop intercepts this for file download; CLI/API returns guidance.\n return {\n \"text\": (\n \"Export this session as a plain-text `.txt` file from the desktop:\\n\"\n \"• Right-click the session tab → export\\n\"\n \"• Command palette → **Export Session**\\n\"\n \"• Sidebar → **Export**\\n\"\n \"• API: `GET /api/sessions/{id}/export?format=txt`\"\n ),\n \"action\": \"export_session\",\n }\n\n if stripped.startswith(\"/import-session\") or stripped.startswith(\"/session-import\"):\n # Desktop uses the file picker when no path is given.\n path = \"\"\n for prefix in (\"/import-session\", \"/session-import\"):\n if raw.lower().startswith(prefix):\n path = raw[len(prefix) :].strip().strip('\"').strip(\"'\")\n break\n if not path:\n return {\n \"text\": (\n \"Import a session from a `.txt` or `.md` export:\\n\"\n \"• Desktop: Command palette → **Import Session** (file picker)\\n\"\n \"• Slash: `/import-session <path-to-file.txt>`\\n\"\n \"• API: `POST /api/sessions/import` with `{ \\\"text\\\": \\\"...\\\" }`\"\n ),\n \"action\": \"import_session\",\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.core.workspace import (\n allowed_roots_for_scope,\n default_project_from_config,\n resolve_under_roots,\n )\n from remedy.memory.session_io import parse_session_text\n from remedy.models import ChatMessage, ChatMessageRole\n from remedy.models import ChatSession as CS\n\n cfg = load_config()\n scope = str(cfg.get(\"access_scope\") or \"home\")\n project = default_project_from_config(cfg)\n roots = allowed_roots_for_scope(scope, project)\n try:\n fpath = resolve_under_roots(path, roots, access_scope=scope)\n except Exception as exc:\n return {\"text\": f\"Path not allowed: {exc}\"}\n if not fpath.is_file():\n return {\"text\": f\"File not found: {fpath}\"}\n try:\n text = fpath.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n text = fpath.read_text(encoding=\"utf-8\", errors=\"replace\")\n try:\n parsed = parse_session_text(text)\n except ValueError as exc:\n return {\"text\": f\"Import failed: {exc}\"}\n title = (parsed.title or \"Imported Session\")[:200]\n session = CS(title=title, model=parsed.model, agent=parsed.agent)\n saved = await memory.create_chat_session(session)\n role_map = {\n \"user\": ChatMessageRole.USER,\n \"assistant\": ChatMessageRole.ASSISTANT,\n \"system\": ChatMessageRole.SYSTEM,\n \"tool\": ChatMessageRole.TOOL,\n }\n n = 0\n for pm in parsed.messages:\n role = role_map.get((pm.role or \"user\").lower(), ChatMessageRole.USER)\n if role == ChatMessageRole.SYSTEM and not (pm.content or \"\").strip():\n continue\n await memory.add_chat_message(\n ChatMessage(\n session_id=saved.id,\n role=role,\n content=pm.content or \"\",\n model=pm.model or parsed.model,\n agent=pm.agent or parsed.agent,\n )\n )\n n += 1\n return {\n \"text\": (\n f\"Imported session **{title}** (`{saved.id[:8]}…`) \"\n f\"with **{n}** messages.\"\n ),\n \"action\": \"import_session_done\",\n \"session_id\": saved.id,\n }\n\n # Knowledge-pack import (folder of notes) — must not match /import-session\n if stripped == \"/import\" or stripped.startswith(\"/import \"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\\n\"\n \"To import a chat session, use `/import-session <file.txt>`.\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n path = _find_config_path()\n if path is None:\n return {}\n return _load_toml_config(path)\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every message.\"\"\"\n from pathlib import Path\n\n candidates = [\n Path(\"~/.remedy/config.toml\").expanduser(),\n Path(\"~/.remedy/config.yaml\").expanduser(),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n ]\n path = next((p for p in candidates if p.exists()), None)\n if path is None:\n return load_config()\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n return load_config()\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return _config_cache[\"data\"]\n # Prefer explicit path when known; load_config() also scans defaults.\n data = _load_toml_config(path) if path else load_config()\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return data\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if isinstance(value, dict):\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n else:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n content = \"\".join(lines)\n path.write_text(content, encoding=\"utf-8\")\n try:\n path.chmod(0o600)\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "cd6c410b2cc67f89af0f2b59abbb40188fac3bb1": {"t": 1785768514.350658, "n": 24465, "h": "4d55c51378ec1ef0", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy import __version__ as _remedy_version\nfrom remedy.interfaces.config import CONFIG_PATHS\nfrom remedy.interfaces.config import (\n PROVIDER_CATALOG,\n load_config as _load_toml_config,\n)\nfrom remedy.interfaces.config import _is_local_url\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — This help / shortcuts\\n\"\n \" Escape — Close panels and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in your Remedy folder on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped in (\"/harness\", \"/brief\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n return {\n \"text\": (\n f\"**Memory Harness** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped.startswith(\"/import\"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n path = _find_config_path()\n if path is None:\n return {}\n return _load_toml_config(path)\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n thinking_level: str | None = None,\n approval_mode: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n if thinking_level is not None:\n kwargs[\"thinking_level\"] = thinking_level\n if approval_mode is not None:\n kwargs[\"approval_mode\"] = approval_mode\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n if thinking_level is not None:\n runtime._thinking_level = str(thinking_level).strip().lower()\n if approval_mode is not None:\n try:\n from remedy.core.approvals import APPROVALS\n\n APPROVALS.set_mode(str(approval_mode))\n except Exception:\n pass\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every message.\"\"\"\n from pathlib import Path\n\n candidates = [\n Path(\"~/.remedy/config.toml\").expanduser(),\n Path(\"~/.remedy/config.yaml\").expanduser(),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n ]\n path = next((p for p in candidates if p.exists()), None)\n if path is None:\n return load_config()\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n return load_config()\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return _config_cache[\"data\"]\n # Prefer explicit path when known; load_config() also scans defaults.\n data = _load_toml_config(path) if path else load_config()\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return data\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n # Per-provider only — never reuse DeepSeek sk-… for xAI, etc.\n try:\n from remedy.interfaces.config import resolve_provider_api_key\n\n api_key = resolve_provider_api_key(cfg, provider)\n except Exception as exc:\n logger.debug(\"resolve_provider_api_key failed: %s\", exc)\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n \"\"\"Persist non-secret settings only. API keys never land in config.toml.\"\"\"\n try:\n from remedy.interfaces.secret_store import scrub_config_secrets\n\n safe = scrub_config_secrets(cfg)\n except Exception:\n safe = dict(cfg or {})\n safe.pop(\"provider_keys\", None)\n if \"llm_api_key\" in safe:\n safe[\"llm_api_key\"] = \"\"\n\n # Refuse to serialize any remaining nested maps that look like key bags.\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n lines.append(\n \"# API keys are stored in ~/.remedy/auth/ (DPAPI-encrypted on Windows),\\n\"\n \"# not in this file.\\n\\n\"\n )\n for key, value in safe.items():\n if key in (\"provider_keys\", \"llm_api_key\"):\n continue # hard block — never write secrets here\n if isinstance(value, dict):\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n else:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n content = \"\".join(lines)\n path.write_text(content, encoding=\"utf-8\")\n try:\n path.chmod(0o600)\n except OSError:\n pass\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "cda63f9fdba43cc9d0206ccbfca19c3be2557b07": {"t": 1785768515.9663744, "n": 23265, "h": "d8aa3f243cd0dc4b", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy import __version__ as _remedy_version\nfrom remedy.interfaces.config import CONFIG_PATHS\nfrom remedy.interfaces.config import (\n PROVIDER_CATALOG,\n load_config as _load_toml_config,\n)\nfrom remedy.interfaces.config import _is_local_url\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Memory Harness: compress session into Session Brief\", \"aliases\": [], \"arguments\": \"focus\"},\n {\"name\": \"/harness\", \"description\": \"Show Memory Harness Session Brief / stats\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/remember\", \"description\": \"Save a durable fact to memory\", \"aliases\": [], \"arguments\": \"text\"},\n {\"name\": \"/whoami\", \"description\": \"Show what Remedy knows about you\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goals\", \"description\": \"List open goals\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/goal\", \"description\": \"Add a goal: /goal <title>\", \"aliases\": [], \"arguments\": \"title\"},\n {\"name\": \"/approve\", \"description\": \"Approve a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/deny\", \"description\": \"Deny a pending high-impact action\", \"aliases\": [], \"arguments\": \"id\"},\n {\"name\": \"/import\", \"description\": \"Import a folder of notes into memory\", \"aliases\": [], \"arguments\": \"path\"},\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str,\n session_id: str | None,\n memory,\n runtime: Any = None,\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n from contextlib import suppress\n\n stripped = command.strip().lower()\n # Preserve original casing for /remember text\n raw = command.strip()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — This help / shortcuts\\n\"\n \" Escape — Close panels and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in your Remedy folder on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\" or stripped.startswith(\"/compact \"):\n focus = raw[len(\"/compact\") :].strip()\n agent = runtime if runtime is not None else None\n # BasicRuntime is often the agent itself\n if agent is not None and hasattr(agent, \"tool_registry\"):\n try:\n result = await agent.tool_registry.execute(\n \"compress_context\", focus=focus\n )\n return {\"text\": str(result)}\n except Exception as e:\n return {\"text\": f\"Memory Harness compact failed: {e}\"}\n return {\n \"text\": (\n \"Memory Harness compact: agent runtime not available. \"\n \"Ask Remedy to call compress_context in chat.\"\n + (f\" Focus: {focus}\" if focus else \"\")\n )\n }\n\n if stripped in (\"/harness\", \"/brief\"):\n agent = runtime\n brief = getattr(agent, \"_session_brief\", None) if agent is not None else None\n if brief is None:\n return {\n \"text\": (\n \"Memory Harness: no Session Brief yet. \"\n \"Use /compact after some work, or ask Remedy to compress_context.\"\n )\n }\n try:\n from remedy.memory.harness.brief import brief_to_context_block\n\n block = brief_to_context_block(brief) or \"(empty brief)\"\n return {\n \"text\": (\n f\"**Memory Harness** · compress passes: {brief.compress_count}\\n\\n\"\n f\"{block}\"\n )\n }\n except Exception as e:\n return {\"text\": f\"Harness status error: {e}\"}\n\n if stripped.startswith(\"/remember\"):\n text = raw[len(\"/remember\") :].strip()\n if not text:\n return {\"text\": \"Usage: /remember <fact to store>\"}\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n from remedy.models import MemoryEntry, MemoryEntryType\n\n await memory.upsert(\n MemoryEntry(\n title=\"Remembered\",\n content=text,\n entry_type=MemoryEntryType.NOTE,\n importance=0.8,\n )\n )\n with suppress(Exception):\n profile = await memory.get_or_create_profile()\n profile.add_fact(text, category=\"general\", confidence=0.9)\n await memory.save_user_profile(profile)\n return {\"text\": f\"Remembered: {text[:300]}\"}\n except Exception as e:\n return {\"text\": f\"Could not save: {e}\"}\n\n if stripped in (\"/whoami\", \"/who-am-i\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n try:\n profile = await memory.get_or_create_profile()\n lines = [\"**What I know about you**\"]\n if profile.display_name:\n lines.append(f\"- Name: {profile.display_name}\")\n for key, trait in list(profile.traits.items())[:20]:\n lines.append(f\"- {key}: {trait.value}\")\n for fact in profile.facts[-15:]:\n lines.append(f\"- ({fact.category}) {fact.fact}\")\n if len(lines) == 1:\n lines.append(\n \"_Nothing stored yet. Use_ `/remember …` _or tell me preferences to save._\"\n )\n return {\"text\": \"\\n\".join(lines)}\n except Exception as e:\n return {\"text\": f\"Profile error: {e}\"}\n\n if stripped in (\"/goals\", \"/goal\"):\n if stripped == \"/goal\" or stripped.startswith(\"/goal \"):\n title = raw[len(\"/goal\") :].strip()\n if not title:\n return {\"text\": \"Usage: /goal <title>\"}\n if runtime is not None and hasattr(runtime, \"create_task\"):\n task = runtime.create_task(title, tags=[\"goal\"])\n return {\"text\": f\"Goal added: **{task.title}** (`{task.id}`)\"}\n return {\"text\": \"Runtime not available to store goals.\"}\n if runtime is not None and hasattr(runtime, \"list_tasks\"):\n tasks = runtime.list_tasks()\n goals = [t for t in tasks if \"goal\" in (t.tags or [])] or list(tasks)\n if not goals:\n return {\"text\": \"No goals yet. `/goal <title>` to add one.\"}\n lines = [\n f\"- [{t.status.value}] {t.title}\"\n + (f\" — {t.result_summary}\" if t.result_summary else \"\")\n for t in goals[:30]\n ]\n return {\"text\": \"**Goals**\\n\" + \"\\n\".join(lines)}\n return {\"text\": \"Runtime not available.\"}\n\n if stripped.startswith(\"/approve\"):\n aid = raw[len(\"/approve\") :].strip()\n if not aid:\n from remedy.core.approvals import APPROVALS\n\n pending = APPROVALS.list_pending()\n if not pending:\n return {\"text\": \"No pending approvals.\"}\n lines = [\n f\"- `{p.id}`: {p.reason} — `{p.command[:80]}`\" for p in pending[:10]\n ]\n return {\n \"text\": \"**Pending approvals**\\n\"\n + \"\\n\".join(lines)\n + \"\\n\\n`/approve <id>` to allow.\"\n }\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=True, scope=\"session\")\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\n \"text\": (\n f\"Approved `{item.id}`. Ask Remedy to **retry** the command:\\n\"\n f\"`{item.command[:200]}`\"\n )\n }\n\n if stripped.startswith(\"/deny\"):\n aid = raw[len(\"/deny\") :].strip()\n if not aid:\n return {\"text\": \"Usage: /deny <approval-id>\"}\n from remedy.core.approvals import APPROVALS\n\n item = APPROVALS.resolve(aid, approve=False)\n if not item:\n return {\"text\": f\"Unknown approval id: {aid}\"}\n return {\"text\": f\"Denied `{item.id}` — command will not run.\"}\n\n if stripped.startswith(\"/import\"):\n path = raw[len(\"/import\") :].strip().strip('\"').strip(\"'\")\n if not path:\n return {\n \"text\": \"Usage: /import <folder path>\\n\"\n \"Imports .md/.txt notes into durable memory (knowledge pack).\"\n }\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n from remedy.memory.knowledge_pack import import_knowledge_pack\n\n result = await import_knowledge_pack(memory, path)\n if not result.get(\"ok\"):\n return {\"text\": f\"Import failed: {result.get('error')}\"}\n return {\n \"text\": (\n f\"Imported **{result['imported']}** notes from `{result['root']}` \"\n f\"(scanned {result['scanned']}, skipped {result['skipped']}).\"\n + (\n f\"\\nErrors: {'; '.join(result['errors'][:5])}\"\n if result.get(\"errors\")\n else \"\"\n )\n )\n }\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n path = _find_config_path()\n if path is None:\n return {}\n return _load_toml_config(path)\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n access_scope: str | None = None,\n harness_mode: str | None = None,\n harness_min_context_pct: float | None = None,\n harness_max_context_pct: float | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n if access_scope is not None:\n kwargs[\"access_scope\"] = access_scope\n if harness_mode is not None:\n kwargs[\"harness_mode\"] = harness_mode\n if harness_min_context_pct is not None:\n kwargs[\"harness_min_context_pct\"] = harness_min_context_pct\n if harness_max_context_pct is not None:\n kwargs[\"harness_max_context_pct\"] = harness_max_context_pct\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n if access_scope is not None:\n runtime._access_scope = access_scope\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every message.\"\"\"\n from pathlib import Path\n\n candidates = [\n Path(\"~/.remedy/config.toml\").expanduser(),\n Path(\"~/.remedy/config.yaml\").expanduser(),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n ]\n path = next((p for p in candidates if p.exists()), None)\n if path is None:\n return load_config()\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n return load_config()\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return _config_cache[\"data\"]\n # Prefer explicit path when known; load_config() also scans defaults.\n data = _load_toml_config(path) if path else load_config()\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return data\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # xAI dual auth (OAuth token or stored console key).\n if not api_key and provider.lower() == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n home = cfg.get(\"home_dir\")\n token = resolve_bearer(Path(home).expanduser() if home else None)\n if token:\n api_key = token\n except Exception as exc:\n logger.debug(\"xAI resolve_bearer failed: %s\", exc)\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n for key, value in cfg.items():\n if isinstance(value, dict):\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n else:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n content = \"\".join(lines)\n path.write_text(content, encoding=\"utf-8\")\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "0b304ef52fd194bded4e30071b1c547e603a2a1f": {"t": 1785768518.1686518, "n": 14575, "h": "73e847ef6c16fe90", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy import __version__ as _remedy_version\nfrom remedy.interfaces.config import CONFIG_PATHS\nfrom remedy.interfaces.config import (\n PROVIDER_CATALOG,\n load_config as _load_toml_config,\n)\nfrom remedy.interfaces.config import _is_local_url\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Compact / summarize the current session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str, session_id: str | None, memory\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n stripped = command.strip().lower()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — This help / shortcuts\\n\"\n \" Escape — Close panels and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in your Remedy folder on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\":\n return {\"text\": \"Session compaction requested (stub).\"}\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n path = _find_config_path()\n if path is None:\n return {}\n return _load_toml_config(path)\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every message.\"\"\"\n from pathlib import Path\n\n candidates = [\n Path(\"~/.remedy/config.toml\").expanduser(),\n Path(\"~/.remedy/config.yaml\").expanduser(),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n ]\n path = next((p for p in candidates if p.exists()), None)\n if path is None:\n return load_config()\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n return load_config()\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return _config_cache[\"data\"]\n # Prefer explicit path when known; load_config() also scans defaults.\n data = _load_toml_config(path) if path else load_config()\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return data\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # xAI dual auth (OAuth token or stored console key).\n if not api_key and provider.lower() == \"xai\":\n try:\n from remedy.interfaces.xai_auth import resolve_bearer\n\n home = cfg.get(\"home_dir\")\n token = resolve_bearer(Path(home).expanduser() if home else None)\n if token:\n api_key = token\n except Exception as exc:\n logger.debug(\"xAI resolve_bearer failed: %s\", exc)\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n for key, value in cfg.items():\n if isinstance(value, dict):\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n else:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n content = \"\".join(lines)\n path.write_text(content, encoding=\"utf-8\")\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "ee9078a56d8b165f90bd1ebdeb4aab00ac61e0f3": {"t": 1785768519.5436347, "n": 14121, "h": "0533da0d65830fcf", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy import __version__ as _remedy_version\nfrom remedy.interfaces.config import CONFIG_PATHS\nfrom remedy.interfaces.config import (\n PROVIDER_CATALOG,\n load_config as _load_toml_config,\n)\nfrom remedy.interfaces.config import _is_local_url\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Compact / summarize the current session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str, session_id: str | None, memory\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n stripped = command.strip().lower()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n keys = (\n \"**Keyboard shortcuts**\\n\"\n \" Enter — Send message (composer)\\n\"\n \" Shift+Enter — New line (composer)\\n\"\n \" Ctrl+N — New chat session\\n\"\n \" Ctrl+P / Ctrl+K — Command palette\\n\"\n \" Ctrl+B — Toggle plan mode\\n\"\n \" Ctrl+, — Settings\\n\"\n \" Ctrl+/ or F1 — This help / shortcuts\\n\"\n \" Escape — Close panels and palette\\n\"\n )\n tips = (\n \"\\n**Tips**\\n\"\n \" · Connect a provider in Settings to chat with models.\\n\"\n \" · Plan mode explores without changing files; Build mode can edit.\\n\"\n \" · Type @ to reference project files.\\n\"\n \" · Your data stays in your Remedy folder on this machine.\\n\"\n )\n return {\n \"text\": (\n f\"**Slash commands**\\n{cmds}\\n\\n{keys}{tips}\"\n )\n }\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\":\n return {\"text\": \"Session compaction requested (stub).\"}\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n path = _find_config_path()\n if path is None:\n return {}\n return _load_toml_config(path)\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every message.\"\"\"\n from pathlib import Path\n\n candidates = [\n Path(\"~/.remedy/config.toml\").expanduser(),\n Path(\"~/.remedy/config.yaml\").expanduser(),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n ]\n path = next((p for p in candidates if p.exists()), None)\n if path is None:\n return load_config()\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n return load_config()\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return _config_cache[\"data\"]\n # Prefer explicit path when known; load_config() also scans defaults.\n data = _load_toml_config(path) if path else load_config()\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return data\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n for key, value in cfg.items():\n if isinstance(value, dict):\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n else:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n content = \"\".join(lines)\n path.write_text(content, encoding=\"utf-8\")\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "c2a576cf8e2ecacb777afaba87444921274f062d": {"t": 1785768520.8536832, "n": 13261, "h": "67432c3772835bde", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy import __version__ as _remedy_version\nfrom remedy.interfaces.config import CONFIG_PATHS\nfrom remedy.interfaces.config import (\n PROVIDER_CATALOG,\n load_config as _load_toml_config,\n)\nfrom remedy.interfaces.config import _is_local_url\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Compact / summarize the current session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str, session_id: str | None, memory\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n stripped = command.strip().lower()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n return {\"text\": f\"Available commands:\\n{cmds}\"}\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n count = int(getattr(registry, \"count\", 0) or 0) if registry is not None else 0\n if registry is not None and count > 0:\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\":\n return {\"text\": \"Session compaction requested (stub).\"}\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n path = _find_config_path()\n if path is None:\n return {}\n return _load_toml_config(path)\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every message.\"\"\"\n from pathlib import Path\n\n candidates = [\n Path(\"~/.remedy/config.toml\").expanduser(),\n Path(\"~/.remedy/config.yaml\").expanduser(),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n ]\n path = next((p for p in candidates if p.exists()), None)\n if path is None:\n return load_config()\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n return load_config()\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return _config_cache[\"data\"]\n # Prefer explicit path when known; load_config() also scans defaults.\n data = _load_toml_config(path) if path else load_config()\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return data\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n for key, value in cfg.items():\n if isinstance(value, dict):\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n else:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n content = \"\".join(lines)\n path.write_text(content, encoding=\"utf-8\")\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}, "b556e3703eda9d0c78511e12bce506308a7723a5": {"t": 1785768524.3377757, "n": 13203, "h": "f8129c9f69d0b31c", "c": "\"\"\"Shared API helpers: SSE framing, slash commands, config sync.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom remedy import __version__ as _remedy_version\nfrom remedy.interfaces.config import CONFIG_PATHS\nfrom remedy.interfaces.config import (\n PROVIDER_CATALOG,\n load_config as _load_toml_config,\n)\nfrom remedy.interfaces.config import _is_local_url\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _sse_stream_text(text: str, *, event: str | None = None) -> str:\n \"\"\"Format a single SSE frame.\"\"\"\n prefix = f\"event: {event}\\n\" if event else \"\"\n payload_obj: dict = {\"text\": text}\n if event:\n payload_obj[\"type\"] = event\n payload = json.dumps(payload_obj)\n return f\"{prefix}data: {payload}\\n\\n\"\n\n\ndef sse_headers() -> dict[str, str]:\n return {\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n \"X-Accel-Buffering\": \"no\",\n }\n\n\n# -- built-in slash commands -------------------------------------------------\n\n_BUILTIN_COMMANDS: list[dict] = [\n {\"name\": \"/help\", \"description\": \"Show available commands\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/new\", \"description\": \"Create a new chat session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/sessions\", \"description\": \"List recent sessions\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/compact\", \"description\": \"Compact / summarize the current session\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/models\", \"description\": \"List available models\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/thinking\", \"description\": \"Toggle thinking visibility\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/memory\", \"description\": \"Search memory\", \"aliases\": [], \"arguments\": \"query\"},\n {\"name\": \"/skills\", \"description\": \"List available skills\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/handoff\", \"description\": \"List handoff notes\", \"aliases\": [], \"arguments\": None},\n {\"name\": \"/init\", \"description\": \"Scan the project and generate AGENTS.md\", \"aliases\": [], \"arguments\": \"path\"},\n]\n\n# Legacy flat list kept for slash-command fallback only; list_models uses\n# PROVIDER_CATALOG and filters strictly by the configured provider.\n_BUILTIN_MODELS: list[dict] = []\nfor _prov, _meta in PROVIDER_CATALOG.items():\n for _m in _meta.get(\"models\") or []:\n _BUILTIN_MODELS.append(\n {\n \"id\": _m[\"id\"],\n \"name\": _m.get(\"name\", _m[\"id\"]),\n \"provider\": _prov,\n \"default\": False,\n }\n )\n\n_BUILTIN_AGENTS: list[dict] = [\n {\"name\": \"default\", \"description\": \"Remedy — general-purpose agent\", \"build_mode\": True},\n {\"name\": \"remedy\", \"description\": \"Remedy — meta-orchestrator with skill routing\", \"build_mode\": True},\n {\"name\": \"explore\", \"description\": \"Codebase explorer for search and analysis\", \"build_mode\": False},\n {\"name\": \"general\", \"description\": \"General-purpose agent for complex tasks\", \"build_mode\": True},\n]\n\n\nasync def handle_slash_command(\n command: str, session_id: str | None, memory\n) -> dict:\n \"\"\"Execute a slash command and return a result.\"\"\"\n stripped = command.strip().lower()\n\n if stripped in (\"/help\", \"/h\"):\n cmds = \"\\n\".join(f\" {c['name']} — {c['description']}\" for c in _BUILTIN_COMMANDS)\n return {\"text\": f\"Available commands:\\n{cmds}\"}\n\n if stripped in (\"/new\", \"/n\"):\n return {\"text\": \"Session marked for creation.\", \"action\": \"new_session\"}\n\n if stripped in (\"/sessions\", \"/s\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n sessions = await memory.list_chat_sessions(limit=10)\n if not sessions:\n return {\"text\": \"No sessions found.\"}\n lines = []\n for s in sessions:\n sid = getattr(s, \"id\", None) or (s.get(\"id\") if isinstance(s, dict) else \"\")\n title = getattr(s, \"title\", None) or (s.get(\"title\") if isinstance(s, dict) else \"Untitled\")\n count = getattr(s, \"message_count\", None)\n if count is None and isinstance(s, dict):\n count = s.get(\"message_count\", 0)\n lines.append(f\" {title} — {count or 0} msg — {str(sid)[:8]}\")\n return {\"text\": \"Recent sessions:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/models\", \"/m\"):\n return {\n \"text\": (\n \"Model list is filtered by your configured provider. \"\n \"Use the model picker in the status bar, or GET /api/models.\"\n ),\n \"action\": \"list_models\",\n }\n\n if stripped == \"/thinking\":\n return {\"text\": \"Thinking visibility toggled.\"}\n\n if stripped.startswith(\"/memory \"):\n query = command[len(\"/memory \"):].strip()\n if not query or memory is None:\n return {\"text\": \"Usage: /memory <query>\"}\n entries = await memory.search(query, limit=5)\n if not entries:\n return {\"text\": \"No memory entries found.\"}\n lines = []\n for e in entries:\n lines.append(f\" **{e.title}** — {e.content[:120]}\")\n return {\"text\": \"Memory results:\\n\" + \"\\n\".join(lines)}\n\n if stripped in (\"/memory\", \"/mem\"):\n return {\"text\": \"Usage: /memory <query>\"}\n\n if stripped in (\"/skills\", \"/sk\") or stripped.startswith(\"/skills \"):\n # Prefer live runtime registry; fall back to empty guidance.\n # Note: handle_slash_command doesn't receive runtime — use memory path via app state.\n # Callers that pass runtime through a side channel aren't available here, so we\n # re-read from a module-level hook set by create_app when possible.\n registry = getattr(handle_slash_command, \"_skills_registry\", None)\n if registry is not None and getattr(registry, \"count\", 0):\n lines = registry.summary_lines()\n tools_hint = (\n \"\\n\\n**Built-in tools** (always available): \"\n \"`file_read`, `file_write`, `list_dir`, `bash_exec`.\\n\"\n \"Skills are procedure packs the agent follows; tools are executable actions.\"\n )\n return {\n \"text\": f\"**{registry.count} skills loaded:**\\n\" + \"\\n\".join(lines) + tools_hint\n }\n return {\n \"text\": (\n \"No skills loaded yet. Default skills ship with Remedy — restart the server \"\n \"to discover bundled skills, or drop SKILL.md packages into `~/.remedy/skills/`.\\n\\n\"\n \"**Built-in tools:** `file_read`, `file_write`, `list_dir`, `bash_exec`.\"\n )\n }\n\n if stripped in (\"/handoff\", \"/ho\"):\n if memory is None:\n return {\"text\": \"Memory store not available.\"}\n handoffs = await memory.list_handoffs(limit=5)\n if not handoffs:\n return {\"text\": \"No handoff notes found.\"}\n lines = []\n for h in handoffs:\n lines.append(f\" **{h.title}** — {h.content[:100]}\")\n return {\"text\": \"Handoffs:\\n\" + \"\\n\".join(lines)}\n\n if stripped == \"/compact\":\n return {\"text\": \"Session compaction requested (stub).\"}\n\n if stripped.startswith(\"/init\"):\n parts = stripped.split(\" \", 1)\n path = parts[1] if len(parts) > 1 else \".\"\n return {\"text\": f\"Project scan requested for: {path}\\nUse the API endpoint POST /api/projects/scan?path=... for detailed results.\", \"action\": \"init_scan\"}\n\n return {\"text\": f\"Unknown command: {command}\\nType /help for available commands.\"}\n\n\ndef _default_config_path() -> Path:\n \"\"\"Canonical user config path (matches desktop sidecar --home).\"\"\"\n return Path.home() / \".remedy\" / \"config.toml\"\n\n\ndef _find_config_path() -> Path | None:\n # Prefer the home config so desktop and CLI always share one persistent file.\n primary = _default_config_path()\n if primary.exists():\n return primary\n for p in CONFIG_PATHS:\n expanded = p.expanduser().resolve()\n if expanded.exists():\n return expanded\n return None\n\n\ndef load_config() -> dict[str, Any]:\n path = _find_config_path()\n if path is None:\n return {}\n return _load_toml_config(path)\n\n\ndef _apply_llm_to_runtime(\n runtime: Any,\n *,\n provider: str,\n model: str,\n base_url: str,\n api_key: str | None = None,\n persona: str | None = None,\n name: str | None = None,\n project_path: str | None = None,\n) -> None:\n \"\"\"Push LLM settings into the live runtime so chat uses the saved config.\"\"\"\n if runtime is None:\n return\n if hasattr(runtime, \"reconfigure_llm\"):\n kwargs: dict[str, Any] = {\n \"provider\": provider,\n \"model\": model,\n \"base_url\": base_url,\n \"api_key\": api_key,\n \"persona\": persona,\n \"name\": name,\n }\n if project_path is not None:\n kwargs[\"project_path\"] = project_path\n runtime.reconfigure_llm(**kwargs)\n return\n # Fallback for older runtimes without reconfigure_llm\n if provider:\n runtime._llm_provider = provider\n if model:\n runtime._llm_model = model\n if base_url:\n runtime._llm_base_url = base_url\n if api_key is not None and api_key != \"\":\n runtime._llm_api_key = api_key\n if project_path is not None and hasattr(runtime, \"set_project_path\"):\n runtime.set_project_path(project_path, as_default=True)\n\n\n# Cache config disk reads across chat messages; invalidate on mtime/size change.\n_config_cache: dict[str, Any] = {\"path\": None, \"mtime\": None, \"size\": None, \"data\": None}\n\n\ndef _load_config_cached() -> dict[str, Any]:\n \"\"\"load_config() with a cheap mtime/size cache to avoid re-reading every message.\"\"\"\n from pathlib import Path\n\n candidates = [\n Path(\"~/.remedy/config.toml\").expanduser(),\n Path(\"~/.remedy/config.yaml\").expanduser(),\n Path(\"remedy.toml\"),\n Path(\"remedy.yaml\"),\n ]\n path = next((p for p in candidates if p.exists()), None)\n if path is None:\n return load_config()\n try:\n st = path.stat()\n mtime, size = st.st_mtime, st.st_size\n except OSError:\n return load_config()\n if (\n _config_cache[\"path\"] == str(path)\n and _config_cache[\"mtime\"] == mtime\n and _config_cache[\"size\"] == size\n and isinstance(_config_cache[\"data\"], dict)\n ):\n return _config_cache[\"data\"]\n # Prefer explicit path when known; load_config() also scans defaults.\n data = _load_toml_config(path) if path else load_config()\n _config_cache.update({\"path\": str(path), \"mtime\": mtime, \"size\": size, \"data\": data})\n return data\n\n\ndef _sync_runtime_llm_from_config(\n runtime: Any,\n *,\n model_override: str | None = None,\n) -> str:\n \"\"\"Reload provider/model/url/key from disk into the live runtime.\n\n Returns the effective API key (may be empty). Re-reads config when the file\n changes (or first call) so settings saved after server start apply without\n a restart, without paying for a full disk parse on every message.\n \"\"\"\n if runtime is None:\n return \"\"\n cfg = _load_config_cached()\n provider = str(\n cfg.get(\"llm_provider\")\n or getattr(runtime, \"_llm_provider\", None)\n or os.environ.get(\"REMEDY_LLM_PROVIDER\")\n or \"openai\"\n )\n model = str(\n model_override\n or cfg.get(\"llm_model\")\n or getattr(runtime, \"_llm_model\", None)\n or os.environ.get(\"REMEDY_LLM_MODEL\")\n or \"\"\n )\n base_url = str(\n cfg.get(\"llm_base_url\")\n or getattr(runtime, \"_llm_base_url\", None)\n or os.environ.get(\"REMEDY_LLM_BASE_URL\")\n or \"\"\n )\n api_key = str(\n cfg.get(\"llm_api_key\")\n or os.environ.get(\"REMEDY_LLM_API_KEY\")\n or getattr(runtime, \"_llm_api_key\", \"\")\n or \"\"\n )\n # Local providers: ensure a dummy key so stream path does not fall back.\n if not api_key and (\n provider.lower() == \"ollama\" or (base_url and _is_local_url(base_url))\n ):\n api_key = \"local\"\n\n _apply_llm_to_runtime(\n runtime,\n provider=provider,\n model=model,\n base_url=base_url,\n api_key=api_key if api_key else None,\n )\n return str(getattr(runtime, \"_llm_api_key\", \"\") or api_key or \"\")\n\n\ndef _write_config(path: Path, cfg: dict[str, Any]) -> None:\n lines = []\n lines.append(\"# Remedy AI Configuration\\n\\n\")\n for key, value in cfg.items():\n if isinstance(value, dict):\n lines.append(f\"[{key}]\\n\")\n for k, v in value.items():\n lines.append(f\"{k} = {_serialize_toml(v)}\\n\")\n lines.append(\"\\n\")\n else:\n lines.append(f\"{key} = {_serialize_toml(value)}\\n\")\n content = \"\".join(lines)\n path.write_text(content, encoding=\"utf-8\")\n\n\ndef _serialize_toml(value: Any) -> str:\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, (int, float)):\n return str(value)\n if isinstance(value, list):\n items = \", \".join(_serialize_toml(v) for v in value)\n return f\"[{items}]\"\n return json.dumps(str(value))\n\n\n"}}}} |