name: lore-engine-poc # Lore Engine POC: Neo4j + Postgres + MinIO + Redis + Python plugin gateway # + 7 GraphMCP workers (Go) + Go mcp-server # # Phase 1 (P1) of the lore-engine × GraphMCP-Example substrate merge. # See docs/VERIFICATION.md for the contract this stack must satisfy. # # Port remap note: the host already runs the damascus stack on 5432/5433, # 7474, 7687, 9000, 9001. We shift the lore stack to the 5434/7475/7688/ # 9002/9003/8766/6379 range to coexist. Containers communicate on the # internal Docker network using the in-network service names (neo4j, # postgres, minio, lore-redis, litellm-host). services: # ─── Neo4j — world graph ──────────────────────────────────────────────────── neo4j: image: neo4j:5.26-community container_name: lore-neo4j environment: NEO4J_AUTH: neo4j/lore-dev-password NEO4J_PLUGINS: '["apoc"]' NEO4J_apoc_export_file_enabled: "true" NEO4J_apoc_import_file_enabled: "true" NEO4J_server_memory_heap_initial__size: 512m NEO4J_server_memory_heap_max__size: 1g ports: - "7475:7474" # browser (remapped from 7474 — damascus occupies 7474 area) - "7688:7687" # bolt (remapped from 7687 — free for lore-neo4j) volumes: - neo4j-data:/data healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"] interval: 5s timeout: 5s retries: 20 # ─── Postgres — operational data + embeddings ────────────────────────────── postgres: image: pgvector/pgvector:pg16 container_name: lore-postgres environment: POSTGRES_USER: lore POSTGRES_PASSWORD: lore-dev-password POSTGRES_DB: lore ports: - "5434:5432" volumes: - postgres-data:/var/lib/postgresql/data - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U lore -d lore"] interval: 5s timeout: 5s retries: 20 # ─── MinIO — blob storage for images ──────────────────────────────────────── minio: image: minio/minio:latest container_name: lore-minio environment: MINIO_ROOT_USER: lorelore MINIO_ROOT_PASSWORD: lore-dev-password command: server /data --console-address ":9001" ports: - "9002:9000" # S3 API (remapped from 9000) - "9003:9001" # console (remapped from 9001) volumes: - minio-data:/data healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/ready"] interval: 5s timeout: 5s retries: 20 # ─── Redis — event stream broker for the GraphMCP workers ─────────────────── # Phase 1 addition: streams raw.discord / raw.messages / raw.lore / raw.encounters # See docs/merge/00-inventory.md §3 for the producer/consumer topology. redis: image: redis:7-alpine container_name: lore-redis command: > redis-server --appendonly yes --appendfsync everysec --maxmemory 1gb --maxmemory-policy noeviction ports: - "6379:6379" volumes: - redis-data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 5s retries: 10 # ─── Lore Gateway — Python MCP server, plugin-driven ─────────────────────── gateway: build: context: ./gateway container_name: lore-gateway depends_on: neo4j: { condition: service_healthy } postgres: { condition: service_healthy } minio: { condition: service_healthy } redis: { condition: service_healthy } mcp-server: { condition: service_started } environment: NEO4J_URL: bolt://neo4j:7687 NEO4J_USER: neo4j NEO4J_PASSWORD: lore-dev-password POSTGRES_URL: postgresql://lore:lore-dev-password@postgres:5432/lore MINIO_URL: http://minio:9000 MINIO_ACCESS_KEY: lorelore MINIO_SECRET_KEY: lore-dev-password MINIO_BUCKET: lore-images MINIO_PUBLIC_URL: http://localhost:9002 PLUGINS_DIR: /app/plugins INIT_CYPHER: /app/neo4j/init.cypher # The Python nsc plugin proxies JSON-RPC to the Go mcp-server. # Default URL is the in-network service name; override here if you run # the mcp-server on a different host. NSC_MCP_URL: http://mcp-server:9000 ports: - "8766:8765" # MCP JSON-RPC (remapped from 8765) volumes: - ./plugins:/app/plugins:ro - ./neo4j:/app/neo4j:ro - ./mock-data:/app/mock-data:ro # ═══ GraphMCP substrate (Phase 1 merge) ════════════════════════════════════ # All workers read LLM/embed URLs from the host's litellm proxy at # 172.22.0.1:4000 (the docker-bridge gateway on Linux). Override per # worker in production. # ─── discord-connector (disabled in Phase 1; env DISCORD_ENABLED gate) ───── discord-connector: build: ./workers/discord-connector container_name: lore-discord-connector depends_on: redis: { condition: service_healthy } neo4j: { condition: service_healthy } environment: DISCORD_ENABLED: "false" # Phase 1 keeps this off DISCORD_TOKEN: "" DISCORD_GUILD_ID: "" REDIS_URL: redis://lore-redis:6379 REDIS_STREAM: raw.discord ENCOUNTER_STREAM: raw.encounters GROUPING_TIMEOUT_MINS: "15" restart: unless-stopped # ─── discord-filter — promotes lore-relevant messages to raw.messages ────── discord-filter: build: ./workers/discord-filter container_name: lore-discord-filter depends_on: redis: { condition: service_healthy } neo4j: { condition: service_healthy } environment: REDIS_URL: redis://lore-redis:6379 IN_STREAM: raw.discord OUT_STREAM: raw.messages REDIS_GROUP: discord-filter CONSUMER_NAME: discord-filter-1 NEO4J_URL: bolt://neo4j:7687 NEO4J_USER: neo4j NEO4J_PASSWORD: lore-dev-password EMBED_URL: http://172.22.0.1:4000/v1 EMBED_MODEL: embed-gemma-300m SIMILARITY_THRESHOLD: "0.72" TOP_K: "3" # ─── lore-watcher — POSTs .md files from ./lore-data to ingestion-worker ── lore-watcher: build: ./workers/lore-watcher container_name: lore-lore-watcher depends_on: ingestion-worker: { condition: service_started } environment: WATCH_DIR: /data/lore INGEST_URL: http://ingestion-worker:8080/ingest/lore DEBOUNCE_MS: "500" volumes: - ./lore-data:/data/lore:ro # ─── ingestion-worker — chunks + embeds + writes Message/Chunk/LoreDocument ingestion-worker: build: ./workers/ingestion-worker container_name: lore-ingestion-worker depends_on: redis: { condition: service_healthy } neo4j: { condition: service_healthy } environment: REDIS_URL: redis://lore-redis:6379 REDIS_STREAM: raw.messages REDIS_GROUP: ingestion CONSUMER_NAME: ingestion-worker-1 NEO4J_URL: bolt://neo4j:7687 NEO4J_USER: neo4j NEO4J_PASSWORD: lore-dev-password EMBED_URL: http://172.22.0.1:4000/v1 EMBED_MODEL: embed-gemma-300m CHUNK_SIZE: "512" CHUNK_OVERLAP: "64" HTTP_PORT: "8080" LORE_STREAM: raw.lore LOG_LEVEL: info ports: - "8081:8080" # ─── entity-extractor (primary LLM-backed, Ollama-compatible) ───────────── entity-extractor: build: ./workers/entity-extractor container_name: lore-entity-extractor depends_on: redis: { condition: service_healthy } neo4j: { condition: service_healthy } environment: REDIS_URL: redis://lore-redis:6379 REDIS_STREAM: raw.messages REDIS_GROUP: extraction CONSUMER_NAME: entity-extractor-1 NEO4J_URL: bolt://neo4j:7687 NEO4J_USER: neo4j NEO4J_PASSWORD: lore-dev-password LLM_URL: http://172.22.0.1:4000/v1 LLM_MODEL: qwen2.5:3b SUPERSEDE_RELATIONS: "ALLIED_WITH,ENEMY_OF" # ─── entity-extractor-2 (twin replica — same binary, different LLM) ──────── entity-extractor-2: build: ./workers/entity-extractor container_name: lore-entity-extractor-2 depends_on: redis: { condition: service_healthy } neo4j: { condition: service_healthy } environment: REDIS_URL: redis://lore-redis:6379 REDIS_STREAM: raw.messages REDIS_GROUP: extraction CONSUMER_NAME: entity-extractor-2 NEO4J_URL: bolt://neo4j:7687 NEO4J_USER: neo4j NEO4J_PASSWORD: lore-dev-password LLM_URL: http://172.22.0.1:4000/v1 LLM_MODEL: qwen3.5 SUPERSEDE_RELATIONS: "ALLIED_WITH,ENEMY_OF" # ─── lore-extractor — entity extraction on lore documents ────────────────── lore-extractor: build: ./workers/lore-extractor container_name: lore-lore-extractor depends_on: redis: { condition: service_healthy } neo4j: { condition: service_healthy } environment: REDIS_URL: redis://lore-redis:6379 REDIS_STREAM: raw.lore REDIS_GROUP: lore-extraction CONSUMER_NAME: lore-extractor-1 NEO4J_URL: bolt://neo4j:7687 NEO4J_USER: neo4j NEO4J_PASSWORD: lore-dev-password LLM_URL: http://172.22.0.1:4000/v1 LLM_MODEL: qwen2.5:3b # ─── lore-extractor-2 (twin replica) ─────────────────────────────────────── lore-extractor-2: build: ./workers/lore-extractor container_name: lore-lore-extractor-2 depends_on: redis: { condition: service_healthy } neo4j: { condition: service_healthy } environment: REDIS_URL: redis://lore-redis:6379 REDIS_STREAM: raw.lore REDIS_GROUP: lore-extraction CONSUMER_NAME: lore-extractor-2 NEO4J_URL: bolt://neo4j:7687 NEO4J_USER: neo4j NEO4J_PASSWORD: lore-dev-password LLM_URL: http://172.22.0.1:4000/v1 LLM_MODEL: qwen3.5 # ─── encounter-processor — Encounter + WITNESSED edges ───────────────────── encounter-processor: build: ./workers/encounter-processor container_name: lore-encounter-processor depends_on: redis: { condition: service_healthy } neo4j: { condition: service_healthy } environment: REDIS_URL: redis://lore-redis:6379 REDIS_STREAM: raw.encounters REDIS_GROUP: encounter-processing CONSUMER_NAME: encounter-processor-1 NEO4J_URL: bolt://neo4j:7687 NEO4J_USER: neo4j NEO4J_PASSWORD: lore-dev-password LLM_URL: http://172.22.0.1:4000/v1 LLM_MODEL: qwen2.5:3b # ─── encounter-processor-2 (twin replica) ────────────────────────────────── encounter-processor-2: build: ./workers/encounter-processor container_name: lore-encounter-processor-2 depends_on: redis: { condition: service_healthy } neo4j: { condition: service_healthy } environment: REDIS_URL: redis://lore-redis:6379 REDIS_STREAM: raw.encounters REDIS_GROUP: encounter-processing CONSUMER_NAME: encounter-processor-2 NEO4J_URL: bolt://neo4j:7687 NEO4J_USER: neo4j NEO4J_PASSWORD: lore-dev-password LLM_URL: http://172.22.0.1:4000/v1 LLM_MODEL: qwen3.5 # ─── mcp-server — Go MCP JSON-RPC on port 9000 ───────────────────────────── # The Python `nsc` plugin in the gateway proxies tools/list + tools/call # to this service. Worker source: services/mcp-server/main.go (unchanged). mcp-server: build: ./workers/mcp-server container_name: lore-mcp-server depends_on: neo4j: { condition: service_healthy } environment: NEO4J_URL: bolt://neo4j:7687 NEO4J_USER: neo4j NEO4J_PASSWORD: lore-dev-password EMBED_URL: http://172.22.0.1:4000/v1 EMBED_MODEL: embed-gemma-300m MCP_PORT: "9000" MAX_CONTEXT_TOKENS: "4000" LOG_LEVEL: info ports: - "9004:9000" volumes: neo4j-data: postgres-data: minio-data: redis-data: