commit f4a39710fef1ec810c57c927439c02c5965f982c Author: williams Date: Fri Jun 12 07:00:23 2026 +0000 Initial import: mem0 bridge - Qdrant+FastEmbed memory server diff --git a/mem0-bridge.py b/mem0-bridge.py new file mode 100644 index 0000000..67b7cda --- /dev/null +++ b/mem0-bridge.py @@ -0,0 +1,338 @@ +"""Mem0 bridge API — HTTP server wrapping the Qdrant+FastEmbed backend. + +Usage (CLI): + python3 /opt/mem0-bridge.py add + python3 /opt/mem0-bridge.py search + +Usage (HTTP): + gunicorn -b 0.0.0.0:8000 mem0-api:app +""" + +import json +import os +import sys +import uuid + +import flask +from urllib.parse import quote +from qdrant_client import QdrantClient +from qdrant_client.http import models +from fastembed import TextEmbedding + +COLLECTION = "mem0" +QDRANT_PATH = "/var/lib/nash/qdrant" +MODEL_NAME = "BAAI/bge-small-en-v1.5" + +_model = None +_client = None + + +def _get_model(): + global _model + if _model is None: + _model = TextEmbedding(model_name=MODEL_NAME) + return _model + + +def _get_client(): + global _client + if _client is None: + _client = QdrantClient(path=QDRANT_PATH) + return _client + + +def _ensure_collection(client, dims=384): + collections = client.get_collections().collections + if not any(c.name == COLLECTION for c in collections): + client.create_collection( + collection_name=COLLECTION, + vectors_config=models.VectorParams(size=dims, distance=models.Distance.COSINE), + ) + + +def memory_add(text, user_id="bridge-user"): + model = _get_model() + client = _get_client() + _ensure_collection(client) + + emb = list(model.embed(text))[0] + point_id = str(uuid.uuid4()) + client.upsert( + collection_name=COLLECTION, + points=[ + models.PointStruct( + id=point_id, + vector=emb.tolist(), + payload={"text": text, "user_id": user_id}, + ) + ], + ) + return {"id": point_id, "text": text, "user_id": user_id} + + +def memory_search(query, user_id="bridge-user", limit=5): + model = _get_model() + client = _get_client() + _ensure_collection(client) + + emb = list(model.embed(query))[0] + results = client.query_points( + collection_name=COLLECTION, + query=emb.tolist(), + query_filter=models.Filter( + must=[models.FieldCondition(key="user_id", match=models.MatchValue(value=user_id))] + ), + limit=limit, + ) + return [ + {"id": p.id, "text": p.payload.get("text", ""), "user_id": p.payload.get("user_id", ""), "score": p.score} + for p in results.points + ] + + +# ---- CLI ---- +def main(): + user_id = os.environ.get("MEM0_USER_ID", "bridge-user") + + if len(sys.argv) < 3: + print(__doc__.strip(), file=sys.stderr) + sys.exit(1) + + command = sys.argv[1] + text = sys.argv[2] + + match command: + case "add": + result = memory_add(text, user_id) + print(json.dumps(result, indent=2)) + case "search": + results = memory_search(text, user_id) + print(json.dumps(results, indent=2)) + case _: + print(f"ERROR: unknown command {command!r}", file=sys.stderr) + sys.exit(1) + + +# ---- HTTP ---- +app = flask.Flask(__name__) + + +@app.route("/add", methods=["POST"]) +def api_add(): + data = flask.request.get_json(silent=True) or {} + text = data.get("text", "") + if not text: + return {"error": "text is required"}, 400 + user_id = data.get("user_id", os.environ.get("MEM0_USER_ID", "bridge-user")) + result = memory_add(text, user_id) + return flask.jsonify(result) + + +@app.route("/search", methods=["POST"]) +def api_search(): + data = flask.request.get_json(silent=True) or {} + query = data.get("query", "") + if not query: + return {"error": "query is required"}, 400 + user_id = data.get("user_id", os.environ.get("MEM0_USER_ID", "bridge-user")) + limit = data.get("limit", 5) + results = memory_search(query, user_id, limit) + return flask.jsonify(results) + + +@app.route("/health", methods=["GET"]) +def api_health(): + return flask.jsonify({"status": "ok"}) + + +if __name__ == "__main__": + main() + + +def memory_scroll(user_id="bridge-user", limit=100, offset=None): + """Scroll through all points for a user (no embedding needed).""" + client = _get_client() + _ensure_collection(client) + scroll_filter = models.Filter( + must=[models.FieldCondition(key="user_id", match=models.MatchValue(value=user_id))] + ) if user_id else None + records, next_offset = client.scroll( + collection_name=COLLECTION, + scroll_filter=scroll_filter, + limit=limit, + offset=offset, + with_payload=True, + with_vectors=False, + ) + result = [ + {"id": p.id, "text": p.payload.get("text", ""), "user_id": p.payload.get("user_id", "")} + for p in records + ] + return {"entries": result, "next_offset": next_offset} + + +def memory_delete(point_id: str): + """Delete a point by ID.""" + client = _get_client() + client.delete( + collection_name=COLLECTION, + points_selector=models.PointIdsList( + points=[point_id], + ), + ) + return {"deleted": point_id} + + +@app.route("/scroll", methods=["POST"]) +def api_scroll(): + data = flask.request.get_json(silent=True) or {} + user_id = data.get("user_id", os.environ.get("MEM0_USER_ID", "bridge-user")) + limit = data.get("limit", 100) + offset = data.get("offset") + result = memory_scroll(user_id, limit, offset) + return flask.jsonify(result) + + +@app.route("/delete", methods=["POST"]) +def api_delete(): + data = flask.request.get_json(silent=True) or {} + point_id = data.get("id", "") + if not point_id: + return {"error": "id is required"}, 400 + result = memory_delete(point_id) + return flask.jsonify(result) + + +# --------------------------------------------------------------------------- +# Web UI +# --------------------------------------------------------------------------- + +import html as _html +import re as _re + + +def _render_markdown(text: str) -> str: + """Minimal markdown→HTML renderer (stdlib only).""" + escaped = _html.escape(text) + escaped = _re.sub(r"`([^`]+)`", r"\1", escaped) + escaped = _re.sub(r"\*\*(\S[^*]*\S)\*\*", r"\1", escaped) + escaped = _re.sub(r"\*(\S[^*]*\S)\*", r"\1", escaped) + escaped = _re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', escaped) + return escaped + + +UI_HTML = r""" + + + + +nash memory + + + +

