Ctrl K
ComunidadGuías…

Dockerfile

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

Archivo original ↗Preguntar a KiBot
# exomem — multi-stage container image.
#
# Four final targets share this one file:
#   lean (target `lean`, DEFAULT — base deps only, no torch, no model download)
#   ml   (target `ml`   — the `embeddings` extra, CPU-only torch, no CUDA runtime)
#   cuda (target `cuda` — the `embeddings` extra, CUDA-capable torch, CPU-default at idle)
#   cloud (target `cloud` — Exomem Cloud cell runtime, derived from `cell-runtime`)
#
# `lean` is intentionally the LAST stage in this file: `docker build .` / `docker
# buildx build .` with no `--target` builds the final stage in the file, and lean
# is meant to be that zero-argument default (matches the stdio one-liner's
# zero-Python, zero-model-download onboarding promise — see docs/docker.md).
# Build `ml` explicitly with `--target ml` (or pull the published `:ml` tag).
# Build `cuda` explicitly with `--target cuda` (or pull the published `:cuda` tag).
#
# Every stage builds from the checked-out source tree (COPY src/ pyproject.toml
# uv.lock), never a published PyPI wheel — see design.md D1.

ARG UV_VERSION=0.11.28

# COPY --from does not support ARG expansion; a global-scope FROM alias does
# (buildx's documented workaround), so the pin lives in one place above.
FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv

########################################################################
# builder-lean — base dependencies only, installed from local source.
# Never touches pyproject.toml's cu132 (CUDA) torch index: `uv sync` here
# only resolves the base `dependencies = [...]` list, which has no torch.
########################################################################
FROM python:3.12-slim AS builder-lean
COPY --from=uv /uv /usr/local/bin/uv

WORKDIR /app
# Deterministic, reproducible build: install the venv at a fixed path, never
# let uv silently download its own Python (python:3.12-slim already has one),
# and copy (not hardlink) into the venv so it survives the COPY --from into a
# later stage cleanly.
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
    UV_PYTHON_DOWNLOADS=never \
    UV_LINK_MODE=copy

# Matches .dockerignore's allowlist exactly — nothing else is needed to build.
COPY pyproject.toml uv.lock README.md LICENSE ./
COPY src/ src/

RUN uv sync --frozen --no-dev --no-editable

########################################################################
# builder-ml — adds the `embeddings` extra (hybrid search) with CPU-only
# torch. See design.md D2 for the full rationale; summary:
#
# pyproject.toml pins `[tool.uv.sources] torch = [{ index = "pytorch-cu132",
# marker = "sys_platform == 'win32' or sys_platform == 'linux'" }]`. This
# container's platform IS Linux, so a plain `uv sync --extra embeddings` here
# would resolve torch against the cu132 (CUDA 13.2) index — multi-GB CUDA
# wheels baked into an image with no CUDA runtime and no GPU. Instead:
#
#   1) `uv pip install torch --index-url .../cpu` — the CPU-only wheel from
#      an explicit index, bypassing [tool.uv.sources] entirely.
#   2) `uv pip install ".[embeddings]"` — the rest of the extra. `uv pip`
#      (unlike `uv sync`/`uv add`) does NOT consult [tool.uv.sources] or
#      [tool.uv.index], so it does not re-resolve/reinstall torch against the
#      CUDA index; the already-installed CPU wheel satisfies the `torch>=2.12`
#      requirement.
#
# If a future pyproject.toml edit changes the `embeddings` extra's torch pin
# in a way the pinned CPU wheel below can no longer satisfy, step 2 fails
# loudly (version conflict) — see design.md's Risks section before changing
# either the extra or this Dockerfile.
########################################################################
FROM builder-lean AS builder-ml
RUN uv pip install --python /app/.venv/bin/python "torch>=2.12" --index-url https://download.pytorch.org/whl/cpu \
 && uv pip install --python /app/.venv/bin/python ".[embeddings]"

########################################################################
# builder-cuda — adds the `embeddings` extra with CUDA-capable torch.
#
# This is intentionally separate from `ml`: the CPU image stays portable and
# small, while this image carries the NVIDIA/CUDA torch wheel for Linux hosts
# that run Docker with the NVIDIA container runtime. CUDA capability still does
# NOT imply CUDA residency: exomem's default `normal` mode selects CPU unless the
# user opts into performance mode or an explicit CUDA device.
########################################################################
FROM builder-lean AS builder-cuda
RUN uv pip install --python /app/.venv/bin/python "torch>=2.12" --index-url https://download.pytorch.org/whl/cu132 \
 && uv pip install --python /app/.venv/bin/python ".[embeddings]"

