Răsfoiți Sursa

improvements, separate knowledge base, tests

Lukas Goldschmidt 2 zile în urmă
părinte
comite
3cbca7e125
6 a modificat fișierele cu 715 adăugiri și 153 ștergeri
  1. 2 7
      Dockerfile
  2. 206 53
      README.md
  3. 13 0
      docker-compose.yml
  4. 322 90
      mem0server.py
  5. 8 3
      reset_memory.py
  6. 164 0
      tests.sh

+ 2 - 7
Dockerfile

@@ -1,12 +1,7 @@
 FROM python:3.11-slim
-
 WORKDIR /app
-
 COPY requirements.txt .
 RUN pip install --no-cache-dir -r requirements.txt
-
-COPY mem0server.py .
-
+# mem0server.py is mounted at runtime — no COPY needed
 EXPOSE 8420
-
-CMD ["uvicorn", "mem0server:app", "--host", "0.0.0.0", "--port", "8420"]
+CMD ["uvicorn", "mem0server:app", "--host", "0.0.0.0", "--port", "8420", "--reload"]

+ 206 - 53
README.md

@@ -1,103 +1,214 @@
 # mem0server
-A lightweight FastAPI wrapper around [mem0](https://github.com/mem0ai/mem0) providing persistent memory over a REST API, with local reranking support.
+
+A lightweight FastAPI wrapper around [mem0](https://github.com/mem0ai/mem0) providing persistent memory over a REST API, with dual-collection storage, metadata passthrough, and local reranking.
+
 ## Architecture
+
 | Component | Provider | Address |
 |-----------|----------|---------|
-| LLM | Groq (`llama-3.1-8b-instant`) | cloud |
+| LLM | Groq (`meta-llama/llama-4-scout-17b-16e-instruct`) | cloud |
 | Vector store | Chroma | `192.168.0.200:8001` |
 | Embedder | Ollama (`nomic-embed-text`) | `192.168.0.200:11434` |
 | Reranker | local REST server | `192.168.0.200:5200` |
-## Setup
-```bash
-python -m venv mem0env
-source mem0env/bin/activate
-pip install fastapi uvicorn mem0 httpx
-```
+
+## Collections
+
+The server maintains two independent Chroma collections with separate extraction prompts:
+
+| Collection | Chroma name | Endpoint prefix | Used by | Extraction style |
+|------------|-------------|-----------------|---------|-----------------|
+| Conversational | `openclaw_mem` | `/memories` | OpenClaw agent | User-centric facts (`"User prefers…"`) |
+| Knowledge | `knowledge_mem` | `/knowledge` | book-ingestor | Objective, encyclopedic facts |
+
+Prompts for both collections are defined in the `PROMPTS` dict at the top of `mem0server.py` and are easy to edit without touching routing code.
+
 ## Environment variables
+
 | Variable | Required | Default | Description |
 |----------|----------|---------|-------------|
 | `GROQ_API_KEY` | ✅ yes | — | Groq API key |
 | `RERANKER_URL` | no | `http://192.168.0.200:5200/rerank` | Local reranker endpoint |
-```bash
-export GROQ_API_KEY=your_key_here
-# optional override:
-export RERANKER_URL=http://localhost:5200/rerank
-```
-## Running
-```bash
-uvicorn mem0server:app --host 0.0.0.0 --port 8420
-```
 
-## Docker
+Create a `.env` file (never commit it):
 
-### Build
-```bash
-docker build -t mem0server .
+```env
+GROQ_API_KEY=your_key_here
+RERANKER_URL=http://192.168.0.200:5200/rerank
 ```
 
-### Run
+## Docker (recommended)
 
-**Option A — pass the API key directly:**
-```bash
-docker run -p 8420:8420 -e GROQ_API_KEY=your_key_here mem0server
-```
+The recommended setup mounts `mem0server.py` as a volume so code changes are picked up by uvicorn's `--reload` without rebuilding the image. Only touch `docker compose build` when `requirements.txt` changes.
 
-**Option B — use your `.env` file:**
-```bash
-docker run -p 8420:8420 --env-file .env mem0server
+### `docker-compose.yml`
+
+```yaml
+services:
+  mem0server:
+    build: .
+    image: mem0server:latest
+    container_name: mem0server
+    ports:
+      - "8420:8420"
+    volumes:
+      - ./mem0server.py:/app/mem0server.py:ro
+    env_file:
+      - .env
+    restart: unless-stopped
 ```
 
-**Option C — override the reranker URL as well:**
-```bash
-docker run -p 8420:8420 \
-  --env-file .env \
-  -e RERANKER_URL=http://192.168.0.200:5200/rerank \
-  mem0server
+### `Dockerfile`
+
+```dockerfile
+FROM python:3.11-slim
+WORKDIR /app
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+# mem0server.py is mounted at runtime via docker-compose volume
+EXPOSE 8420
+CMD ["uvicorn", "mem0server:app", "--host", "0.0.0.0", "--port", "8420", "--reload"]
 ```
 
-> ⚠️ Never bake your `.env` into the image. Always pass secrets at runtime via `--env-file` or `-e`.
+### Workflow
+
+| Situation | Command |
+|-----------|---------|
+| First time / deps changed | `docker compose up --build` |
+| Code edit | Just save — uvicorn reloads automatically |
+| Restart container | `docker compose restart` |
+| View logs | `docker compose logs -f` |
+
+> ⚠️ Never bake `.env` into the image. Always pass secrets at runtime via `env_file` or `-e`.
+
+---
 
 ## API
+
 ### `GET /health`
-Returns server status and configured reranker URL.
+
+Returns server status, active collection names, and a preview of each extraction prompt.
+
 ```json
-{ "status": "ok", "reranker_url": "http://192.168.0.200:5200/rerank" }
+{
+  "status": "ok",
+  "reranker_url": "http://192.168.0.200:5200/rerank",
+  "collections": {
+    "conversational": "openclaw_mem",
+    "knowledge": "knowledge_mem"
+  },
+  "prompts": {
+    "conversational": "You are a personal memory assistant…",
+    "knowledge": "You are a knowledge extraction assistant…"
+  }
+}
 ```
+
 ---
-### `POST /memories`
-Add a memory for a user.
+
+### `/memories` — conversational collection (OpenClaw)
+
+#### `POST /memories`
+
+Add a memory. Accepts plain text or a messages array.
+
 ```json
-{ "text": "The user prefers dark mode.", "userId": "alice" }
+{ "text": "I prefer Python over JavaScript.", "user_id": "alice" }
 ```
----
-### `POST /memories/search`
-Search memories with reranking. Fetches `limit * 3` candidates from mem0, then reranks them locally.
+
 ```json
-{ "query": "UI preferences", "userId": "alice", "limit": 5 }
+{
+  "messages": [{"role": "user", "content": "I've used Vim for 10 years."}],
+  "user_id": "alice"
+}
 ```
-Returns results with an added `rerank_score` field.
----
-### `POST /memories/recent`
+
+#### `POST /memories/search`
+
+Search with reranking. Fetches `limit × 3` candidates, reranks locally, returns top `limit`.
+
+```json
+{ "query": "editor preferences", "user_id": "alice", "limit": 5 }
+```
+
+Results include a `rerank_score` field.
+
+#### `POST /memories/recent`
+
 Return the most recently created memories for a user.
+
 ```json
-{ "userId": "alice", "limit": 5 }
+{ "user_id": "alice", "limit": 5 }
 ```
----
-### `DELETE /memories`
+
+#### `DELETE /memories`
+
 Delete memories by filter.
+
 ```json
 { "filter": { "user_id": "alice" } }
 ```
+
+---
+
+### `/knowledge` — knowledge collection (book-ingestor)
+
+Same shape as `/memories` with two additions:
+
+**`metadata`** — arbitrary key/value dict stored alongside the memory. Useful for provenance tagging:
+
+```json
+{
+  "text": "Silvio Gesell proposed demurrage to discourage currency hoarding.",
+  "user_id": "knowledge_base",
+  "metadata": { "source_file": "gesell_neo.pdf", "chapter": 3, "page": 47 }
+}
+```
+
+**`infer: false`** — bypasses LLM extraction entirely and stores the text verbatim. Use for pre-summarised chunks where you want exact content preserved:
+
+```json
+{
+  "text": "MIDI SysEx messages use a 7-bit checksum.",
+  "user_id": "knowledge_base",
+  "infer": false,
+  "metadata": { "source_file": "midi_spec.pdf", "chapter": 9, "page": 112 }
+}
+```
+
+#### `POST /knowledge/search`
+#### `POST /knowledge/recent`
+#### `DELETE /knowledge`
+
+Same request/response shape as their `/memories` counterparts.
+
+---
+
+### `POST /search` — merged search (both collections)
+
+Queries both collections simultaneously, tags each result with `_source`, then runs a single rerank pass over the merged pool. Intended for OpenClaw's autorecall webhook when it should draw on both conversational memory and ingested knowledge.
+
+```json
+{ "query": "Gesell economic theory", "user_id": "knowledge_base", "limit": 8 }
+```
+
+Each result includes `"_source": "conversational"` or `"_source": "knowledge"`.
+
+---
+
 ## Reranker contract
+
 The server expects a reranker at `RERANKER_URL` accepting:
+
 ```json
 {
   "query": "...",
-  "documents": ["doc1", "doc2", "..."],
+  "documents": ["doc1", "doc2"],
   "top_k": 5
 }
 ```
+
 And returning:
+
 ```json
 {
   "results": [
@@ -106,4 +217,46 @@ And returning:
   ]
 }
 ```
-If the reranker is unreachable, search falls back gracefully to the raw mem0 results.
+
+If the reranker is unreachable the server falls back gracefully to raw mem0 results — no crash, no error returned to the caller.
+
+---
+
+## Resetting a collection
+
+`reset_memory.py` deletes and recreates a collection directly via the Chroma HTTP API, then restarts the mem0server container so it re-acquires the fresh collection object:
+
+```bash
+python reset_memory.py openclaw_mem   # wipe conversational
+python reset_memory.py knowledge_mem  # wipe knowledge
+```
+
+```python
+# reset_memory.py
+import sys, subprocess, requests
+
+base = "http://192.168.0.200:8001/api/v1"
+name = sys.argv[1]
+requests.delete(f"{base}/collections/{name}")
+requests.post(f"{base}/collections", json={"name": name})
+print(f"collection reset: {name}")
+subprocess.run(["docker", "compose", "restart", "mem0server"])
+print("mem0server restarted")
+```
+
+---
+
+## Testing
+
+`tests.sh` exercises every endpoint. Run it after any server change:
+
+```bash
+bash tests.sh
+```
+
+Key assertions to eyeball manually:
+
+- `/knowledge/search` query `"Gesell demurrage"` → Gesell result ranked #1, MIDI result near the bottom
+- `/knowledge/search` query `"free money currency hoarding"` → Gesell `rerank_score` should be `> 0.9`, MIDI `< 0.001`  
+- `/search` (merged) results should include `_source` field on every item
+- `infer: false` response body should show the text stored verbatim, not an LLM-rewritten version

+ 13 - 0
docker-compose.yml

@@ -0,0 +1,13 @@
+# docker-compose.yml
+services:
+  mem0server:
+    build: .
+    image: mem0server:latest
+    container_name: mem0server
+    ports:
+      - "8420:8420"
+    volumes:
+      - ./mem0server.py:/app/mem0server.py:ro
+    env_file:
+      - .env
+    restart: unless-stopped

+ 322 - 90
mem0server.py

@@ -1,86 +1,181 @@
 import os
+import math
+import json
 import httpx
 from fastapi import FastAPI, Request
 from fastapi.responses import JSONResponse
 from mem0 import Memory
 
-# --- Env validation -----------------------------------------------------------
+# =============================================================================
+# ENVIRONMENT
+# =============================================================================
+
 GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
 if not GROQ_API_KEY:
     raise RuntimeError("GROQ_API_KEY environment variable is not set.")
 
 RERANKER_URL = os.environ.get("RERANKER_URL", "http://192.168.0.200:5200/rerank")
 
-# --- mem0 config --------------------------------------------------------------
-config = {
-    "llm": {
-        "provider": "groq",
-        "config": {
-            "model": "meta-llama/llama-4-scout-17b-16e-instruct",
-            "temperature": 0.025,
-            "max_tokens": 1500,
-        },
-    },
-    "vector_store": {
-        "provider": "chroma",
-        "config": {
-            "host": "192.168.0.200",
-            "port": 8001,
-            "collection_name": "openclaw_mem",
-        },
+# =============================================================================
+# SAFE JSON RESPONSE  (handles Infinity / NaN from Chroma / reranker scores)
+# =============================================================================
+
+def _sanitize(obj):
+    if isinstance(obj, float):
+        if math.isnan(obj) or math.isinf(obj):
+            return None
+    if isinstance(obj, dict):
+        return {k: _sanitize(v) for k, v in obj.items()}
+    if isinstance(obj, list):
+        return [_sanitize(i) for i in obj]
+    return obj
+
+
+class SafeJSONResponse(JSONResponse):
+    def render(self, content) -> bytes:
+        return json.dumps(
+            _sanitize(content), ensure_ascii=False
+        ).encode("utf-8")
+
+
+# =============================================================================
+# PROMPTS
+# Edit these to change how each collection extracts and stores facts.
+# =============================================================================
+
+PROMPTS = {
+    # Used by /memories — conversational, user-centric recall for OpenClaw.
+    "conversational": {
+        "fact_extraction": """
+You are a personal memory assistant. Extract concise, standalone facts about
+the user from the conversation below. Write each fact as a single sentence
+starting with "User" — for example:
+  - "User is interested in generative music."
+  - "User is familiar with Python async patterns."
+  - "User prefers dark mode interfaces."
+Only extract facts that are clearly stated or strongly implied. Ignore filler,
+greetings, and opinions the user is uncertain about.
+""".strip(),
+        "update_memory": """
+You manage a long-term memory database for a personal AI assistant.
+You receive existing memories and new information. Update, merge, or add
+memories as needed. Keep each memory as a single concise sentence starting
+with "User". Remove duplicates and outdated facts.
+""".strip(),
     },
-    "embedder": {
-        "provider": "ollama",
-        "config": {
-            "model": "nomic-embed-text",
-            "ollama_base_url": "http://192.168.0.200:11434",
-        },
+
+    # Used by /knowledge — objective, source-neutral facts for book/doc ingest.
+    "knowledge": {
+        "fact_extraction": """
+You are a knowledge extraction system that reads source material and produces
+a list of objective, encyclopedic facts. Write each fact as a precise,
+self-contained sentence. Do NOT reframe facts as user preferences or interests.
+Preserve names, terminology, and relationships exactly as they appear.
+Examples:
+  - "Silvio Gesell proposed demurrage as a mechanism to discourage hoarding of currency."
+  - "The MIDI standard uses a 7-bit checksum for SysEx message validation."
+Only extract verifiable facts. Ignore meta-commentary and transitional prose.
+""".strip(),
+        "update_memory": """
+You manage a knowledge base that stores objective facts extracted from books,
+documents, and reference material. You receive existing facts and new
+information. Update, merge, or add facts as needed. Keep each fact as a
+precise, self-contained sentence. Remove duplicates and outdated entries.
+""".strip(),
     },
 }
 
-memory = Memory.from_config(config)
+# =============================================================================
+# MEM0 CONFIG FACTORY
+# =============================================================================
 
-# --- Patch: Chroma empty-filter crash -----------------------------------------
-orig_search = memory.vector_store.search
+def make_config(collection_name: str, prompt_key: str) -> dict:
+    return {
+        "llm": {
+            "provider": "groq",
+            "config": {
+                "model": "meta-llama/llama-4-scout-17b-16e-instruct",
+                "temperature": 0.025,
+                "max_tokens": 1500,
+            },
+        },
+        "vector_store": {
+            "provider": "chroma",
+            "config": {
+                "host": "192.168.0.200",
+                "port": 8001,
+                "collection_name": collection_name,
+            },
+        },
+        "embedder": {
+            "provider": "ollama",
+            "config": {
+                "model": "nomic-embed-text",
+                "ollama_base_url": "http://192.168.0.200:11434",
+            },
+        },
+        "custom_prompts": PROMPTS[prompt_key],
+    }
+
+
+# =============================================================================
+# MEMORY INSTANCES
+# =============================================================================
+
+memory_conv = Memory.from_config(make_config("openclaw_mem", "conversational"))
+memory_know = Memory.from_config(make_config("knowledge_mem", "knowledge"))
+
+# =============================================================================
+# CHROMA EMPTY-FILTER PATCH  (applied to both instances)
+# =============================================================================
 
 NOOP_WHERE = {"$and": [
     {"user_id": {"$ne": ""}},
     {"user_id": {"$ne": ""}},
 ]}
 
-def is_effectively_empty(filters):
+
+def is_effectively_empty(filters) -> bool:
     if not filters:
         return True
     if filters in ({"AND": []}, {"OR": []}):
         return True
     return False
 
-def safe_search(query, vectors, limit=10, filters=None):
-    if is_effectively_empty(filters):
-        return memory.vector_store.collection.query(
-            query_embeddings=vectors,
-            n_results=limit,
-            where=NOOP_WHERE,
-        )
-    try:
-        return orig_search(query=query, vectors=vectors, limit=limit, filters=filters)
-    except Exception as e:
-        if "Expected where" in str(e):
-            return memory.vector_store.collection.query(
+
+def make_safe_search(mem_instance: Memory):
+    orig = mem_instance.vector_store.search
+
+    def safe_search(query, vectors, limit=10, filters=None):
+        if is_effectively_empty(filters):
+            return mem_instance.vector_store.collection.query(
                 query_embeddings=vectors,
                 n_results=limit,
                 where=NOOP_WHERE,
             )
-        raise
+        try:
+            return orig(query=query, vectors=vectors, limit=limit, filters=filters)
+        except Exception as e:
+            if "Expected where" in str(e):
+                return mem_instance.vector_store.collection.query(
+                    query_embeddings=vectors,
+                    n_results=limit,
+                    where=NOOP_WHERE,
+                )
+            raise
+
+    return safe_search
+
+
+memory_conv.vector_store.search = make_safe_search(memory_conv)
+memory_know.vector_store.search = make_safe_search(memory_know)
 
-memory.vector_store.search = safe_search
+# =============================================================================
+# RERANKER
+# =============================================================================
 
-# --- Reranker -----------------------------------------------------------------
 def rerank_results(query: str, items: list, top_k: int) -> list:
-    """
-    Call the local reranker server and re-order mem0 results by score.
-    Falls back to the original list if the reranker is unavailable.
-    """
+    """Re-order results via local reranker. Falls back gracefully."""
     if not items:
         return items
 
@@ -97,7 +192,6 @@ def rerank_results(query: str, items: list, top_k: int) -> list:
         print(f"[reranker] unavailable, skipping rerank: {exc}")
         return items[:top_k]
 
-    # Re-attach original mem0 metadata by matching text
     text_to_meta = {r.get("memory", ""): r for r in items}
     merged = []
     for r in reranked:
@@ -106,77 +200,215 @@ def rerank_results(query: str, items: list, top_k: int) -> list:
             merged.append({**meta, "rerank_score": r["score"]})
     return merged
 
-# --- App ----------------------------------------------------------------------
-app = FastAPI(title="mem0 server")
+# =============================================================================
+# SHARED HELPERS
+# =============================================================================
 
+def extract_user_id(data: dict) -> str:
+    return data.get("userId") or data.get("user_id") or "default"
 
-@app.get("/health")
-async def health():
-    return {"status": "ok", "reranker_url": RERANKER_URL}
 
+async def handle_add(req: Request, mem: Memory, verbatim_allowed: bool = False):
+    """
+    Shared add handler for /memories and /knowledge.
 
-@app.post("/memories")
-async def add_memory(req: Request):
+    Supports:
+      - text        — raw string (legacy)
+      - messages    — list of {role, content} dicts (standard mem0)
+      - infer       — bool, default True. If False and verbatim_allowed=True,
+                      stores content without LLM extraction.
+      - metadata    — dict, passed through to mem0
+      - user_id / userId
+    """
     data = await req.json()
+    user_id = extract_user_id(data)
+    metadata = data.get("metadata") or {}
+    infer = data.get("infer", True)
+
+    messages = data.get("messages")
     text = data.get("text")
-    user_id = data.get("userId") or data.get("user_id") or "default"
-    if not text:
-        return JSONResponse({"error": "Empty 'text' field"}, status_code=400)
-    result = memory.add(text, user_id=user_id)
-    print("add_memory:", {"user_id": user_id, "text": text[:80], "result": result})
-    return result
 
+    if not messages and not text:
+        return SafeJSONResponse(
+            content={"error": "Provide 'text' or 'messages'"}, status_code=400
+        )
 
-@app.post("/memories/search")
-async def search(req: Request):
+    # infer:false — store verbatim (knowledge collection only)
+    if verbatim_allowed and not infer:
+        content = text or " ".join(
+            m["content"] for m in messages if m.get("role") == "user"
+        )
+        result = mem.add(content, user_id=user_id, metadata=metadata, infer=False)
+        print(f"[add verbatim] user={user_id} chars={len(content)} meta={metadata}")
+        return SafeJSONResponse(content=result)
+
+    # Normal path — LLM extraction
+    if messages:
+        result = mem.add(messages, user_id=user_id, metadata=metadata)
+    else:
+        result = mem.add(text, user_id=user_id, metadata=metadata)
+
+    print(f"[add] user={user_id} infer=True meta={metadata}")
+    return SafeJSONResponse(content=result)
+
+
+async def handle_search(req: Request, mem: Memory):
     data = await req.json()
     query = (data.get("query") or "").strip()
-    user_id = data.get("userId") or data.get("user_id") or "default"
+    user_id = extract_user_id(data)
     limit = int(data.get("limit", 5))
 
     if not query:
-        return {"results": []}
+        return SafeJSONResponse(content={"results": []})
 
-    # 1. Retrieve candidates from mem0 (fetch more than limit for reranker)
     fetch_k = max(limit * 3, 15)
     try:
-        result = memory.search(query, user_id=user_id, limit=fetch_k)
+        result = mem.search(query, user_id=user_id, limit=fetch_k)
     except Exception:
-        # Fallback: get_all + simple text filter
-        all_res = memory.get_all(user_id=user_id)
-        items = all_res.get("results", []) if isinstance(all_res, dict) else (all_res if isinstance(all_res, list) else [])
+        all_res = mem.get_all(user_id=user_id)
+        items = (
+            all_res.get("results", [])
+            if isinstance(all_res, dict)
+            else (all_res if isinstance(all_res, list) else [])
+        )
         q = query.lower()
         items = [r for r in items if q in r.get("memory", "").lower()]
         result = {"results": items}
 
     items = result.get("results", [])
-
-    # 2. Rerank
     items = rerank_results(query, items, top_k=limit)
-
-    result = {"results": items}
-    print("search:", {"user_id": user_id, "query": query, "count": len(items)})
-    return result
+    print(f"[search] user={user_id} query={query!r} hits={len(items)}")
+    return SafeJSONResponse(content={"results": items})
 
 
-@app.delete("/memories")
-async def delete(req: Request):
-    data = await req.json()
-    return memory.delete(data.get("filter", {}))
-
-
-@app.post("/memories/recent")
-async def recent(req: Request):
+async def handle_recent(req: Request, mem: Memory):
     data = await req.json()
-    user_id = data.get("userId") or data.get("user_id") or "default"
+    user_id = extract_user_id(data)
     if not user_id:
-        return JSONResponse({"error": "Missing userId"}, status_code=400)
+        return SafeJSONResponse(content={"error": "Missing userId"}, status_code=400)
     limit = int(data.get("limit", 5))
-    print("recent payload:", data, "user_id:", user_id)
+
     try:
-        results = memory.get_all(user_id=user_id)
+        results = mem.get_all(user_id=user_id)
     except Exception:
-        results = memory.search(query="*", user_id=user_id)
+        results = mem.search(query="recent", user_id=user_id)
+
     items = results.get("results", [])
     items = sorted(items, key=lambda r: r.get("created_at", ""), reverse=True)
-    return {"results": items[:limit]}
+    return SafeJSONResponse(content={"results": items[:limit]})
+
+# =============================================================================
+# APP
+# =============================================================================
+
+app = FastAPI(title="mem0 server")
+
+
+@app.get("/health")
+async def health():
+    return SafeJSONResponse(content={
+        "status": "ok",
+        "reranker_url": RERANKER_URL,
+        "collections": {
+            "conversational": "openclaw_mem",
+            "knowledge": "knowledge_mem",
+        },
+        "prompts": {
+            k: {pk: pv[:80] + "…" for pk, pv in pv_dict.items()}
+            for k, pv_dict in PROMPTS.items()
+        },
+    })
+
+
+# ---------------------------------------------------------------------------
+# /memories  — conversational, OpenClaw
+# ---------------------------------------------------------------------------
+
+@app.post("/memories")
+async def add_memory(req: Request):
+    return await handle_add(req, memory_conv, verbatim_allowed=False)
+
+
+@app.post("/memories/search")
+async def search_memories(req: Request):
+    return await handle_search(req, memory_conv)
+
+
+@app.post("/memories/recent")
+async def recent_memories(req: Request):
+    return await handle_recent(req, memory_conv)
+
+
+@app.delete("/memories")
+async def delete_memory(req: Request):
+    data = await req.json()
+    return SafeJSONResponse(content=memory_conv.delete(data.get("filter", {})))
+
+
+# ---------------------------------------------------------------------------
+# /knowledge  — objective facts, book-ingestor
+# ---------------------------------------------------------------------------
+
+@app.post("/knowledge")
+async def add_knowledge(req: Request):
+    return await handle_add(req, memory_know, verbatim_allowed=True)
+
+
+@app.post("/knowledge/search")
+async def search_knowledge(req: Request):
+    return await handle_search(req, memory_know)
+
+
+@app.post("/knowledge/recent")
+async def recent_knowledge(req: Request):
+    return await handle_recent(req, memory_know)
+
+
+@app.delete("/knowledge")
+async def delete_knowledge(req: Request):
+    data = await req.json()
+    return SafeJSONResponse(content=memory_know.delete(data.get("filter", {})))
+
+
+# ---------------------------------------------------------------------------
+# /search  — merged results from both collections (OpenClaw autorecall)
+# ---------------------------------------------------------------------------
+
+@app.post("/search")
+async def search_all(req: Request):
+    """
+    Query both collections and merge results.
+    Results are tagged with _source: conversational | knowledge.
+    Accepts same payload as /memories/search.
+    """
+    data = await req.json()
+    query = (data.get("query") or "").strip()
+    user_id = extract_user_id(data)
+    limit = int(data.get("limit", 5))
+
+    if not query:
+        return SafeJSONResponse(content={"results": []})
+
+    fetch_k = max(limit * 3, 15)
+
+    def fetch(mem: Memory, tag: str):
+        try:
+            r = mem.search(query, user_id=user_id, limit=fetch_k)
+            items = r.get("results", [])
+        except Exception:
+            items = []
+        for item in items:
+            item["_source"] = tag
+        return items
+
+    conv_items = fetch(memory_conv, "conversational")
+    know_items = fetch(memory_know, "knowledge")
+
+    merged = conv_items + know_items
+    merged = rerank_results(query, merged, top_k=limit)
+
+    print(
+        f"[search/all] user={user_id} query={query!r} "
+        f"conv={len(conv_items)} know={len(know_items)} merged={len(merged)}"
+    )
+    return SafeJSONResponse(content={"results": merged})

+ 8 - 3
reset_memory.py

@@ -1,11 +1,16 @@
+# reset_memory.py
 import sys
 import requests
 
-base = "http://192.168.0.200:8000/api/v1"
+base = "http://192.168.0.200:8001/api/v1"
+mem0_base = "http://192.168.0.200:8420"
 
 name = sys.argv[1]
-
 requests.delete(f"{base}/collections/{name}")
 requests.post(f"{base}/collections", json={"name": name})
+print(f"collection reset: {name}")
 
-print("collection reset:", name)
+# Bounce the mem0 server so it re-acquires the fresh collection
+import subprocess
+subprocess.run(["docker", "compose", "restart", "mem0server"])
+print("mem0server restarted")

+ 164 - 0
tests.sh

@@ -0,0 +1,164 @@
+#!/usr/bin/env bash
+# tests.sh — mem0server endpoint tests
+# Usage: bash tests.sh [BASE_URL]
+# Default base: http://192.168.0.200:8420
+
+BASE="${1:-http://192.168.0.200:8420}"
+PASS=0
+FAIL=0
+
+# Colours
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+CYAN='\033[0;36m'
+BOLD='\033[1m'
+RESET='\033[0m'
+
+sep()  { echo -e "\n${CYAN}${BOLD}━━━ $* ━━━${RESET}"; }
+ok()   { echo -e "${GREEN}✓ $*${RESET}"; ((PASS++)); }
+fail() { echo -e "${RED}✗ $*${RESET}"; ((FAIL++)); }
+
+# Run curl, pretty-print with jq, check HTTP 200
+run() {
+  local label="$1"; shift
+  echo -e "\n${BOLD}▶ $label${RESET}"
+  local body
+  body=$(curl -s -w "\n__STATUS__%{http_code}" "$@")
+  local status
+  status=$(echo "$body" | tail -1 | sed 's/__STATUS__//')
+  local json
+  json=$(echo "$body" | sed '$d')
+
+  echo "$json" | jq . 2>/dev/null || echo "$json"
+
+  if [[ "$status" == "200" ]]; then
+    ok "HTTP $status"
+  else
+    fail "HTTP $status"
+  fi
+}
+
+# ─────────────────────────────────────────────
+sep "HEALTH"
+# ─────────────────────────────────────────────
+
+run "GET /health" \
+  "$BASE/health"
+
+# ─────────────────────────────────────────────
+sep "/memories  (conversational — OpenClaw)"
+# ─────────────────────────────────────────────
+
+run "POST /memories — plain text" \
+  -X POST "$BASE/memories" \
+  -H "Content-Type: application/json" \
+  -d '{"text": "I love building AI agents and I prefer Python over JavaScript", "user_id": "testuser"}'
+
+run "POST /memories — messages array" \
+  -X POST "$BASE/memories" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "messages": [{"role": "user", "content": "I have been using Vim for 10 years and hate GUI editors"}],
+    "user_id": "testuser"
+  }'
+
+run "POST /memories/search — programming preferences" \
+  -X POST "$BASE/memories/search" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "programming preferences", "user_id": "testuser", "limit": 5}'
+
+run "POST /memories/recent" \
+  -X POST "$BASE/memories/recent" \
+  -H "Content-Type: application/json" \
+  -d '{"user_id": "testuser", "limit": 5}'
+
+# ─────────────────────────────────────────────
+sep "/knowledge  (objective facts — book-ingestor)"
+# ─────────────────────────────────────────────
+
+run "POST /knowledge — Gesell with metadata (LLM extraction)" \
+  -X POST "$BASE/knowledge" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "text": "Silvio Gesell proposed demurrage as a mechanism to discourage hoarding of currency. He described this in The Natural Economic Order published in 1916.",
+    "user_id": "knowledge_base",
+    "metadata": {"source_file": "gesell_neo.pdf", "chapter": 3, "page": 47}
+  }'
+
+run "POST /knowledge — MIDI verbatim (infer:false)" \
+  -X POST "$BASE/knowledge" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "text": "MIDI SysEx messages use a 7-bit checksum computed as the twos complement of the sum of all data bytes.",
+    "user_id": "knowledge_base",
+    "infer": false,
+    "metadata": {"source_file": "midi_spec.pdf", "chapter": 9, "page": 112}
+  }'
+
+run "POST /knowledge/search — Gesell demurrage (should NOT return MIDI)" \
+  -X POST "$BASE/knowledge/search" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "Gesell demurrage", "user_id": "knowledge_base", "limit": 5}'
+
+run "POST /knowledge/search — free money currency hoarding" \
+  -X POST "$BASE/knowledge/search" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "free money currency hoarding", "user_id": "knowledge_base", "limit": 5}'
+
+run "POST /knowledge/search — MIDI checksum (should NOT return Gesell)" \
+  -X POST "$BASE/knowledge/search" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "MIDI checksum SysEx", "user_id": "knowledge_base", "limit": 5}'
+
+run "POST /knowledge/recent" \
+  -X POST "$BASE/knowledge/recent" \
+  -H "Content-Type: application/json" \
+  -d '{"user_id": "knowledge_base", "limit": 5}'
+
+# ─────────────────────────────────────────────
+sep "/search  (merged — both collections)"
+# ─────────────────────────────────────────────
+
+run "POST /search — Python programming (expect _source tags on results)" \
+  -X POST "$BASE/search" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "Python programming", "user_id": "testuser", "limit": 8}'
+
+run "POST /search — Gesell economic theory (cross-collection)" \
+  -X POST "$BASE/search" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "Gesell economic theory", "user_id": "knowledge_base", "limit": 5}'
+
+# ─────────────────────────────────────────────
+sep "ERROR HANDLING"
+# ─────────────────────────────────────────────
+
+run "POST /memories — missing text/messages (expect 400)" \
+  -X POST "$BASE/memories" \
+  -H "Content-Type: application/json" \
+  -d '{"user_id": "testuser"}'
+
+run "POST /memories — infer:false ignored on /memories (no verbatim)" \
+  -X POST "$BASE/memories" \
+  -H "Content-Type: application/json" \
+  -d '{"text": "This should still go through LLM extraction", "user_id": "testuser", "infer": false}'
+
+# ─────────────────────────────────────────────
+sep "DELETE  (comment out if you want to keep test data)"
+# ─────────────────────────────────────────────
+
+# Uncomment to clean up after testing:
+# run "DELETE /memories — testuser" \
+#   -X DELETE "$BASE/memories" \
+#   -H "Content-Type: application/json" \
+#   -d '{"filter": {"user_id": "testuser"}}'
+#
+# run "DELETE /knowledge — knowledge_base" \
+#   -X DELETE "$BASE/knowledge" \
+#   -H "Content-Type: application/json" \
+#   -d '{"filter": {"user_id": "knowledge_base"}}'
+
+# ─────────────────────────────────────────────
+sep "RESULTS"
+# ─────────────────────────────────────────────
+echo -e "\n${BOLD}Passed: ${GREEN}$PASS${RESET}  ${BOLD}Failed: ${RED}$FAIL${RESET}\n"