342 lines
12 KiB
Python
342 lines
12 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; }
|
|
.copy-btn { background: none; border: 1px solid #ccc; color: #999; cursor: pointer; font-size: .8rem; padding: .15rem .5rem; border-radius: 4px; }
|
|
.copy-btn:hover { background: #ddd; border-color: #bbb; color: #333; }
|
|
.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]
|
|
# Build the HTML card for each entry
|
|
card = (
|
|
f'<div class="entry-header"><span class="entry-id">{_html.escape(short_id)}</span>'
|
|
f'<button class="copy-btn" type="button" onclick="navigator.clipboard.writeText(\'{_html.escape(e["id"])}\')">copy id</button>'
|
|
f'<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"))
|