########################################################################
# builder-hosted — builder-lean plus ONNX Runtime and the embedding weights
# resolved at BUILD time into the image itself.
#
# A hosted cell runs under a default-deny NetworkPolicy with no egress rules
# and a read-only root filesystem. It therefore cannot download a model, and
# has nowhere writable to cache one if it could. Any weight the cell needs
# must already be a layer, or the `embeddings` grant produces a cell that
# advertises semantic recall and fails on first use.
#
# This stage builds from `builder-lean`, NOT `builder-ml`: the hosted lane
# serves the same bi-encoder through ONNX Runtime, which imports ~40 MiB
# against torch's ~400 and holds a materially smaller peak. Peak per cell is
# what decides how many tenants a node carries, so torch's absence here is the
# capacity argument, not a packaging preference. It is only possible in this
# lane — the reranker and CLIP are sentence-transformers models, and the hosted
# stage withholds both (EXOMEM_DISABLE_RANKING; CLIP belongs to `vision`).
#
# Only the bi-encoder is fetched, and only in the form the runtime reads.
########################################################################
FROM builder-lean AS builder-hosted
# Install the lean ONNX environment shared by Cloud, without fetching a model.
RUN uv pip install --python /app/.venv/bin/python ".[embeddings-onnx]" \
 && /app/.venv/bin/python -c "import importlib.util; assert importlib.util.find_spec('torch') is None, 'torch reached the cell environment'"

########################################################################
# builder-cloud-model — the multilingual recall model a cloud cell encodes
# with: bge-m3's pinned int8 artefact and its tokenizer, the model a personal
# server runs. Fetched through the same backend, then loaded once more with
# the network closed. It
# fetches into a root of its own, so the cloud stage copies bge-m3 alone: a
# cloud cell never loads the English model, and re-embeds a legacy sidecar
# with bge-m3.
########################################################################
FROM builder-hosted AS builder-cloud-model
ENV HF_HOME=/opt/exomem-cloud-models \
    EXOMEM_RECALL_MODEL=BAAI/bge-m3
# The pinned multilingual export may need int8 quantization. ONNX's graph
# builder stays in this build stage; the cloud runtime copies only its models.
RUN uv pip install --python /app/.venv/bin/python "onnx>=1.17" \
 && EXOMEM_EMBED_BACKEND=onnx /app/.venv/bin/python -c "\
from exomem.embeddings import MODEL_NAME; \
from exomem import embedding_backend as backend; \
backend.load_encoder(MODEL_NAME, backend=backend.ONNX); \
print('fetched', MODEL_NAME)" \
 && HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 EXOMEM_EMBED_BACKEND=onnx \
    /app/.venv/bin/python -c "\
from exomem.embeddings import MODEL_NAME; \
from exomem import embedding_backend as backend, recall_space; \
encoder = backend.load_encoder(MODEL_NAME, backend=backend.ONNX); \
v = encoder.encode(['offline load gate'], batch_size=1); \
assert v.shape == (1, recall_space.declared_dim(MODEL_NAME)), v.shape; \
print('offline load verified', MODEL_NAME, v.shape)"

