Adds a small embedder sidecar (Xenova/bge-small-en-v1.5, ONNX, CPU-only) that the web app calls inline on memory.write and memory.update, and on demand from the new memory.search tool. memory.search performs three candidate fetches in parallel — pgvector cosine similarity, Postgres full-text via plainto_tsquery + ts_rank_cd, and tag-set overlap — then fuses them with Reciprocal Rank Fusion (k=60). Each result carries its per-source rank so the model can see *why* a memory surfaced. The migrator boot step gained an idempotent embedding backfill: any row with embedding IS NULL is batched (32 at a time) through the embedder after SQL migrations apply. Safe to run on every boot. New tool memory.update fixes the missing edit path; centralises the re-embed-on-content-change rule alongside write. Stack additions: - apps/embedder/ — Fastify server, persistent /data/models volume so the ~30 MB model only downloads once - apps/web/lib/embedder.ts — typed HTTP client with batched embed + health probe - packages/schemas — MemoryUpdateInput, MemorySearchInput - docker-compose — embedder service, healthcheck, app + migrator both depend_on it healthy; EMBEDDER_URL promoted to a required env var Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
5.5 KiB
YAML
176 lines
5.5 KiB
YAML
# =============================================================================
|
|
# shared-memory — compose stack.
|
|
#
|
|
# Two supported deployment modes:
|
|
#
|
|
# 1. Behind an external reverse proxy (DEFAULT)
|
|
# The `app` service exposes ${APP_PORT:-3000} on the host. Point your
|
|
# proxy (HAProxy, nginx, Traefik, Cloudflare Tunnel, etc.) at it. The
|
|
# app trusts X-Forwarded-Proto / X-Forwarded-Host headers so callbacks
|
|
# and MCP discovery URLs use PUBLIC_URL correctly.
|
|
#
|
|
# docker compose up -d
|
|
#
|
|
# 2. Built-in TLS via Caddy (opt-in profile)
|
|
# Adds a Caddy reverse proxy on host ports 80/443 with automatic
|
|
# Let's Encrypt certificates for $APP_HOSTNAME. Use this on a VM that
|
|
# doesn't already sit behind a proxy.
|
|
#
|
|
# docker compose --profile tls up -d
|
|
#
|
|
# All runtime config lives in .env (never committed). See .env.example.
|
|
# =============================================================================
|
|
|
|
name: shared-memory
|
|
|
|
services:
|
|
db:
|
|
image: pgvector/pgvector:pg16
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER not set in .env}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD not set in .env}
|
|
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB not set in .env}
|
|
volumes:
|
|
- db_data:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 20
|
|
networks:
|
|
- internal
|
|
|
|
# Embedding sidecar — loads bge-small-en-v1.5 once and serves /embed.
|
|
# First boot downloads the model (~30 MB) into a named volume so future
|
|
# boots are warm.
|
|
embedder:
|
|
image: ${EMBEDDER_IMAGE_REF:-shared-memory-embedder:local}
|
|
build:
|
|
context: .
|
|
dockerfile: apps/embedder/Dockerfile
|
|
restart: unless-stopped
|
|
environment:
|
|
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-Xenova/bge-small-en-v1.5}
|
|
EMBEDDING_DIM: ${EMBEDDING_DIM:-384}
|
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
|
volumes:
|
|
- embedder_models:/data/models
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8080/health | grep -q '\"ready\":true' || exit 1"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 180s
|
|
networks:
|
|
- internal
|
|
|
|
# One-shot migration runner + embedding backfill. Exits 0 when both are
|
|
# up-to-date; `app` waits on its successful completion before starting.
|
|
migrator:
|
|
image: ${IMAGE_REF:-shared-memory-web:local}
|
|
build:
|
|
context: .
|
|
dockerfile: apps/web/Dockerfile
|
|
restart: "no"
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
embedder:
|
|
condition: service_healthy
|
|
environment:
|
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
|
EMBEDDER_URL: ${EMBEDDER_URL:-http://embedder:8080}
|
|
command: ["node", "apps/web/migrate.mjs"]
|
|
networks:
|
|
- internal
|
|
|
|
app:
|
|
image: ${IMAGE_REF:-shared-memory-web:local}
|
|
build:
|
|
context: .
|
|
dockerfile: apps/web/Dockerfile
|
|
restart: unless-stopped
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
embedder:
|
|
condition: service_healthy
|
|
migrator:
|
|
condition: service_completed_successfully
|
|
environment:
|
|
NODE_ENV: production
|
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
|
|
|
PUBLIC_URL: ${PUBLIC_URL:?PUBLIC_URL not set in .env}
|
|
|
|
# Auth.js v5 needs to know its public URL when behind a reverse proxy.
|
|
AUTH_URL: ${PUBLIC_URL}
|
|
AUTH_TRUST_HOST: "true"
|
|
|
|
OIDC_ISSUER: ${OIDC_ISSUER:?OIDC_ISSUER not set in .env}
|
|
OIDC_CLIENT_ID_WEB: ${OIDC_CLIENT_ID_WEB:?required}
|
|
OIDC_CLIENT_SECRET_WEB: ${OIDC_CLIENT_SECRET_WEB:?required}
|
|
OIDC_CLIENT_ID_MCP: ${OIDC_CLIENT_ID_MCP:?required}
|
|
OIDC_AUDIENCE: ${OIDC_AUDIENCE:?required}
|
|
|
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
|
|
|
EMBEDDER_URL: ${EMBEDDER_URL:-http://embedder:8080}
|
|
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-Xenova/bge-small-en-v1.5}
|
|
EMBEDDING_DIM: ${EMBEDDING_DIM:-384}
|
|
|
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?required}
|
|
CLI_TOKEN_SECRET: ${CLI_TOKEN_SECRET:?required}
|
|
ports:
|
|
# Exposed to the host so an external reverse proxy (HAProxy, nginx,
|
|
# etc.) can reach the app. When using the `tls` profile, Caddy also
|
|
# proxies via the internal network — leaving this exposed is harmless
|
|
# but you can bind to 127.0.0.1 only by setting APP_BIND=127.0.0.1.
|
|
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-3000}:3000"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3000/api/health || exit 1"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 15s
|
|
networks:
|
|
- internal
|
|
- web
|
|
|
|
# Opt-in TLS terminator. Skipped unless `--profile tls` is passed.
|
|
# External-proxy deployments (HAProxy, nginx, Cloudflare Tunnel, etc.)
|
|
# leave this off and proxy directly to host:${APP_PORT}.
|
|
caddy:
|
|
image: caddy:2-alpine
|
|
profiles: ["tls"]
|
|
restart: unless-stopped
|
|
depends_on:
|
|
app:
|
|
condition: service_healthy
|
|
ports:
|
|
- "80:80"
|
|
- "443:443"
|
|
- "443:443/udp"
|
|
environment:
|
|
APP_HOSTNAME: ${APP_HOSTNAME:-localhost}
|
|
ACME_EMAIL: ${ACME_EMAIL:-}
|
|
volumes:
|
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
- caddy_data:/data
|
|
- caddy_config:/config
|
|
networks:
|
|
- web
|
|
|
|
volumes:
|
|
db_data:
|
|
caddy_data:
|
|
caddy_config:
|
|
embedder_models:
|
|
|
|
networks:
|
|
internal:
|
|
driver: bridge
|
|
web:
|
|
driver: bridge
|