@hiperplano/aluy-cli 1.0.0-rc.32 → 1.0.0-rc.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ aluy-mem0-server — servidor HTTP stdlib para o Mem0 OSS local.
4
+
5
+ EST-1138 · ADR-0123 §8-emenda E2/E3 · C4.
6
+ Implementa EXATAMENTE os endpoints que `mem0-memory-engine.ts` chama.
7
+
8
+ Bind: 127.0.0.1 (loopback, CA-G2-6), porta via --port (default 11435).
9
+ Uma instância de Memory() — criada UMA vez no boot.
10
+
11
+ Endpoints:
12
+ GET /health → 200 {"ok":true}
13
+ POST /v1/memories/ → mem.add(...) → {"id":"..."}
14
+ GET /v1/memories/?... → mem.search(...) → {"results":[...]}
15
+ GET /v1/users/ → lista scopes → {"users":[...]}
16
+ DELETE /v1/memories/?... → mem.delete_all(...) → 204
17
+
18
+ Dependências: mem0ai, chromadb (já no venv provisionado).
19
+ Embedder: nomic-embed-text via Ollama em 127.0.0.1:11434.
20
+ Store: chromadb em ~/.aluy/memory.
21
+ """
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import signal
27
+ import sys
28
+ import traceback
29
+ from http.server import HTTPServer, BaseHTTPRequestHandler
30
+ from pathlib import Path
31
+ from socketserver import ThreadingMixIn
32
+ from typing import Any
33
+
34
+ # ── Constantes ────────────────────────────────────────────────────────────
35
+ DEFAULT_PORT = 11435
36
+ DEFAULT_HOST = "127.0.0.1"
37
+ ALUY_MEMORY_DIR = os.path.expanduser("~/.aluy/memory")
38
+ OLLAMA_BASE_URL = "http://127.0.0.1:11434"
39
+ EMBEDDER_MODEL = "nomic-embed-text"
40
+ # LLM local p/ extração de fatos (mem0 add infer=True). SEM isto o mem0 cai no
41
+ # default OpenAI → 401. Local-first: tudo via Ollama loopback, zero credencial.
42
+ LLM_MODEL = "qwen2.5:0.5b"
43
+ USERS_FILE = os.path.join(ALUY_MEMORY_DIR, "aluy_users.json")
44
+
45
+
46
+ # ── Mem0 config ───────────────────────────────────────────────────────────
47
+ def _build_mem0_config() -> dict[str, Any]:
48
+ """Monta a config do mem0ai com paths locais absolutos."""
49
+ return {
50
+ # history em ~/.aluy/memory (não no default ~/.mem0): TODO o estado do mem0 fica num
51
+ # único lugar ⇒ limpar ~/.aluy/memory no self-heal recria TUDO fresco. Antes o history
52
+ # ia p/ ~/.mem0/history.db e, de uma versão antiga, dava `no such column: prev_value`.
53
+ "history_db_path": os.path.join(ALUY_MEMORY_DIR, "history.db"),
54
+ "vector_store": {
55
+ "provider": "chroma",
56
+ "config": {
57
+ "collection_name": "mem0",
58
+ "path": ALUY_MEMORY_DIR,
59
+ },
60
+ },
61
+ "embedder": {
62
+ "provider": "ollama",
63
+ "config": {
64
+ "model": EMBEDDER_MODEL,
65
+ "ollama_base_url": OLLAMA_BASE_URL,
66
+ },
67
+ },
68
+ "llm": {
69
+ "provider": "ollama",
70
+ "config": {
71
+ "model": LLM_MODEL,
72
+ "ollama_base_url": OLLAMA_BASE_URL,
73
+ },
74
+ },
75
+ }
76
+
77
+
78
+ # ── Tracking de usuários ──────────────────────────────────────────────────
79
+ def _load_users() -> dict[str, dict[str, Any]]:
80
+ """Carrega o tracking de usuários do disco (JSON simples)."""
81
+ try:
82
+ if os.path.exists(USERS_FILE):
83
+ with open(USERS_FILE, "r", encoding="utf-8") as f:
84
+ return json.load(f)
85
+ except Exception:
86
+ pass
87
+ return {}
88
+
89
+
90
+ def _save_users(users: dict[str, dict[str, Any]]) -> None:
91
+ """Persiste o tracking de usuários."""
92
+ os.makedirs(ALUY_MEMORY_DIR, mode=0o700, exist_ok=True)
93
+ tmp = USERS_FILE + ".tmp"
94
+ with open(tmp, "w", encoding="utf-8") as f:
95
+ json.dump(users, f)
96
+ os.replace(tmp, USERS_FILE)
97
+ os.chmod(USERS_FILE, 0o600)
98
+
99
+
100
+ def _track_user(users: dict[str, dict[str, Any]], user_id: str) -> None:
101
+ """Registra um user_id se ainda não existe no tracking."""
102
+ if user_id not in users:
103
+ users[user_id] = {"created_at": None}
104
+ _save_users(users)
105
+
106
+
107
+ def _untrack_user(users: dict[str, dict[str, Any]], user_id: str) -> None:
108
+ """Remove um user_id do tracking."""
109
+ if user_id in users:
110
+ del users[user_id]
111
+ _save_users(users)
112
+
113
+
114
+ # ── Handler HTTP ──────────────────────────────────────────────────────────
115
+ class Mem0Handler(BaseHTTPRequestHandler):
116
+ """
117
+ Handler HTTP que traduz requests REST ↔ chamadas ao Memory() do mem0ai.
118
+ """
119
+
120
+ # Injectados pelo servidor (antes de start).
121
+ memory: Any = None
122
+ users: dict[str, dict[str, Any]] = {}
123
+
124
+ # ── Helpers ────────────────────────────────────────────────────────
125
+
126
+ def _send_json(self, status: int, body: Any) -> None:
127
+ self.send_response(status)
128
+ self.send_header("Content-Type", "application/json; charset=utf-8")
129
+ self.send_header("Access-Control-Allow-Origin", "*")
130
+ self.end_headers()
131
+ self.wfile.write(json.dumps(body, default=str).encode("utf-8"))
132
+
133
+ def _send_no_content(self) -> None:
134
+ self.send_response(204)
135
+ self.send_header("Access-Control-Allow-Origin", "*")
136
+ self.end_headers()
137
+
138
+ def _read_body(self) -> dict[str, Any]:
139
+ length = int(self.headers.get("Content-Length", "0"))
140
+ if length == 0:
141
+ return {}
142
+ raw = self.rfile.read(length)
143
+ return json.loads(raw)
144
+
145
+ def _parse_qs(self) -> dict[str, str]:
146
+ """Extrai query params da URL (simples, sem dependência)."""
147
+ path = self.path
148
+ qs: dict[str, str] = {}
149
+ if "?" in path:
150
+ query_string = path.split("?", 1)[1]
151
+ for pair in query_string.split("&"):
152
+ if "=" in pair:
153
+ k, v = pair.split("=", 1)
154
+ qs[k] = v
155
+ elif pair:
156
+ qs[pair] = ""
157
+ return qs
158
+
159
+ # ── Routing ────────────────────────────────────────────────────────
160
+
161
+ def _route_path(self) -> str:
162
+ """Devolve o path sem query string."""
163
+ return self.path.split("?", 1)[0].rstrip("/")
164
+
165
+ def do_GET(self) -> None:
166
+ path = self._route_path()
167
+ try:
168
+ if path == "/health":
169
+ self._handle_health()
170
+ elif path == "/v1/memories":
171
+ self._handle_search()
172
+ elif path == "/v1/users":
173
+ self._handle_list_users()
174
+ else:
175
+ self._send_json(404, {"error": "not found"})
176
+ except Exception:
177
+ traceback.print_exc()
178
+ self._send_json(500, {"error": traceback.format_exc()})
179
+
180
+ def do_POST(self) -> None:
181
+ path = self._route_path()
182
+ try:
183
+ if path == "/v1/memories":
184
+ self._handle_add()
185
+ else:
186
+ self._send_json(404, {"error": "not found"})
187
+ except Exception:
188
+ traceback.print_exc()
189
+ self._send_json(500, {"error": traceback.format_exc()})
190
+
191
+ def do_DELETE(self) -> None:
192
+ path = self._route_path()
193
+ try:
194
+ if path == "/v1/memories":
195
+ self._handle_delete()
196
+ else:
197
+ self._send_json(404, {"error": "not found"})
198
+ except Exception:
199
+ traceback.print_exc()
200
+ self._send_json(500, {"error": traceback.format_exc()})
201
+
202
+ def do_OPTIONS(self) -> None:
203
+ self.send_response(200)
204
+ self.send_header("Access-Control-Allow-Origin", "*")
205
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
206
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
207
+ self.end_headers()
208
+
209
+ # ── Handlers ───────────────────────────────────────────────────────
210
+
211
+ def _handle_health(self) -> None:
212
+ """GET /health → 200 {"ok":true} (handshake do boot)."""
213
+ self._send_json(200, {"ok": True})
214
+
215
+ def _handle_add(self) -> None:
216
+ """POST /v1/memories/ → mem.add(...) → {"id":"..."}."""
217
+ body = self._read_body()
218
+ user_id = body.get("user_id", "default")
219
+ messages = body.get("messages", [])
220
+ metadata = body.get("metadata")
221
+
222
+ if not messages:
223
+ self._send_json(400, {"error": "messages é obrigatório"})
224
+ return
225
+
226
+ # infer=False: armazena o conteúdo DIRETO (embed+store), sem extração por LLM.
227
+ # Semântica da porta MemoryEngine = "guarda o que recebe"; e o judge local
228
+ # (qwen2.5:0.5b) é fraco demais p/ a extração de fatos do mem0 (retornaria vazio).
229
+ result = self.memory.add(messages, user_id=user_id, metadata=metadata, infer=False)
230
+
231
+ # mem0ai 2.0.7 retorna {"results":[{"id",...}]}; o TS espera {"id": "..."}.
232
+ first_id = None
233
+ if isinstance(result, dict):
234
+ res_list = result.get("results")
235
+ if isinstance(res_list, list) and res_list:
236
+ first_id = res_list[0].get("id")
237
+ else:
238
+ first_id = result.get("id")
239
+ elif isinstance(result, list) and result:
240
+ first_id = result[0].get("id")
241
+
242
+ # Track user
243
+ _track_user(self.users, user_id)
244
+
245
+ self._send_json(200, {"id": first_id})
246
+
247
+ def _handle_search(self) -> None:
248
+ """GET /v1/memories/?user_id&query&limit → mem.search(...) → {"results":[...]}."""
249
+ qs = self._parse_qs()
250
+ user_id = qs.get("user_id", "default")
251
+ query = qs.get("query", "")
252
+ limit = int(qs.get("limit", "10"))
253
+
254
+ # mem0ai 2.0.7: entity params via filters=, paginação via top_k= (não user_id/limit).
255
+ resp = self.memory.search(query, filters={"user_id": user_id}, top_k=limit)
256
+
257
+ # mem0ai.search retorna {"results": [...]}
258
+ results = resp.get("results", []) if isinstance(resp, dict) else []
259
+
260
+ self._send_json(200, {"results": results})
261
+
262
+ def _handle_list_users(self) -> None:
263
+ """GET /v1/users/ → lista scopes com memory_count."""
264
+ # Reconcilia tracking file com o estado real (get_all de cada user).
265
+ users_list = []
266
+ for user_id in list(self.users.keys()):
267
+ try:
268
+ # mem0ai 2.0.7: get_all usa filters= e retorna {"results": [...]}.
269
+ all_mems = self.memory.get_all(filters={"user_id": user_id})
270
+ except Exception:
271
+ # F100 — erro TRANSITÓRIO no get_all NÃO é "scope vazio". Conflar os dois
272
+ # (count=0 ⇒ untrack) DESTRÓI o tracking de um scope COM memórias num hiccup
273
+ # do backend — o scope some da lista (memórias intactas, mas invisíveis) até
274
+ # o próximo add. CA-MA8: degrada, não destrói estado. Preserva o tracking e
275
+ # OMITE só desta resposta (reaparece no próximo list bem-sucedido).
276
+ continue
277
+
278
+ if isinstance(all_mems, dict):
279
+ count = len(all_mems.get("results", []))
280
+ elif isinstance(all_mems, list):
281
+ count = len(all_mems)
282
+ else:
283
+ count = 0
284
+
285
+ created_at = self.users[user_id].get("created_at")
286
+ entry: dict[str, Any] = {
287
+ "user_id": user_id,
288
+ "memory_count": count,
289
+ }
290
+ if created_at:
291
+ entry["created_at"] = created_at
292
+
293
+ # Vazio COMPROVADO (get_all OK retornando 0) ⇒ foi deletado: untrack.
294
+ if count > 0:
295
+ users_list.append(entry)
296
+ else:
297
+ _untrack_user(self.users, user_id)
298
+
299
+ self._send_json(200, {"users": users_list})
300
+
301
+ def _handle_delete(self) -> None:
302
+ """DELETE /v1/memories/?user_id=X → mem.delete_all(user_id=X)."""
303
+ qs = self._parse_qs()
304
+ user_id = qs.get("user_id", "default")
305
+
306
+ self.memory.delete_all(user_id=user_id)
307
+ _untrack_user(self.users, user_id)
308
+
309
+ self._send_no_content()
310
+
311
+ # Suprime logs por request (stderr polui a TUI).
312
+ def log_message(self, format: str, *args: Any) -> None:
313
+ pass
314
+
315
+
316
+ # ── ThreadingHTTPServer ───────────────────────────────────────────────────
317
+ class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
318
+ """HTTPServer com thread por request (stdlib apenas)."""
319
+ daemon_threads = True
320
+
321
+
322
+ # ── Main ──────────────────────────────────────────────────────────────────
323
+ def _reset_incompatible_stores() -> None:
324
+ """Move os stores de memória p/ backup, p/ recriar frescos. Chamado quando a versão atual
325
+ do chromadb/mem0 não lê o store de uma versão anterior (KeyError '_type' no chromadb /
326
+ sqlite 'no such column' no history). Best-effort — nunca lança. Limpa OS DOIS lugares:
327
+ `~/.aluy/memory` (chromadb + history novo) E `~/.mem0/history.db` (history default ANTIGO,
328
+ de antes de consolidarmos o path — senão a 2ª tentativa ainda bate nele)."""
329
+ import time
330
+ try:
331
+ ts = int(time.time())
332
+ except Exception: # noqa: BLE001
333
+ ts = 0
334
+ targets = [ALUY_MEMORY_DIR, os.path.expanduser("~/.mem0/history.db")]
335
+ for path in targets:
336
+ if not os.path.exists(path):
337
+ continue
338
+ try:
339
+ os.rename(path, f"{path}.incompat-{ts}")
340
+ except OSError:
341
+ # rename pode falhar (cross-device/permissão) ⇒ apaga o conteúdo problemático.
342
+ import shutil
343
+ try:
344
+ if os.path.isdir(path):
345
+ shutil.rmtree(path, ignore_errors=True)
346
+ else:
347
+ os.remove(path)
348
+ except Exception: # noqa: BLE001
349
+ pass
350
+
351
+
352
+ def main() -> None:
353
+ parser = argparse.ArgumentParser(description="aluy-mem0-server")
354
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT,
355
+ help=f"Porta loopback (default: {DEFAULT_PORT})")
356
+ parser.add_argument("--host", type=str, default=DEFAULT_HOST,
357
+ help=f"Host (default: {DEFAULT_HOST})")
358
+ args = parser.parse_args()
359
+
360
+ # Import lazy — só depois do venv estar configurado.
361
+ try:
362
+ from mem0 import Memory # type: ignore[import-untyped]
363
+ except ImportError:
364
+ print("ERRO: mem0ai não instalado. Rode `aluy init` primeiro.", file=sys.stderr)
365
+ sys.exit(1)
366
+
367
+ # Garante o dir de store com perms corretas.
368
+ os.makedirs(ALUY_MEMORY_DIR, mode=0o700, exist_ok=True)
369
+
370
+ # Instancia Memory() UMA vez. SELF-HEAL de upgrade: um store gravado por uma versão
371
+ # ANTERIOR do chromadb/mem0 pode ser ilegível pela atual (ex.: chromadb `KeyError: '_type'`
372
+ # na config da collection; sqlite `no such column: prev_value` no history). Em vez de
373
+ # CRASHAR (deixando o sidecar "fora" sem causa visível), movemos os stores antigos p/
374
+ # backup e RECRIAMOS frescos. Perde-se memória antiga (já ilegível de qualquer forma),
375
+ # mas o serviço SOBE. Achado ao vivo na máquina do dono (dois stores de jun/antigo).
376
+ config = _build_mem0_config()
377
+ try:
378
+ memory = Memory.from_config(config)
379
+ except Exception as exc: # noqa: BLE001 — qualquer erro ao ler o store antigo
380
+ if os.environ.get("ALUY_MEM0_RESET_DONE") == "1":
381
+ # JÁ resetamos uma vez e AINDA falha ⇒ não é store velho; propaga (não loopa).
382
+ raise
383
+ sys.stderr.write(
384
+ f"aluy-mem0: store incompatível ({type(exc).__name__}: {exc}); "
385
+ "movendo p/ backup e reiniciando fresco…\n"
386
+ )
387
+ _reset_incompatible_stores()
388
+ # RE-EXEC em vez de retry in-process: o chromadb CACHEIA o client/conexão por
389
+ # processo, então um 2º `from_config` no MESMO processo reusa o store velho. Reiniciar
390
+ # o processo garante chromadb/mem0 FRESCOS. `ALUY_MEM0_RESET_DONE` evita loop infinito.
391
+ # CROSS-SO: o supervisor health-checa a PORTA (não o PID), então trocar de processo é OK
392
+ # em Linux/macOS/Windows. `os.execv` no Windows pode falhar em casos raros (path/quoting)
393
+ # — então caímos p/ um respawn via subprocess + saída limpa; e, em último caso, p/ um
394
+ # retry in-process (melhor um chromadb possivelmente cacheado do que o sidecar morto).
395
+ os.environ["ALUY_MEM0_RESET_DONE"] = "1"
396
+ try:
397
+ os.execv(sys.executable, [sys.executable, *sys.argv])
398
+ except Exception: # noqa: BLE001 — execv indisponível/falhou (ex.: Windows)
399
+ try:
400
+ import subprocess
401
+
402
+ subprocess.Popen([sys.executable, *sys.argv], close_fds=True)
403
+ os._exit(0) # encerra ESTE processo; o novo assume a porta
404
+ except Exception: # noqa: BLE001 — sem respawn possível: tenta in-process
405
+ os.makedirs(ALUY_MEMORY_DIR, mode=0o700, exist_ok=True)
406
+ memory = Memory.from_config(config)
407
+ else:
408
+ return # inalcançável após _exit — só p/ o type-checker
409
+ else:
410
+ return # inalcançável (execv substitui o processo) — só p/ o type-checker
411
+
412
+ # Injeta no handler.
413
+ Mem0Handler.memory = memory
414
+ Mem0Handler.users = _load_users()
415
+
416
+ # Cria servidor com bind explícito em loopback.
417
+ server = ThreadingHTTPServer((args.host, args.port), Mem0Handler)
418
+
419
+ # Graceful shutdown no SIGTERM (boot-supervisor mata com SIGTERM).
420
+ def _shutdown(signum: int, frame: Any) -> None:
421
+ print(f"\naluy-mem0-server: recebido sinal {signum}, desligando...", file=sys.stderr)
422
+ server.shutdown()
423
+ signal.signal(signal.SIGTERM, _shutdown)
424
+ signal.signal(signal.SIGINT, _shutdown)
425
+
426
+ print(f"aluy-mem0-server: escutando em http://{args.host}:{args.port}", file=sys.stderr)
427
+
428
+ try:
429
+ server.serve_forever()
430
+ except KeyboardInterrupt:
431
+ pass
432
+ finally:
433
+ server.server_close()
434
+ print("aluy-mem0-server: parado.", file=sys.stderr)
435
+
436
+
437
+ if __name__ == "__main__":
438
+ main()