########################################################################
# restic-fetch — the static restic binary for the cloud image's backup and
# restore Jobs (design D8). Fetched and verified in its own small stage, not
# the final `cloud` image, so no curl/bzip2/apt residue ships in the runtime
# layer that the image-pin admission policy trusts.
#
# The cloud image is published for linux/amd64 and linux/arm64. BuildKit sets
# TARGETARCH to the platform being built, which names the matching restic
# release asset; each asset has its own digest from that release's SHA256SUMS.
# The case below is closed over the platforms the release publishes: any other
# architecture fails the build instead of shipping a binary that cannot run.
# The final `restic version` runs the binary in this stage, which runs on the
# target platform. On a native build, as in the release and the pull-request
# check, a binary for another architecture fails here rather than in a
# tenant's first backup Job.
########################################################################
FROM debian:bookworm-slim AS restic-fetch
ARG TARGETARCH
ARG RESTIC_VERSION=0.19.1
ARG RESTIC_SHA256_AMD64=f415415624dcc452f2a02b8c33641791a8c6d6d3b65bbb3543fcf9a25151585c
ARG RESTIC_SHA256_ARM64=a5f64aaab53d51e311fa3829124c5b703f2d14cf187d8640b6be3b2b49376465
RUN case "${TARGETARCH}" in \
      amd64) restic_sha256="${RESTIC_SHA256_AMD64}" ;; \
      arm64) restic_sha256="${RESTIC_SHA256_ARM64}" ;; \
      *) echo "restic-fetch: no pinned restic for architecture '${TARGETARCH}'" >&2; exit 1 ;; \
    esac \
 && apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl bzip2 \
 && rm -rf /var/lib/apt/lists/* \
 && curl -fsSL -o /tmp/restic.bz2 \
      "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_${TARGETARCH}.bz2" \
 && echo "${restic_sha256}  /tmp/restic.bz2" | sha256sum -c - \
 && bunzip2 /tmp/restic.bz2 \
 && chmod 755 /tmp/restic \
 && mv /tmp/restic /usr/local/bin/restic \
 && /usr/local/bin/restic version

########################################################################
# Final: ml (target `ml`). Fresh slim base — no build tooling, no uv, no
# source tree — just the populated venv. HF_HOME pins the downloaded
# embedding/CLIP model weights under the declared /data volume so they
# persist across restarts instead of re-downloading into an ephemeral layer.
########################################################################
FROM python:3.12-slim AS ml
COPY --from=builder-ml /app/.venv /app/.venv
COPY LICENSE /LICENSE

ENV PATH=/app/.venv/bin:$PATH \
    EXOMEM_HOST=0.0.0.0 \
    EXOMEM_LOG_DIR=/data/logs \
    HF_HOME=/data/hf \
    EXOMEM_CONTAINER_VARIANT=ml

LABEL org.opencontainers.image.source="https://github.com/Artexis10/exomem" \
      org.opencontainers.image.licenses="AGPL-3.0-or-later" \
      org.opencontainers.image.description="exomem — local knowledge substrate for owned markdown/Obsidian vaults, exposed through MCP, REST, and CLI (ml: hybrid embeddings + CLIP search, CPU-only torch, no GPU)"

# No HEALTHCHECK here by design (design.md D5) — a Dockerfile-level HEALTHCHECK
# would run unconditionally, including for `--transport stdio`, where there is
# no HTTP server to probe. The healthcheck lives in compose.yaml instead, where
# it only applies to the long-running HTTP deployment.
VOLUME /data
EXPOSE 8765
ENTRYPOINT ["exomem"]
CMD ["--transport", "http", "--port", "8765"]

########################################################################
# Final: cuda (target `cuda`). Fresh slim base — no build tooling, no uv, no
# source tree — just the populated venv. The image is CUDA-capable but remains
# CPU-default at idle via exomem's mode resolver. NVIDIA_* env vars are hints
# consumed by the NVIDIA container runtime; without that runtime, doctor reports
# CUDA unavailable and the service degrades to CPU.
########################################################################
FROM python:3.12-slim AS cuda
COPY --from=builder-cuda /app/.venv /app/.venv
COPY LICENSE /LICENSE

ENV PATH=/app/.venv/bin:$PATH \
    EXOMEM_HOST=0.0.0.0 \
    EXOMEM_LOG_DIR=/data/logs \
    HF_HOME=/data/hf \
    EXOMEM_CONTAINER_VARIANT=cuda \
    NVIDIA_VISIBLE_DEVICES=all \
    NVIDIA_DRIVER_CAPABILITIES=compute,utility

LABEL org.opencontainers.image.source="https://github.com/Artexis10/exomem" \
      org.opencontainers.image.licenses="AGPL-3.0-or-later" \
      org.opencontainers.image.description="exomem — local knowledge substrate for owned markdown/Obsidian vaults, exposed through MCP, REST, and CLI (cuda: hybrid embeddings + CLIP search, CUDA-capable torch, CPU-default at idle)"

# No HEALTHCHECK here by design — a Dockerfile-level HEALTHCHECK would run
# unconditionally, including for `--transport stdio`, where there is no HTTP
# server to probe. The healthcheck lives in compose.yaml instead.
VOLUME /data
EXPOSE 8765
ENTRYPOINT ["exomem"]
CMD ["--transport", "http", "--port", "8765"]

########################################################################
# cell-runtime — the base of the cloud image, which adds its own model. The
# platform mounts only the bound vault/state/log roots plus native
# Secret/operator projections. Kubernetes supplies readOnlyRootFilesystem; this image supplies
# the matching fixed runtime identity and avoids bytecode writes to the image
# layer.
#
# The cell image carries the embedding runtime and pre-baked weights, not the lean venv:
# a paying tenant gets the same semantic recall as the free local runtime.
# The cell has no egress and no writable root, so the weights are a read-only
# image layer under HF_HOME and the hub client is pinned offline — an
# accidental fetch must fail loudly at load rather than hang a tenant query
# against a blocked NetworkPolicy until the client's deadline.
#
# MALLOC_ARENA_MAX=2 bounds glibc's per-thread arenas, whose freed-but-held
# high-water otherwise grows with the thread count and never returns to the
# OS; the runtime also calls malloc_trim(0) after model reaps and derived
# drain batches. The cloud stage inherits both.
########################################################################
FROM python:3.12-slim AS cell-runtime
COPY --from=builder-hosted /app/.venv /app/.venv
COPY LICENSE /LICENSE

ARG EXOMEM_RELEASE_BUILD_TIME
RUN test -n "$EXOMEM_RELEASE_BUILD_TIME" \
 && groupadd --gid 10001 exomem \
 && useradd --uid 10001 --gid 10001 --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin exomem

ENV PATH=/app/.venv/bin:$PATH \
    EXOMEM_HOST=0.0.0.0 \
    EXOMEM_RELEASE_BUILD_TIME=${EXOMEM_RELEASE_BUILD_TIME} \
    HF_HOME=/opt/exomem-models \
    HF_HUB_OFFLINE=1 \
    TRANSFORMERS_OFFLINE=1 \
    EXOMEM_DISABLE_RANKING=1 \
    EXOMEM_EMBED_BACKEND=onnx \
    MALLOC_ARENA_MAX=2 \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

USER 10001:10001
EXPOSE 8765
ENTRYPOINT ["exomem"]
CMD ["--transport", "http", "--port", "8765"]

########################################################################
# Final: cloud (target `cloud`). Exomem Cloud cell runtime (design D1/D2/D8).
#
# Derived from `cell-runtime`: the offline ONNX model environment,
# `EXOMEM_DISABLE_RANKING` and read-only-root compatibility, with bge-m3 as its
# only pre-baked model (see `recall_space.configured_recall_model`). It adds the
# media engines below; each stays off until cellctl switches it on in a cell
# (EXOMEM_MEDIA_ENGINES). Cloud mode (`EXOMEM_CLOUD_CELL=1`) is a
# thin seam over the standalone runtime (D1), so this stage exists to add the
# identity, backup tooling and command the cell pod needs — not a different
# Python environment.
#
# UID/GID 10001 keeps `cell-runtime`'s numeric identity but gets home `/data/host`
# instead of `/nonexistent`: standalone custody resolves under
# `<home>/.local/state/exomem/standalone-host-control-v1` with no code
# override (`authorization_custody._standalone_host_control_root`, D2), so the
# passwd home directory IS the custody root. The pod's `fsGroup: 10001` plus
# this image's explicit UID/GID keep ownership consistent across first start,
# pod replacement and restore.
#
# restic ships baked into the image (not fetched at runtime) because a cloud
# cell's NetworkPolicy grants no runtime egress at all, and every Job that
# touches the volume — init, backup, restore — runs this one digest-pinned
# image under the ValidatingAdmissionPolicy (D4, D8).
########################################################################
FROM cell-runtime AS cloud
COPY --from=restic-fetch /usr/local/bin/restic /usr/local/bin/restic
COPY --from=builder-cloud-model /opt/exomem-cloud-models /opt/exomem-models
USER root
RUN usermod --home /data/host exomem \
 && apt-get update \
 && apt-get install -y --no-install-recommends age \
 && rm -rf /var/lib/apt/lists/*

# Media engines (change `add-cloud-multimodal-processing`, design D3–D5).
#
# Tesseract with every script model Debian packages from tessdata, the OSD model,
# and the language packs EXOMEM_OCR_LANGS names (`+`-separated tessdata names). A
# pack added there needs no code change: OCR reads each pack's script from its own
# data. The same list is the default OCR reads with when a page names no script.
#
# The Python engines are the packages the `media-cpu` extra adds to the venv that
# `cell-runtime` already holds, at the versions and hashes uv.lock pins. The export
# skips every package the venv has, so the serving runtime (onnxruntime, protobuf,
# click) keeps the versions production runs; `--no-deps` stops uv from changing one
# to satisfy a media package, and `uv pip check` fails the build when that leaves a
# requirement unmet. It has no CUDA wheel and no torch; the gate below fails the
# build if either arrives.
#
# EXOMEM_OCR_INVENTORY: reading each OCR model's script reads every traineddata
# file (about 350 MB), and every media child is fresh. The gate's first OCR run
# writes that inventory here, read-only at run time, so a child reads one small file.
#
# OMP_THREAD_LIMIT=1: Tesseract otherwise starts one OpenMP thread per host CPU,
# not per cell CPU, and each thread's stack counts against the media child's
# data-segment limit.
ARG EXOMEM_OCR_LANGS=eng+jpn+jpn_vert+est
# The engines this image claims. The gate needs a passing sample of every media
# kind they serve, so a claimed format cannot ship unproven.
ARG EXOMEM_MEDIA_SHIPPED_ENGINES=documents,ocr,speech
RUN packs="$(echo "${EXOMEM_OCR_LANGS}" | tr '+_' ' -' | sed 's/[^ ][^ ]*/tesseract-ocr-&/g')" \
 && apt-get update \
 && apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-osd 'tesseract-ocr-script-*' ${packs} \
 && rm -rf /var/lib/apt/lists/*
