Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/ahmidarrow_remedyai.json
T

1 line
2.3 MiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}