Files
vectorize-io__hindsight/hindsight-extensions
Nicolò Boschi ac41cee604 feat(extensions): add hindsight-extensions registry and unbundle Supabase (#3988)
* feat(extensions): add hindsight-extensions registry and unbundle Supabase

Extensions were only ever bundle-able or nothing: shipping one meant putting
it in `hindsight_api.extensions.builtin`, where it becomes maintainer-owned
forever, lands in every image, and — because `extensions/__init__.py` eagerly
re-exported every implementation — drags its dependencies into core's import
graph. That pipe is why a third-party IdP's JWT client was a direct dependency
of every Hindsight install.

Add `hindsight-extensions/` as the registry for extensions distributed
separately from the server. Its README is the contract: slots and how config
env vars map onto them, how to write an extension, the package layout and
naming (`hindsight-extensions/<name>/` -> `hindsight-ext-<name>` ->
`hindsight_ext_<name>`), and Docker packaging.

Move `SupabaseTenantExtension` there as the first entry, published as
`hindsight-ext-supabase-tenant`. All 54 of its tests move with it, plus two new
ones asserting the documented `hindsight_ext_supabase_tenant:...` env value
actually resolves through `load_extension`.

Two decisions worth their comments:

- The extension does NOT declare `hindsight-api-slim` as a runtime dependency.
  The server is the host process that imports it, not something it installs;
  declaring it would let `pip install` of an extension silently move the server
  version underneath a running deployment. It is a dev extra, resolved from the
  local checkout via `tool.uv.sources` (dev-only metadata, verified absent from
  the built wheel).
- The Docker example installs with `uv pip install --python
  /app/api/.venv/bin/python`, matching docker-compose/custom-models: the image's
  venv was created by `uv sync` and ships no `pip`, so a bare `pip install`
  lands in user site-packages and is invisible to the server.

Core changes:

- `extensions/__init__.py` and `builtin/__init__.py` export interfaces only.
  Nothing needed the concrete re-exports — the loader imports by path — and
  dropping them is what lets an extension have optional dependencies at all.
- `builtin/supabase_tenant.py` stays for one minor release as a module whose
  `__getattr__` raises the migration instructions. `load_extension` wraps a
  missing *attribute*, not a failed import, so the ImportError propagates with
  its message intact instead of surfacing as "class not found".
- Drop the direct `PyJWT[crypto]` dependency: no core module imports `jwt` any
  more. Note this does not shrink the install — `mcp` pulls pyjwt transitively
  and `cryptography` is already pinned directly — so the win here is ownership
  and import graph, not bytes.

Locks are not checked in for extensions: `tool.uv.sources` pins the whole
api-slim tree, so every core dependency bump would leave them stale. CI runs
`uv sync --extra dev` and retriggers on `core` changes, since these tests run
against the server's interfaces.

Docs point at the registry rather than restating it, and the Deploying section's
Docker recipe was replaced — it named an image (`vectorize/hindsight-api`) and a
PYTHONPATH volume-mount pattern that no longer exist.

Also includes two one-line generated-file syncs in skills/hindsight-docs
(quickstart, installation) that were already stale on main; regenerating the
docs skill picks them up.

* refactor(extensions): ship extensions by image, drop the compat shim

Follow-up on review. Three changes to how an extension is distributed:

- Delete `builtin/supabase_tenant.py`. An install pinned to the old path now
  fails at startup with ModuleNotFoundError rather than a guided message. The
  docs carry the migration instead.
- Extensions are not published to PyPI. There is no wheel, no version and no
  release step: the unit of distribution is an image built on top of Hindsight
  that installs the extension's dependencies and copies the package onto
  PYTHONPATH. That drops the whole "declare hindsight-api-slim only as a dev
  extra" problem — nothing resolves dependencies against a running server any
  more.
- The pyproject is now test-harness only (`package = false`, no build backend,
  no distribution metadata), and says so in a comment so nobody re-adds
  packaging to it.

Docs say 0.9.3, not 0.10.

Since the Dockerfile is now the distribution mechanism rather than an example,
CI builds it — its final `import` step is the only thing proving the extension
is reachable from the interpreter the server actually runs. It builds against
`:latest-slim` via a HINDSIGHT_IMAGE build arg to keep the pull cheap.

Verified against the real image, not just locally:

  docker build -f hindsight-extensions/supabase-tenant/Dockerfile \
    --build-arg HINDSIGHT_IMAGE=ghcr.io/vectorize-io/hindsight:latest-slim ...
  -> load_extension('TENANT', TenantExtension) inside the container returns
     SupabaseTenantExtension with its config resolved from the env vars.

Worth noting from that build: `uv pip install 'PyJWT[crypto]' httpx` reports
"Checked 2 packages" — both are already in the base image transitively. The
line stays because the extension should pin what it imports rather than rely on
the server's transitive tree, but it costs nothing today.

56 extension tests pass; the 3 remaining core tests (which assert no
implementation is re-exported and no core module imports jwt) pass.

* fix(tests): import ApiKeyTenantExtension from its module, not the package

Dropping the concrete re-exports from `hindsight_api.extensions` broke
`tests/test_extensions.py`, which imported `ApiKeyTenantExtension` from the
package inside a multi-line parenthesised import. A collection ImportError
fails the whole shard, which is why all three test-api shards and all six LLM
acceptance jobs went red at once on the previous push.

I'd checked for this with a single-line grep, which cannot see a name inside a
parenthesised import list. Re-checked with an AST scan over every package in
the repo (this was the only occurrence) and by collecting the full suite:
7780 tests collect clean.
2026-09-01 14:47:37 +02:00
..

Hindsight Extensions

Extensions customise the Hindsight API server without forking it: multi-tenancy and authentication, extra HTTP endpoints, extra MCP tools, and hooks around retain/recall/reflect. They are ordinary Python packages that the server imports by path at startup.

This directory is the registry. Each subdirectory is an extension that lives outside the server, so installing Hindsight does not drag in a third-party vendor's client library and changing an extension does not require a Hindsight release.

Extensions here are not published to PyPI. You ship one by building an image on top of Hindsight that copies the extension in — see Packaging.

Registry

Extension Slot What it does
supabase-tenant TENANT Validates Supabase Auth JWTs and gives each user their own Postgres schema

Extensions maintained outside this repository can be listed here too — open a PR adding a row that links to yours.

What stays in the server

Two extensions ship with hindsight-api-slim because they add no dependencies and any deployment may want them:

  • hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension — single shared API key, single schema.
  • hindsight_api.extensions.builtin.memory_defense_regex:MemoryDefenseRegexExtension — regex-based memory defense policies.

Anything that talks to a specific vendor, identity provider, or deployment style belongs here instead.


Extension slots

The server loads at most one extension per slot, each from its own environment variable in module.path:ClassName form:

Slot Environment variable Base class
Tenancy / auth HINDSIGHT_API_TENANT_EXTENSION TenantExtension
HTTP endpoints HINDSIGHT_API_HTTP_EXTENSION HttpExtension
MCP tools HINDSIGHT_API_MCP_EXTENSION MCPExtension
Operation hooks HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION OperationValidatorExtension
Memory defense HINDSIGHT_API_MEMORY_DEFENSE_EXTENSION MemoryDefenseExtension

Every other environment variable sharing the slot's prefix becomes the extension's config, lowercased and with the prefix stripped. For the TENANT slot:

HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension
HINDSIGHT_API_TENANT_SUPABASE_URL=https://xxx.supabase.co   # -> config["supabase_url"]
HINDSIGHT_API_TENANT_SCHEMA_PREFIX=user                     # -> config["schema_prefix"]

There is nothing to register: if the class is importable in the server's Python environment and subclasses the slot's base class, it loads.


Writing an extension

# hindsight_ext_myauth/extension.py
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
from hindsight_api.models import RequestContext


class MyTenantExtension(TenantExtension):
    def __init__(self, config: dict[str, str]) -> None:
        super().__init__(config)
        self.secret = config.get("secret")
        if not self.secret:
            raise ValueError("HINDSIGHT_API_TENANT_SECRET is required")

    async def on_startup(self) -> None:
        """Open clients, warm caches. Raise to stop the server booting misconfigured."""

    async def authenticate(self, context: RequestContext) -> TenantContext:
        if context.api_key != self.secret:
            raise AuthenticationError("Invalid API key")
        return TenantContext(schema_name="my_tenant")

    async def list_tenants(self) -> list[Tenant]:
        """Schemas the background worker should process."""
        return [Tenant(schema="my_tenant")]

    async def on_shutdown(self) -> None:
        """Close what on_startup opened."""

Things worth knowing before you write one:

  • Validate config in __init__. A misconfigured extension should fail the server's startup, not the first request that hits it.
  • self.context is an ExtensionContext, the supported API into the server. For tenant extensions the important call is await self.context.run_migration(schema), which provisions a new tenant schema. Cache the schemas you have already migrated — authenticate runs on every request.
  • list_tenants() drives the background worker. A schema you never return gets no consolidation or maintenance, so returning only schemas seen since the last restart means tenants go stale until they are used again.
  • Extensions run in-process, inside the auth boundary. A TenantExtension decides which tenant's data a request can reach; treat schema names derived from user input as untrusted and validate their shape before they reach a schema name.

The interfaces live in hindsight-api-slim/hindsight_api/extensions/; each base class documents the full method set.


Packaging an extension

Layout — one directory per extension, mirroring supabase-tenant/:

hindsight-extensions/<name>/
├── pyproject.toml            # test harness only — not a published distribution
├── README.md                 # config reference + how to build an image with it
├── Dockerfile                # the image that ships it
├── hindsight_ext_<name>/
│   ├── __init__.py           # re-export the class for a short import path
│   └── extension.py
└── tests/

Naming keeps the env var short and unambiguous:

Directory hindsight-extensions/supabase-tenant/
Import package hindsight_ext_supabase_tenant
Env value hindsight_ext_supabase_tenant:SupabaseTenantExtension

There is no version number, no wheel and no release step. The unit of distribution is the image you build, so the extension is exactly as current as the checkout you built it from.

The pyproject is for tests, not packaging

The pyproject.toml exists so uv run pytest works. It declares package = false, so uv puts the dependencies in a virtualenv and leaves the sources on the path without building anything:

[project]
name = "hindsight-ext-<name>"
version = "0"
requires-python = ">=3.11"
dependencies = [
    "PyJWT[crypto]>=2.12.0",   # whatever your extension imports
    "httpx>=0.27.0",
    "hindsight-api-slim",      # test-time only: the interfaces you write against
    "pytest>=7.0.0",
    "pytest-asyncio>=0.21.0",
]

[tool.uv]
package = false

[tool.uv.sources]
hindsight-api-slim = { path = "../../hindsight-api-slim", editable = true }

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
asyncio_mode = "auto"

hindsight-api-slim belongs here and only here. At runtime the server is the host process that imports your extension, not something your extension installs — an extension that pulled the server in as a dependency could move the server version underneath the deployment it was being added to.

Develop and test

From the extension directory:

uv sync                  # deps plus the server from ../../hindsight-api-slim
uv run pytest tests -v

Extension tests are plain unit tests — construct the class with a config dict, mock whatever it talks to, and assert on the TenantContext / ValidationResult it returns. They do not need a database.

To run a real server against your extension without building an image:

cd ../../hindsight-api-slim
PYTHONPATH=../hindsight-extensions/myauth \
HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_myauth:MyTenantExtension \
HINDSIGHT_API_TENANT_SECRET=dev-secret \
uv run hindsight-api

Docker packaging

The Hindsight image does not carry extensions. Ship yours by building an image on top of it that installs the extension's dependencies and copies the extension in.

FROM ghcr.io/vectorize-io/hindsight:latest

# Install into the server's virtualenv explicitly. It was created by `uv sync`
# and ships no `pip` of its own, so a bare `pip install` would land in user
# site-packages and be invisible to the running server.
RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
      'PyJWT[crypto]>=2.12.0' \
      'httpx>=0.27.0'

# /app/extensions is ours — the image does not use it — so nothing the server
# ships can be shadowed by what lands here.
COPY hindsight-extensions/supabase-tenant/hindsight_ext_supabase_tenant \
     /app/extensions/hindsight_ext_supabase_tenant
ENV PYTHONPATH=/app/extensions

# Fail the build, rather than the first authenticated request.
RUN /app/api/.venv/bin/python -c "import hindsight_ext_supabase_tenant"

That last import line is the one worth keeping: without it a packaging mistake ships happily and only surfaces when a request reaches the extension.

Build from the repository root, so the extension sources are in the build context:

docker build -f hindsight-extensions/supabase-tenant/Dockerfile -t hindsight-with-supabase .

docker run -p 8888:8888 \
  -e HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension \
  -e HINDSIGHT_API_TENANT_SUPABASE_URL=https://xxx.supabase.co \
  -e HINDSIGHT_API_DATABASE_URL=postgresql://... \
  hindsight-with-supabase

Use ghcr.io/vectorize-io/hindsight:latest-slim as the base if you do not need the bundled local embedding/reranking models.

Same shape in docker-compose.yml — build the image and pass the extension's variables as environment:

services:
  hindsight-api:
    build:
      context: .                                                  # repository root
      dockerfile: hindsight-extensions/supabase-tenant/Dockerfile
    environment:
      HINDSIGHT_API_TENANT_EXTENSION: hindsight_ext_supabase_tenant:SupabaseTenantExtension
      HINDSIGHT_API_TENANT_SUPABASE_URL: https://xxx.supabase.co

A worker deployment loads the same tenant extension as the API, so give both containers the identical extension variables — otherwise the worker cannot enumerate tenant schemas and background consolidation stops for every tenant.

Do not install extensions at container start (an entrypoint that runs pip install). That resolves unpinned code over the network into a running server on every restart.


Contributing an extension

Open a PR adding hindsight-extensions/<name>/ with the layout above:

  • a README.md documenting every environment variable it reads,
  • tests that exercise the extension through its base-class interface,
  • a Dockerfile that builds an image with it,
  • a row in the registry table above.

Extensions here are owned by their contributors. If you would rather host yours yourself, add a registry row pointing at your repository and package — no code needs to live in this tree.