RUN --mount=type=bind,from=uv,source=/uv,target=/usr/local/bin/uv \
    --mount=type=bind,from=builder-lean,source=/app,target=/src,rw \
    uv export --project /src --frozen --no-dev --no-emit-project --extra media-cpu \
      $(/app/.venv/bin/python -c 'import importlib.metadata as m; print(*{"--no-emit-package=" + d.metadata["Name"] for d in m.distributions()})') \
      --output-file /tmp/media-cpu.txt \
 && uv pip install --python /app/.venv/bin/python --no-cache --require-hashes --no-deps -r /tmp/media-cpu.txt \
 && uv pip check --python /app/.venv/bin/python \
 && rm /tmp/media-cpu.txt
ENV EXOMEM_OCR_DEFAULT_LANGS=${EXOMEM_OCR_LANGS} \
    EXOMEM_OCR_INVENTORY=/opt/exomem-ocr/inventory.json \
    OMP_THREAD_LIMIT=1 \
    EXOMEM_ASR_BACKEND=onnx \
    EXOMEM_MEDIA_BUDGETS='{"speech":{"anon_mib":1024,"vmdata_mib":4096},"clip":{"anon_mib":256,"vmdata_mib":1792}}'
# New media artifacts only fetch their pinned publication; a cell never builds them.
RUN HF_HUB_OFFLINE=0 python -c "\
from exomem.media_models import ensure_media_model; \
ensure_media_model('image'); ensure_media_model('image-query'); ensure_media_model('speech')"
RUN --network=none python -c "\
from exomem.clip_query import OnnxClipQueryEncoder; \
assert OnnxClipQueryEncoder().encode(['offline query gate']).shape == (1, 512)"
# The image tower must keep the stored vector space: with networking off, it
# reproduces the sentence-transformers vector of a committed photograph.
RUN --network=none \
    --mount=type=bind,source=tests/fixtures/clip,target=/clip-gate \
    python -c "\
import json, numpy as np; from pathlib import Path; from exomem import embeddings; \
ref = np.asarray(json.loads(Path('/clip-gate/reference.json').read_text())['vector'], dtype=np.float32); \
vec = embeddings.embed_image(Path('/clip-gate/photo.jpg')); \
assert type(embeddings._CLIP_MODEL).__name__ == 'OnnxClipImageEncoder'; \
assert float(vec @ (ref / np.linalg.norm(ref))) >= 0.9999"
# The support proof: with networking off, extract a real sample of every format
# the image serves and find its phrase. A format that fails is not shipped.
RUN --network=none \
    --mount=type=bind,source=tests/fixtures/media-samples,target=/samples \
    --mount=type=bind,source=scripts/check-media-samples.py,target=/media-gate.py \
    mkdir -p /opt/exomem-ocr \
 && HOME=/tmp python /media-gate.py /samples "${EXOMEM_MEDIA_SHIPPED_ENGINES}" \
 && test -s "${EXOMEM_OCR_INVENTORY}" \
 && find /tmp -mindepth 1 -delete

