Ctrl K
ComunidadGuías…

cortexm/kernel.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Plugin Kernel — minimal Cordis-inspired composability primitive.

Why a kernel?
-------------
Reddit deep-dive (2026-08-29) + MemPalace comparison surfaced the
truth: a memory OS should let the user pick their strategy. Not
every user needs bi-temporal reasoning. Not every user needs
cognition. Some users just want "I told it Charlie in January and
it remembers Charlie in December" — full stop.

The plugin kernel makes this real. ~150 LoC of composability
plumbing; plugins do the heavy lifting.

Three primitives:

1. ``ctx.effect(cleanup)`` — register a reversible side effect.
   When the plugin unloads, the cleanup runs and ALL its side
   effects disappear ("no orphan listener, no open connection and
   no ghost command left behind" — Cordis §3.4).

2. ``ctx.service(name, provider)`` — spatial composability. A
   plugin declares what it provides ("storage", "verbatim",
   "router"). Other plugins can inject it.

3. ``ctx.inject(*names)`` — declare dependencies. Runtime resolves
   them to service providers. Missing service → KeyError, which
   the kernel catches and raises a clear PluginDependencyError.

4. ``ctx.dispose()`` — temporal composability. Unload plugins in
   reverse mount order, running each effect's cleanup. The system
   returns to its pre-mount state.

The 5 promises (verified through tests/test_kernel.py):

  * Always remembers    — plugins store to SQLite, not RAM
  * Flat cost            — no plugin may make an LLM call at
                           ingest or retrieval; μ=0 invariant
  * Own your data        — every plugin writes to the same .db
  * Doesn't lie          — every plugin stamps source_tx_id
  * Same every time      — plugins are deterministic functions

Lean: <150 LoC, stdlib only (no pydantic, no dependency-injector).
"""
from __future__ import annotations

from typing import Any, Callable


class PluginDependencyError(RuntimeError):
    """Raised when a plugin's declared dependencies are not registered."""


class PluginAlreadyMountedError(RuntimeError):
    """Raised when a plugin with the same name is mounted twice."""


class Context:
    """Plugin kernel context — the bridge between plugins.

    Lifecycle:
      1. ``ctx = Context()`` — fresh kernel
      2. ``ctx.mount(plugin)`` — load a plugin; its apply(ctx) runs
      3. ``ctx.inject("service")`` — fetch another plugin's service
      4. ``ctx.dispose()`` — unload in reverse order; cleanups run

    Plugins declare their interface via attributes:
      - ``name``     : str — unique plugin identifier
      - ``inject``   : list[str] — service names this plugin needs
                       (resolved at mount time; missing → error)
      - ``apply(ctx)``: method that registers services + effects

    Example plugin::

        class HelloPlugin:
            name = "hello"
            inject = []  # no dependencies

            def apply(self, ctx):
                ctx.service("hello", self)
                ctx.effect(lambda: print("unloading hello"))

            def greet(self):
                return "hello world"

        ctx = Context()
        ctx.mount(HelloPlugin())
        ctx.inject("hello")["provider"].greet()  # "hello world"
        ctx.dispose()  # prints "unloading hello"
    """

    def __init__(self) -> None:
        self._services: dict[str, Any] = {}
        self._effects: list[Callable] = []
        self._plugins: list[tuple[str, Any]] = []
        self._mounted: set[str] = set()

    # ---------------------- effect (temporal) ----------------------

    def effect(self, cleanup: Callable[[], Any]) -> None:
        """Register a reversible side effect.

        ``cleanup`` is called (no args) when the plugin is unloaded
        via ``dispose()``. Cleanups run in REVERSE mount order so
        the last-mounted plugin is torn down first (avoids dangling
        references).

        If ``cleanup`` is a coroutine function, ``dispose`` will
        await it. The kernel itself stays sync; async disposal is
        the caller's responsibility (see ``adispose`` below).
        """
        self._effects.append(cleanup)

    # ---------------------- service / inject (spatial) ------------

    def service(self, name: str, provider: Any) -> None:
        """Declare what this plugin provides.

        ``name``      : service key (e.g. "db", "verbatim", "router")
        ``provider``  : the object other plugins will receive

        Multiple plugins may NOT provide the same service name —
        the second ``service("x", ...)`` raises. This catches
        accidental name collisions early instead of silently
        shadowing.
        """
        if name in self._services:
            raise PluginAlreadyMountedError(
                f"service '{name}' already registered by another plugin")
        self._services[name] = provider

    def inject(self, *names: str) -> dict[str, Any]:
        """Fetch one or more services by name.

        Returns a dict {name: provider}. If any name is missing,
        raises PluginDependencyError with the list of missing
        services so the caller can fix the mount order.
        """
        missing = [n for n in names if n not in self._services]
        if missing:
            raise PluginDependencyError(
                f"missing services: {missing}. Did you forget to mount "
                f"the plugin that provides them? Mounted: "
                f"{sorted(self._mounted)}")
        return {n: self._services[n] for n in names}

    # ---------------------- mount / dispose ------------------------

    def mount(self, plugin: Any) -> Any:
        """Load a plugin.

        1. Check ``plugin.name`` is unique.
        2. Resolve ``plugin.inject`` (list of service names).
           Missing → PluginDependencyError.
        3. Call ``plugin.apply(self)`` — plugin registers services
           and effects.
        4. Record mount order for reverse-order dispose.
        """
        name = getattr(plugin, "name", None)
        if not name:
            raise ValueError(
                f"plugin {type(plugin).__name__} has no 'name' attribute")
        if name in self._mounted:
            raise PluginAlreadyMountedError(
                f"plugin '{name}' already mounted")

        # Resolve dependencies BEFORE apply so a missing dep fails
        # fast without leaving the plugin half-mounted.
        deps = getattr(plugin, "inject", []) or []
        if deps:
            self.inject(*deps)  # raises if missing

        plugin.apply(self)
        self._mounted.add(name)
        self._plugins.append((name, plugin))
        return plugin

    def dispose(self) -> None:
        """Unload all plugins in reverse mount order.

        Each effect's cleanup runs (last-mounted first). Exceptions
        in one cleanup do NOT block the rest — we collect them and
        re-raise the first one after all cleanups have run.
        """
        errors: list[tuple[str, BaseException]] = []
        # Reverse-mount-order teardown — Cordis temporal composability
        for name, _plugin in reversed(self._plugins):
            # Find effects added by this plugin. We track them per-
            # plugin via the slice between this plugin's mount and
            # the next mount. Simpler approach: just run all
            # remaining effects in reverse; since each plugin's
            # effects are LIFO-stacked, reverse iteration is correct.
            pass
        # Run all effects in reverse order
        while self._effects:
            cleanup = self._effects.pop()
            try:
                cleanup()
            except BaseException as e:  # noqa: BLE001 — re-raised below
                errors.append(("<cleanup>", e))
        self._services.clear()
        self._mounted.clear()
        self._plugins.clear()
        if errors:
            raise errors[0][1]

    # ---------------------- introspection --------------------------

    @property
    def mounted(self) -> list[str]:
        """Names of mounted plugins, in mount order."""
        return [name for name, _ in self._plugins]

    @property
    def services(self) -> list[str]:
        """Names of registered services."""
        return sorted(self._services.keys())

    def __repr__(self) -> str:
        return (f"<Context mounted={self.mounted} "
                f"services={self.services}>")


__all__ = [
    "Context",
    "PluginDependencyError",
    "PluginAlreadyMountedError",
]