Date : 27/01/2026
Status : IMPLEMENTEE
Priorité : HAUTE
Effort estimé : 1 session
Stack : Ollama (win11) + Supabase pgvector (prod-portainer)
Projet GitLab : claude-memory (avec CI/CD)
Schema Supabase : claude_memory (isolé du public)
Après compression du contexte, Claude perd des informations critiques (credentials, procédures, conventions) et fait des erreurs. Le système actuel basé sur grep ne permet pas de recherche sémantique.
Système hybride :
~/.claude/memory/*.md
│
│ Script ingestion (cron 5min ou hook)
▼
┌─────────────────────────────────────┐
│ Ollama (win11:11434) │
│ Modèle: nomic-embed-text │
│ 768 dimensions, ~500Mo VRAM │
└─────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Supabase pgvector (prod-portainer) │
│ Table: claude_memory │
└─────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────┐
│ API: POST /api/memory/search │
│ → Retourne chunks pertinents │
└─────────────────────────────────────┘
Repo : https://gitlab.33800.nowhere84.com/gouroubleu/claude-memory
claude-memory/
├── .gitlab-ci.yml # Pipeline CI/CD
├── Dockerfile # Image Python
├── requirements.txt # Dépendances
├── src/
│ ├── __init__.py
│ ├── sync.py # Script de synchronisation
│ ├── api.py # API FastAPI (optionnel)
│ ├── embeddings.py # Client Ollama
│ └── config.py # Configuration
├── migrations/
│ └── 001_init_schema.sql # Schema Supabase
├── tests/
│ └── test_sync.py
└── README.md
stages:
- test
- build
- deploy
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
test:
stage: test
image: python:3.11-slim
script:
- pip install -r requirements.txt
- pip install pytest
- pytest tests/ -v
only:
- merge_requests
- main
build:
stage: build
image: docker:24.0.5
services:
- docker:24.0.5-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $IMAGE_TAG -t $IMAGE_LATEST .
- docker push $IMAGE_TAG
- docker push $IMAGE_LATEST
only:
- main
deploy:
stage: deploy
image: curlimages/curl:latest
script:
- |
curl -X POST "http://192.168.1.12:9000/api/stacks/webhooks/${PORTAINER_WEBHOOK_TOKEN}" \
-H "Content-Type: application/json"
environment:
name: production
only:
- main
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
# Cron intégré pour sync toutes les 5 min
RUN apt-get update && apt-get install -y cron && rm -rf /var/lib/apt/lists/*
COPY crontab /etc/cron.d/claude-memory
RUN chmod 0644 /etc/cron.d/claude-memory && crontab /etc/cron.d/claude-memory
CMD ["python", "-m", "src.api"]
supabase>=2.0.0
requests>=2.31.0
fastapi>=0.109.0
uvicorn>=0.27.0
python-dotenv>=1.0.0
claude_memory-- Créer le schema dédié (isolé de public)
CREATE SCHEMA IF NOT EXISTS claude_memory;
-- Activer pgvector si pas déjà fait
CREATE EXTENSION IF NOT EXISTS vector;
-- Table principale dans le schema dédié
CREATE TABLE claude_memory.chunks (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
content TEXT NOT NULL,
embedding vector(768), -- nomic-embed-text = 768 dims
namespace VARCHAR(100) DEFAULT 'default', -- multi-env: "claude", "stack-86000", etc.
file_source VARCHAR(255) NOT NULL, -- ex: "credentials.md"
section VARCHAR(255), -- ex: "## SSH Linux"
chunk_index INTEGER DEFAULT 0,
checksum VARCHAR(64), -- MD5 du contenu pour détecter changements
metadata JSONB DEFAULT '{}', -- données flexibles (tags, priorité, etc.)
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Table de métadonnées des fichiers (pour tracking sync)
CREATE TABLE claude_memory.files (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
namespace VARCHAR(100) DEFAULT 'default',
file_name VARCHAR(255) NOT NULL,
checksum VARCHAR(64) NOT NULL,
chunks_count INTEGER DEFAULT 0,
last_sync TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(namespace, file_name)
);
-- Table de logs des recherches (analytics)
CREATE TABLE claude_memory.search_logs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
query TEXT NOT NULL,
namespace VARCHAR(100),
results_count INTEGER,
top_similarity FLOAT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index pour recherche vectorielle rapide
CREATE INDEX idx_chunks_embedding ON claude_memory.chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- Index pour filtrage par namespace + fichier
CREATE INDEX idx_chunks_namespace ON claude_memory.chunks(namespace);
CREATE INDEX idx_chunks_file ON claude_memory.chunks(file_source);
CREATE INDEX idx_chunks_ns_file ON claude_memory.chunks(namespace, file_source);
-- Fonction de recherche sémantique (avec namespace optionnel)
CREATE OR REPLACE FUNCTION claude_memory.search(
query_embedding vector(768),
match_threshold FLOAT DEFAULT 0.7,
match_count INT DEFAULT 5,
filter_namespace VARCHAR(100) DEFAULT NULL -- NULL = tous les namespaces
)
RETURNS TABLE (
id UUID,
content TEXT,
namespace VARCHAR(100),
file_source VARCHAR(255),
section VARCHAR(255),
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
c.id,
c.content,
c.namespace,
c.file_source,
c.section,
1 - (c.embedding <=> query_embedding) AS similarity
FROM claude_memory.chunks c
WHERE 1 - (c.embedding <=> query_embedding) > match_threshold
AND (filter_namespace IS NULL OR c.namespace = filter_namespace)
ORDER BY c.embedding <=> query_embedding
LIMIT match_count;
END;
$$;
-- Fonction pour stats
CREATE OR REPLACE FUNCTION claude_memory.stats()
RETURNS TABLE (
total_chunks BIGINT,
total_files BIGINT,
last_sync TIMESTAMPTZ
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
(SELECT COUNT(*) FROM claude_memory.chunks),
(SELECT COUNT(*) FROM claude_memory.files),
(SELECT MAX(last_sync) FROM claude_memory.files);
END;
$$;
"""Configuration centralisée"""
import os
from pathlib import Path
# Chemins
MEMORY_DIR = Path(os.environ.get("MEMORY_DIR", "/data/memory"))
# AI-Orchestrator (pour accès Ollama)
AI_ORCHESTRATOR_URL = os.environ.get("AI_ORCHESTRATOR_URL", "http://192.168.1.12:5501")
EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "nomic-embed-text")
# Supabase
SUPABASE_URL = os.environ.get("SUPABASE_URL", "https://supabase-api-prod.33800.nowhere84.com")
SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_KEY")
SUPABASE_SCHEMA = "claude_memory"
# Chunking
CHUNK_SIZE = int(os.environ.get("CHUNK_SIZE", "500"))
"""Client AI-Orchestrator pour embeddings (via Ollama)"""
import requests
import time
from .config import AI_ORCHESTRATOR_URL, EMBEDDING_MODEL
def get_embedding(text: str) -> list[float]:
"""Génère embedding via AI-Orchestrator → Ollama"""
# Créer un job embedding
response = requests.post(
f"{AI_ORCHESTRATOR_URL}/api/jobs",
json={
"tool_id": "ollama",
"job_type": "embeddings",
"priority": "normal",
"input_params": {
"model": EMBEDDING_MODEL,
"prompt": text
}
},
timeout=10
)
response.raise_for_status()
job = response.json()
job_id = job["job_id"]
# Attendre le résultat (polling)
max_wait = 60 # secondes
start = time.time()
while time.time() - start < max_wait:
status_response = requests.get(
f"{AI_ORCHESTRATOR_URL}/api/jobs/{job_id}",
timeout=10
)
status = status_response.json()
if status["status"] == "completed":
return status["output"]["embedding"]
elif status["status"] == "failed":
raise Exception(f"Embedding failed: {status.get('error')}")
time.sleep(0.5)
raise TimeoutError(f"Embedding job {job_id} timeout")
def get_embeddings_batch(texts: list[str]) -> list[list[float]]:
"""Génère embeddings en batch (plus efficace)"""
# TODO: Implémenter batch si ai-orchestrator le supporte
return [get_embedding(text) for text in texts]
def check_ollama_health() -> bool:
"""Vérifie si Ollama est accessible via ai-orchestrator"""
try:
response = requests.get(f"{AI_ORCHESTRATOR_URL}/api/tools", timeout=5)
tools = response.json()
ollama = next((t for t in tools if t["id"] == "ollama"), None)
return ollama and ollama.get("status") == "running"
except:
return False
#!/usr/bin/env python3
"""
Sync memory/*.md vers Supabase pgvector
Usage: python -m src.sync [--force]
"""
import hashlib
from supabase import create_client
from .config import MEMORY_DIR, SUPABASE_URL, SUPABASE_KEY, SUPABASE_SCHEMA, CHUNK_SIZE
from .embeddings import get_embedding
def chunk_markdown(content: str, file_name: str) -> list[dict]:
"""Découpe un fichier markdown en chunks par section"""
chunks = []
current_section = "Introduction"
current_content = []
for line in content.split('\n'):
if line.startswith('## '):
if current_content:
text = '\n'.join(current_content).strip()
if text:
chunks.append({
"content": text,
"section": current_section,
"file_source": file_name
})
current_section = line[3:].strip()
current_content = []
else:
current_content.append(line)
if current_content:
text = '\n'.join(current_content).strip()
if text:
chunks.append({
"content": text,
"section": current_section,
"file_source": file_name
})
# Sous-découper les gros chunks
final_chunks = []
for chunk in chunks:
if len(chunk["content"]) > CHUNK_SIZE:
paragraphs = chunk["content"].split('\n\n')
current = ""
idx = 0
for para in paragraphs:
if len(current) + len(para) > CHUNK_SIZE:
if current:
final_chunks.append({
**chunk,
"content": current.strip(),
"chunk_index": idx
})
idx += 1
current = para
else:
current += "\n\n" + para if current else para
if current:
final_chunks.append({
**chunk,
"content": current.strip(),
"chunk_index": idx
})
else:
chunk["chunk_index"] = 0
final_chunks.append(chunk)
return final_chunks
def md5(text: str) -> str:
return hashlib.md5(text.encode()).hexdigest()
def sync_memory(force: bool = False):
"""Synchronise les fichiers memory vers Supabase"""
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
# Charger checksums existants depuis table files
existing = {}
if not force:
result = supabase.schema(SUPABASE_SCHEMA).table("files").select("file_name,checksum").execute()
for row in result.data:
existing[row["file_name"]] = row["checksum"]
stats = {"synced": 0, "skipped": 0, "chunks": 0}
for md_file in MEMORY_DIR.glob("*.md"):
content = md_file.read_text()
checksum = md5(content)
file_name = md_file.name
if not force and existing.get(file_name) == checksum:
print(f"[SKIP] {file_name}")
stats["skipped"] += 1
continue
print(f"[SYNC] {file_name}")
# Supprimer anciens chunks
supabase.schema(SUPABASE_SCHEMA).table("chunks").delete().eq("file_source", file_name).execute()
# Créer nouveaux chunks
chunks = chunk_markdown(content, file_name)
for chunk in chunks:
embedding = get_embedding(chunk["content"])
supabase.schema(SUPABASE_SCHEMA).table("chunks").insert({
"content": chunk["content"],
"embedding": embedding,
"file_source": chunk["file_source"],
"section": chunk["section"],
"chunk_index": chunk["chunk_index"],
"checksum": checksum
}).execute()
# Mettre à jour table files
supabase.schema(SUPABASE_SCHEMA).table("files").upsert({
"file_name": file_name,
"checksum": checksum,
"chunks_count": len(chunks)
}, on_conflict="file_name").execute()
print(f" → {len(chunks)} chunks")
stats["synced"] += 1
stats["chunks"] += len(chunks)
print(f"\n[DONE] {stats['synced']} fichiers sync, {stats['skipped']} skipped, {stats['chunks']} chunks total")
return stats
if __name__ == "__main__":
import sys
force = "--force" in sys.argv
sync_memory(force)
"""API FastAPI pour recherche mémoire"""
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from supabase import create_client
import uvicorn
from .config import SUPABASE_URL, SUPABASE_KEY, SUPABASE_SCHEMA
from .embeddings import get_embedding, check_ollama_health
app = FastAPI(title="Claude Memory API", version="1.0.0")
class SearchRequest(BaseModel):
query: str
namespace: str | None = None # None = recherche tous les namespaces
limit: int = 5
threshold: float = 0.7
class SearchResult(BaseModel):
content: str
namespace: str
file_source: str
section: str
similarity: float
@app.get("/health")
def health():
"""Health check"""
return {
"status": "ok",
"ollama": check_ollama_health()
}
@app.get("/stats")
def stats():
"""Statistiques de la mémoire"""
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
result = supabase.schema(SUPABASE_SCHEMA).rpc("stats").execute()
return result.data[0] if result.data else {}
@app.post("/search", response_model=list[SearchResult])
def search(req: SearchRequest):
"""Recherche sémantique dans la mémoire"""
if not req.query.strip():
raise HTTPException(400, "Query cannot be empty")
# Générer embedding
embedding = get_embedding(req.query)
# Recherche (namespace optionnel)
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
result = supabase.schema(SUPABASE_SCHEMA).rpc("search", {
"query_embedding": embedding,
"match_threshold": req.threshold,
"match_count": req.limit,
"filter_namespace": req.namespace # None = tous
}).execute()
# Log la recherche
supabase.schema(SUPABASE_SCHEMA).table("search_logs").insert({
"query": req.query,
"namespace": req.namespace,
"results_count": len(result.data),
"top_similarity": result.data[0]["similarity"] if result.data else None
}).execute()
return result.data
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8080)
Fichier : docker-compose.yml
version: "3.8"
services:
claude-memory:
image: registry.33800.nowhere84.com/gouroubleu/claude-memory:latest
container_name: claude-memory
restart: unless-stopped
environment:
- MEMORY_DIR=/data/memory
- AI_ORCHESTRATOR_URL=http://192.168.1.12:5501
- SUPABASE_URL=https://supabase-api-prod.33800.nowhere84.com
- SUPABASE_SERVICE_KEY=${SUPABASE_SERVICE_KEY}
- EMBEDDING_MODEL=nomic-embed-text
volumes:
# Mount les fichiers memory depuis PVE
- /mnt/stock_8to/33800-stack/claude/memory:/data/memory:ro
ports:
- "5510:8080"
networks:
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.claude-memory.rule=Host(`claude-memory.33800.nowhere84.com`)"
- "traefik.http.routers.claude-memory.tls=true"
- "traefik.http.services.claude-memory.loadbalancer.server.port=8080"
networks:
traefik-public:
external: true
URL API : https://claude-memory.33800.nowhere84.com
Endpoints :
GET /health # Health check + status Ollama
GET /stats # Stats mémoire (chunks, fichiers, last sync)
POST /search # Recherche sémantique
Usage :
curl -X POST https://claude-memory.33800.nowhere84.com/search \
-H "Content-Type: application/json" \
-d '{"query": "comment configurer le split-dns", "limit": 5}'
#!/bin/bash
# /home/gouroubleu/scripts/claude-search.sh
# Usage: claude-search.sh "ma question"
QUERY="$1"
curl -s -X POST https://claude-memory.33800.nowhere84.com/search \
-H "Content-Type: application/json" \
-d "{\"query\": \"$QUERY\", \"limit\": 5}" | jq -r '.[] | "[\(.file_source)] \(.section) (score: \(.similarity | . * 100 | floor)%)\n\(.content)\n---"'
## Recherche Mémoire Vectorielle
Après compression ou si info manquante :
\`\`\`bash
/home/gouroubleu/scripts/claude-search.sh "ta question"
\`\`\`
API directe :
- GET https://claude-memory.33800.nowhere84.com/stats
- POST https://claude-memory.33800.nowhere84.com/search
Le container claude-memory inclut un cron interne qui sync toutes les 5 minutes.
Fichier crontab (dans le container) :
*/5 * * * * python -m src.sync >> /var/log/sync.log 2>&1
Sync manuel (si besoin) :
docker exec claude-memory python -m src.sync --force
| Étape | Action |
|---|---|
| 1.1 | Cloner ai-orchestrator depuis GitLab |
| 1.2 | Ajouter job_type embeddings pour Ollama |
| 1.3 | Endpoint: POST /api/jobs avec {"tool_id": "ollama", "job_type": "embeddings", ...} |
| 1.4 | Push + CI/CD déploie automatiquement |
Code à ajouter dans ai-orchestrator (workers/ollama.py ou équivalent) :
async def handle_ollama_job(job):
if job["job_type"] == "embeddings":
response = requests.post(
f"{OLLAMA_URL}/api/embeddings",
json={
"model": job["input_params"]["model"],
"prompt": job["input_params"]["prompt"]
}
)
return {"embedding": response.json()["embedding"]}
elif job["job_type"] == "chat":
# ... existant
| Étape | Action |
|---|---|
| 2.1 | Créer repo GitLab claude-memory |
| 2.2 | Pull modèle nomic-embed-text sur Ollama |
| 2.3 | Exécuter migration SQL dans Supabase Studio |
| 2.4 | Push code source vers GitLab |
| 2.5 | Pipeline CI/CD build + deploy automatique |
| 2.6 | Première sync manuelle --force |
| 2.7 | Créer script claude-search.sh |
| 2.8 | Ajouter entrée nginx + dashboard |
| 2.9 | Test end-to-end |
| 2.10 | Mettre à jour CLAUDE.md |
Après compression, Claude peut :
# Recherche sémantique
./claude-search.sh "credentials gitlab"
# → Retourne section credentials de credentials.md
./claude-search.sh "comment déployer un service"
# → Retourne procédures de ci-cd.md et workflows.md
./claude-search.sh "configuration split-dns pihole"
# → Retourne chunks pertinents de pihole-split-dns.md
Stats disponibles :
curl https://claude-memory.33800.nowhere84.com/stats
# {"total_chunks": 150, "total_files": 15, "last_sync": "2026-01-27T20:30:00Z"}
| Dossier | Fichiers | Priorité |
|---|---|---|
| ~/.claude/memory/*.md | ~15 fichiers | HAUTE |
| ~/.claude/CLAUDE.md | 1 fichier | HAUTE |
| ~/STATUS.md | 1 fichier | MOYENNE |
| ~/docs/*.md | Documentation | BASSE (optionnel) |
gitlab.33800.nowhere84.com/gouroubleu/claude-memory
├── Code source complet
├── Dockerfile optimisé
├── Pipeline CI/CD fonctionnel
├── Migration SQL
└── README.md
claude_memory OK ?claude-memory (pas dans ai-orchestrator) OK ?En attente de validation pour exécution.