Files
nash-mem0/mem0-bridge.py
T

339 lines
11 KiB
Python

"""Mem0 bridge API — HTTP server wrapping the Qdrant+FastEmbed backend.
Usage (CLI):
python3 /opt/mem0-bridge.py add <text>
python3 /opt/mem0-bridge.py search <query>
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"<code>\1</code>", escaped)
escaped = _re.sub(r"\*\*(\S[^*]*\S)\*\*", r"<strong>\1</strong>", escaped)
escaped = _re.sub(r"\*(\S[^*]*\S)\*", r"<em>\1</em>", escaped)
escaped = _re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', escaped)
return escaped
UI_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>nash memory</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #222; padding: 1.5rem; }
h1 { font-size: 1.3rem; margin-bottom: 1rem; display: flex; align-items: center; gap: 1rem; }
.count { font-size: .85rem; color: #666; font-weight: normal; }
.toolbar { margin-bottom: 1rem; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.toolbar a { text-decoration: none; padding: .35rem .8rem; border-radius: 6px; font-size: .85rem; }
.toolbar a.active { background: #0066cc; color: #fff; }
.toolbar a:not(.active) { background: #ddd; color: #333; }
.toolbar a:hover:not(.active) { background: #ccc; }
.entry { background: #fff; border-radius: 8px; padding: 1rem; margin-bottom: .75rem; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.entry-id { font-size: .75rem; color: #999; margin-bottom: .35rem; }
.entry-text { line-height: 1.5; word-break: break-word; }
.entry-text code { background: #f0f0f0; padding: .1rem .3rem; border-radius: 3px; font-size: .9em; }
.entry-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .35rem; }
.del-btn { background: none; border: 1px solid #ccc; color: #999; cursor: pointer; font-size: .8rem; padding: .15rem .5rem; border-radius: 4px; }
.del-btn:hover { background: #e55; border-color: #e55; color: #fff; }
.entry-text strong { font-weight: 600; }
.entry-text em { font-style: italic; }
.entry-text a { color: #06c; }
.entry-raw { font-family: monospace; white-space: pre-wrap; font-size: .85rem; line-height: 1.4; }
.empty { color: #888; text-align: center; margin-top: 3rem; }
.add-form { display: flex; gap: .5rem; margin-bottom: 1rem; align-items: flex-start; }
.add-form textarea { flex: 1; padding: .5rem; border: 1px solid #ccc; border-radius: 6px; font: inherit; font-size: .9rem; resize: vertical; background: #fff; color: #222; }
.add-form button { padding: .5rem 1rem; border: none; border-radius: 6px; background: #0066cc; color: #fff; cursor: pointer; font-size: .9rem; }
.add-form button:hover { background: #0055aa; }
pre { margin: 0; }
@media (prefers-color-scheme: dark) {
body { background: #1a1a1a; color: #ddd; }
.entry { background: #2a2a2a; }
.entry-text code { background: #333; }
.del-btn { border-color: #555; color: #777; }
.del-btn:hover { background: #c33; border-color: #c33; }
.add-form textarea { background: #333; color: #ddd; border-color: #555; }
.add-form button { background: #3388ee; }
.toolbar a.active { background: #3388ee; }
.toolbar a:not(.active) { background: #444; color: #ddd; }
.toolbar a:hover:not(.active) { background: #555; }
}
</style>
</head>
<body>
<h1>nash memory <span class="count">$COUNT entries</span></h1>
<div class="toolbar">
<a href="?format=html" class="$HTML_ACTIVE">rendered</a>
<a href="?format=raw" class="$RAW_ACTIVE">raw markdown</a>
</div>
<form method="post" action="/-/add" class="add-form">
<textarea name="text" rows="2" placeholder="Add a memory entry…" required></textarea>
<button type="submit">add</button>
</form>
$ENTRIES
</body>
</html>"""
@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 = (
'<div class="entry">'
f'<div class="entry-header"><span class="entry-id">{_html.escape(short_id)}</span>'
'<form method="post" action="/-/delete/' + _html.escape(e['id']) + '" style="margin:0" onsubmit="return confirm(\'Delete this entry?\')">'
'<button class="del-btn" type="submit">delete</button></form></div>'
f'<div class="entry-text">{rendered}</div>'
"</div>"
)
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/<point_id>", 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"))