# EXOMEM_LOG_DIR: no runtime log ever lands on the tenant volume D8 backs up,
# even without the manifest setting it (design D1.2 "Log directory"). D2's
# writable `/tmp` emptyDir is where this actually lands at runtime.
#
# FASTMCP_CHECK_FOR_UPDATES / FASTMCP_SHOW_SERVER_BANNER: a cell's
# NetworkPolicy grants no egress at all, not even DNS (D5), so FastMCP's
# startup update check otherwise stalls every cold start on DNS (measured:
# `/health` up after 24s against 4s with it off). Both names are read by the
# installed `fastmcp.settings.Settings` (`env_prefix="FASTMCP_"`).
#
# EXOMEM_RECALL_MODEL: a cloud cell encodes recall with the multilingual model
# a personal server runs.
# ORT_DISABLE_TELEMETRY: ORT reads this before import, preventing its device-ID
# database from entering the tenant volume without changing the custody home.
#
# EXOMEM_DISABLE_CLIP: the image carries the ONNX image tower and the multilingual
# query tower. Image search stays off by default; a cell turns it on after the image
# engine's own acceptance passes.
ENV EXOMEM_CONTAINER_VARIANT=cloud \
    EXOMEM_CLOUD_RESOURCE_POLICY=service-v1 \
    EXOMEM_RECALL_MODEL=BAAI/bge-m3 \
    EXOMEM_DISABLE_CLIP=1 \
    ORT_DISABLE_TELEMETRY=1 \
    EXOMEM_LOG_DIR=/tmp/exomem-logs \
    FASTMCP_CHECK_FOR_UPDATES=off \
    FASTMCP_SHOW_SERVER_BANNER=false

