Pārlūkot izejas kodu

improvements, separate knowledge base, tests

Lukas Goldschmidt 2 dienas atpakaļ
vecāks
revīzija
3cbca7e125
6 mainītis faili ar 715 papildinājumiem un 153 dzēšanām
  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
 FROM python:3.11-slim
-
 WORKDIR /app
 WORKDIR /app
-
 COPY requirements.txt .
 COPY requirements.txt .
 RUN pip install --no-cache-dir -r 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
 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
 # 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
 ## Architecture
+
 | Component | Provider | Address |
 | 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` |
 | Vector store | Chroma | `192.168.0.200:8001` |
 | Embedder | Ollama (`nomic-embed-text`) | `192.168.0.200:11434` |
 | Embedder | Ollama (`nomic-embed-text`) | `192.168.0.200:11434` |
 | Reranker | local REST server | `192.168.0.200:5200` |
 | 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
 ## Environment variables
+
 | Variable | Required | Default | Description |
 | Variable | Required | Default | Description |
 |----------|----------|---------|-------------|
 |----------|----------|---------|-------------|
 | `GROQ_API_KEY` | ✅ yes | — | Groq API key |
 | `GROQ_API_KEY` | ✅ yes | — | Groq API key |
 | `RERANKER_URL` | no | `http://192.168.0.200:5200/rerank` | Local reranker endpoint |
 | `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
 ## API
+
 ### `GET /health`
 ### `GET /health`
-Returns server status and configured reranker URL.
+
+Returns server status, active collection names, and a preview of each extraction prompt.
+
 ```json
 ```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
 ```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
 ```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.
 Return the most recently created memories for a user.
+
 ```json
 ```json
-{ "userId": "alice", "limit": 5 }
+{ "user_id": "alice", "limit": 5 }
 ```
 ```
----
-### `DELETE /memories`
+
+#### `DELETE /memories`
+
 Delete memories by filter.
 Delete memories by filter.
+
 ```json
 ```json
 { "filter": { "user_id": "alice" } }
 { "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
 ## Reranker contract
+
 The server expects a reranker at `RERANKER_URL` accepting:
 The server expects a reranker at `RERANKER_URL` accepting:
+
 ```json
 ```json
 {
 {
   "query": "...",
   "query": "...",
-  "documents": ["doc1", "doc2", "..."],
+  "documents": ["doc1", "doc2"],
   "top_k": 5
   "top_k": 5
 }
 }
 ```
 ```
+
 And returning:
 And returning:
+
 ```json
 ```json
 {
 {
   "results": [
   "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 os
+import math
+import json
 import httpx
 import httpx
 from fastapi import FastAPI, Request
 from fastapi import FastAPI, Request
 from fastapi.responses import JSONResponse
 from fastapi.responses import JSONResponse
 from mem0 import Memory
 from mem0 import Memory
 
 
-# --- Env validation -----------------------------------------------------------
+# =============================================================================
+# ENVIRONMENT
+# =============================================================================
+
 GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
 GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
 if not GROQ_API_KEY:
 if not GROQ_API_KEY:
     raise RuntimeError("GROQ_API_KEY environment variable is not set.")
     raise RuntimeError("GROQ_API_KEY environment variable is not set.")
 
 
 RERANKER_URL = os.environ.get("RERANKER_URL", "http://192.168.0.200:5200/rerank")
 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": [
 NOOP_WHERE = {"$and": [
     {"user_id": {"$ne": ""}},
     {"user_id": {"$ne": ""}},
     {"user_id": {"$ne": ""}},
     {"user_id": {"$ne": ""}},
 ]}
 ]}
 
 
-def is_effectively_empty(filters):
+
+def is_effectively_empty(filters) -> bool:
     if not filters:
     if not filters:
         return True
         return True
     if filters in ({"AND": []}, {"OR": []}):
     if filters in ({"AND": []}, {"OR": []}):
         return True
         return True
     return False
     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,
                 query_embeddings=vectors,
                 n_results=limit,
                 n_results=limit,
                 where=NOOP_WHERE,
                 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:
 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:
     if not items:
         return 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}")
         print(f"[reranker] unavailable, skipping rerank: {exc}")
         return items[:top_k]
         return items[:top_k]
 
 
-    # Re-attach original mem0 metadata by matching text
     text_to_meta = {r.get("memory", ""): r for r in items}
     text_to_meta = {r.get("memory", ""): r for r in items}
     merged = []
     merged = []
     for r in reranked:
     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"]})
             merged.append({**meta, "rerank_score": r["score"]})
     return merged
     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()
     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")
     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()
     data = await req.json()
     query = (data.get("query") or "").strip()
     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))
     limit = int(data.get("limit", 5))
 
 
     if not query:
     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)
     fetch_k = max(limit * 3, 15)
     try:
     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:
     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()
         q = query.lower()
         items = [r for r in items if q in r.get("memory", "").lower()]
         items = [r for r in items if q in r.get("memory", "").lower()]
         result = {"results": items}
         result = {"results": items}
 
 
     items = result.get("results", [])
     items = result.get("results", [])
-
-    # 2. Rerank
     items = rerank_results(query, items, top_k=limit)
     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()
     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:
     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))
     limit = int(data.get("limit", 5))
-    print("recent payload:", data, "user_id:", user_id)
+
     try:
     try:
-        results = memory.get_all(user_id=user_id)
+        results = mem.get_all(user_id=user_id)
     except Exception:
     except Exception:
-        results = memory.search(query="*", user_id=user_id)
+        results = mem.search(query="recent", user_id=user_id)
+
     items = results.get("results", [])
     items = results.get("results", [])
     items = sorted(items, key=lambda r: r.get("created_at", ""), reverse=True)
     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 sys
 import requests
 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]
 name = sys.argv[1]
-
 requests.delete(f"{base}/collections/{name}")
 requests.delete(f"{base}/collections/{name}")
 requests.post(f"{base}/collections", json={"name": 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"