Newer
Older
navi-1 / navi / context_providers / _loader.py
"""Context provider registry and loader.

Context providers inject dynamic runtime data into the LLM context on every call.
Each provider is a .py module with:
  - name: str
  - description: str
  - global_provider: bool  (True = injected in all profiles regardless of config)
  - async def get_context() -> str | None
"""

import importlib.util
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import structlog

log = structlog.get_logger()

_REQUIRED = ("name", "description", "get_context")
_BUILTIN_PREFIX = "navi_cp"
_USER_PREFIX = "user_cp"


@dataclass
class CPLoadResult:
    loaded: list[str] = field(default_factory=list)
    errors: dict[str, str] = field(default_factory=dict)


class ContextProviderRegistry:
    def __init__(self) -> None:
        self._providers: dict[str, Any] = {}
        self._builtin_names: set[str] = set()

    def register(self, module: Any, builtin: bool = False) -> None:
        self._providers[module.name] = module
        if builtin:
            self._builtin_names.add(module.name)

    def get(self, name: str) -> Any | None:
        return self._providers.get(name)

    def all_names(self) -> list[str]:
        return list(self._providers)

    def get_globals(self) -> list[Any]:
        return [p for p in self._providers.values() if getattr(p, "global_provider", False)]

    def get_named(self, names: list[str]) -> list[Any]:
        return [self._providers[n] for n in names if n in self._providers]

    def load_from_dir(self, path: str, builtin: bool = False) -> CPLoadResult:
        result = CPLoadResult()
        dir_path = Path(path)
        if not dir_path.is_dir():
            return result
        prefix = _BUILTIN_PREFIX if builtin else _USER_PREFIX
        for py_file in sorted(dir_path.glob("*.py")):
            if py_file.stem.startswith("_"):
                continue
            mod_key = f"{prefix}.{py_file.stem}"
            try:
                spec = importlib.util.spec_from_file_location(mod_key, py_file)
                if spec is None or spec.loader is None:
                    raise ImportError(f"Cannot create spec for {py_file.name}")
                mod = importlib.util.module_from_spec(spec)
                sys.modules[mod_key] = mod
                spec.loader.exec_module(mod)  # type: ignore[union-attr]
                missing = [r for r in _REQUIRED if not hasattr(mod, r)]
                if missing:
                    raise AttributeError(f"Missing required definitions: {', '.join(missing)}")
                if not callable(getattr(mod, "get_context", None)):
                    raise AttributeError("get_context must be callable")
                self.register(mod, builtin=builtin)
                result.loaded.append(mod.name)
                log.info("context_provider.loaded", name=mod.name, file=py_file.name)
            except Exception as exc:
                result.errors[py_file.name] = str(exc)
                sys.modules.pop(mod_key, None)
                log.warning("context_provider.load_error", file=py_file.name, error=str(exc))
        return result

    def reload_user_providers(self, path: str) -> CPLoadResult:
        for name in [n for n in self._providers if n not in self._builtin_names]:
            del self._providers[name]
        for key in [k for k in sys.modules if k.startswith(f"{_USER_PREFIX}.")]:
            del sys.modules[key]
        return self.load_from_dir(path, builtin=False)