# The builder's gate loaded the model at its fetch root. Load the model this
# image names once more where the cell reads it, offline, so a broken copy
# fails the build rather than a tenant's first query. Cloud mode resolves the
# model as a cell does; HOME and the temporary files stay in /tmp, which the
# step empties, so the gate leaves nothing in the image.
RUN HOME=/tmp EXOMEM_CLOUD_CELL=1 python -c "\
from exomem.embeddings import MODEL_NAME; \
from exomem import embedding_backend as backend, recall_space; \
v = backend.load_encoder(MODEL_NAME, backend=backend.ONNX).encode(['image gate'], batch_size=1); \
assert v.shape == (1, recall_space.declared_dim(MODEL_NAME)), v.shape; \
print('image load verified', MODEL_NAME, v.shape)" \
 && find /tmp -mindepth 1 -delete

LABEL org.opencontainers.image.source="https://github.com/Artexis10/exomem" \
      org.opencontainers.image.licenses="AGPL-3.0-or-later" \
      org.opencontainers.image.description="exomem — Exomem Cloud cell runtime (fixed UID/GID 10001, home /data/host, restic-equipped, standalone trust model)"

# No HEALTHCHECK here by design (design.md D5) — see the `ml` stage's comment
# above; cellctl reads readiness from the pod's own probes, not from Docker.
USER 10001:10001
EXPOSE 8765
ENTRYPOINT ["exomem"]
CMD ["--transport", "http", "--host", "0.0.0.0", "--port", "8765"]

########################################################################
# Final: lean (target `lean`) — the DEFAULT stage (last in this file; see
# the top-of-file note). Base dependencies only: no torch, no
# sentence-transformers, no model download. EXOMEM_DISABLE_EMBEDDINGS=1
# keeps search in keyword/BM25 mode. Opt into `ml` explicitly for hybrid
# search (`--target ml` at build time, or the published `:ml` tag).
########################################################################
FROM python:3.12-slim AS lean
COPY --from=builder-lean /app/.venv /app/.venv
COPY LICENSE /LICENSE

ENV PATH=/app/.venv/bin:$PATH \
    EXOMEM_HOST=0.0.0.0 \
    EXOMEM_LOG_DIR=/data/logs \
    EXOMEM_DISABLE_EMBEDDINGS=1 \
    EXOMEM_CONTAINER_VARIANT=lean

LABEL org.opencontainers.image.source="https://github.com/Artexis10/exomem" \
      org.opencontainers.image.licenses="AGPL-3.0-or-later" \
      org.opencontainers.image.description="exomem — local knowledge substrate for owned markdown/Obsidian vaults, exposed through MCP, REST, and CLI (lean: keyword/BM25 search, no torch, no model download)"

# No HEALTHCHECK here by design (design.md D5) — see the `ml` stage's comment
# above; the healthcheck lives in compose.yaml.
VOLUME /data
EXPOSE 8765
ENTRYPOINT ["exomem"]
CMD ["--transport", "http", "--port", "8765"]