nash memory $COUNT entries

+ +
+ + +
+$ENTRIES + +""" + + +@app.route("/", methods=["GET"]) +def ui_index(): + fmt = flask.request.args.get("format", "html") + result = memory_scroll(user_id="bridge-user", limit=10000) + entries = result["entries"] + + if fmt == "raw": + lines = [f"nash memory — {len(entries)} entries\n"] + for e in entries: + lines.append(f"--- {e['id']} ---") + lines.append(e["text"]) + lines.append("") + text = "\n".join(lines) + return flask.Response(text, mimetype="text/plain") + + html_active = "active" if fmt != "raw" else "" + raw_active = "active" if fmt == "raw" else "" + + cards = [] + for e in entries: + rendered = _render_markdown(e["text"]) + short_id = e["id"][:8] + card = ( + '
' + f'
{_html.escape(short_id)}' + '
' + '
' + f'
{rendered}
' + "
" + ) + cards.append(card) + + body = UI_HTML\ + .replace("$COUNT", str(len(entries)))\ + .replace("$HTML_ACTIVE", html_active)\ + .replace("$RAW_ACTIVE", raw_active)\ + .replace("$ENTRIES", "\n".join(cards)) + + return flask.Response(body, mimetype="text/html") + + +@app.route("/-/delete/", methods=["POST"]) +def ui_delete(point_id): + memory_delete(point_id) + return flask.redirect(flask.url_for("ui_index")) + + +@app.route("/-/add", methods=["POST"]) +def ui_add(): + text = flask.request.form.get("text", "").strip() + if text: + memory_add(text, user_id="bridge-user") + return flask.redirect(flask.url_for("ui_index"))