@hiperplano/aluy-cli 1.0.0-rc.9 → 1.0.0-rc.91

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.
package/README.md CHANGED
@@ -41,7 +41,7 @@ opcionais. Funciona em **Linux, macOS e Windows** — o terminal recomendado é
41
41
  aluy # sessão interativa (TUI)
42
42
  aluy "refatore o módulo X" # dá um objetivo direto e acompanha o agente trabalhar
43
43
  aluy -p "liste os TODOs" # modo headless (one-shot), ideal p/ scripts/CI
44
- aluy --resume <id> # retoma uma conversa anterior (o id aparece ao sair)
44
+ aluy --resume <id|nome> # retoma uma conversa anterior (id ao sair, ou o nome do /rename)
45
45
  aluy --continue # retoma a sessão mais recente deste diretório
46
46
  ```
47
47
 
@@ -69,6 +69,7 @@ Dentro da sessão, **slash-commands** controlam tudo sem sair do fluxo:
69
69
  Filesystem, Memory, …).
70
70
  - **Complementos opcionais** (modo turbo) — memória persistente, modelos locais via
71
71
  Ollama e gestão de contexto, instaláveis no onboard ou depois com `aluy bootstrap`.
72
+ Guia completo (instalação, problemas comuns, instalação manual): [docs/turbo.md](docs/turbo.md).
72
73
 
73
74
  ## Configuração
74
75
 
@@ -0,0 +1,483 @@
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 CONFIG-DRIVEN: o boot passa `ALUY_MEM0_EMBEDDER` (escolhido na instalação); default
40
+ # bge-m3 (multilíngue, forte). Trocar o embedder muda a DIMENSÃO do vetor (bge-m3/mxbai=1024,
41
+ # nomic=768) ⇒ o store chromadb antigo fica incompatível e o self-heal o reseta no 1º boot.
42
+ EMBEDDER_MODEL = os.environ.get("ALUY_MEM0_EMBEDDER", "bge-m3").strip() or "bge-m3"
43
+ # LLM local p/ extração de fatos (mem0 add infer=True). SEM isto o mem0 cai no
44
+ # default OpenAI → 401. Local-first: tudo via Ollama loopback, zero credencial.
45
+ LLM_MODEL = "qwen2.5:0.5b"
46
+ USERS_FILE = os.path.join(ALUY_MEMORY_DIR, "aluy_users.json")
47
+
48
+
49
+ # ── Mem0 config ───────────────────────────────────────────────────────────
50
+ def _build_mem0_config() -> dict[str, Any]:
51
+ """Monta a config do mem0ai com paths locais absolutos."""
52
+ return {
53
+ # history em ~/.aluy/memory (não no default ~/.mem0): TODO o estado do mem0 fica num
54
+ # único lugar ⇒ limpar ~/.aluy/memory no self-heal recria TUDO fresco. Antes o history
55
+ # ia p/ ~/.mem0/history.db e, de uma versão antiga, dava `no such column: prev_value`.
56
+ "history_db_path": os.path.join(ALUY_MEMORY_DIR, "history.db"),
57
+ "vector_store": {
58
+ "provider": "chroma",
59
+ "config": {
60
+ "collection_name": "mem0",
61
+ "path": ALUY_MEMORY_DIR,
62
+ },
63
+ },
64
+ "embedder": {
65
+ "provider": "ollama",
66
+ "config": {
67
+ "model": EMBEDDER_MODEL,
68
+ "ollama_base_url": OLLAMA_BASE_URL,
69
+ },
70
+ },
71
+ "llm": {
72
+ "provider": "ollama",
73
+ "config": {
74
+ "model": LLM_MODEL,
75
+ "ollama_base_url": OLLAMA_BASE_URL,
76
+ },
77
+ },
78
+ }
79
+
80
+
81
+ # ── Tracking de usuários ──────────────────────────────────────────────────
82
+ def _load_users() -> dict[str, dict[str, Any]]:
83
+ """Carrega o tracking de usuários do disco (JSON simples)."""
84
+ try:
85
+ if os.path.exists(USERS_FILE):
86
+ with open(USERS_FILE, "r", encoding="utf-8") as f:
87
+ return json.load(f)
88
+ except Exception:
89
+ pass
90
+ return {}
91
+
92
+
93
+ def _save_users(users: dict[str, dict[str, Any]]) -> None:
94
+ """Persiste o tracking de usuários."""
95
+ os.makedirs(ALUY_MEMORY_DIR, mode=0o700, exist_ok=True)
96
+ tmp = USERS_FILE + ".tmp"
97
+ with open(tmp, "w", encoding="utf-8") as f:
98
+ json.dump(users, f)
99
+ os.replace(tmp, USERS_FILE)
100
+ os.chmod(USERS_FILE, 0o600)
101
+
102
+
103
+ def _track_user(users: dict[str, dict[str, Any]], user_id: str) -> None:
104
+ """Registra um user_id se ainda não existe no tracking."""
105
+ if user_id not in users:
106
+ users[user_id] = {"created_at": None}
107
+ _save_users(users)
108
+
109
+
110
+ def _untrack_user(users: dict[str, dict[str, Any]], user_id: str) -> None:
111
+ """Remove um user_id do tracking."""
112
+ if user_id in users:
113
+ del users[user_id]
114
+ _save_users(users)
115
+
116
+
117
+ # ── Handler HTTP ──────────────────────────────────────────────────────────
118
+ class Mem0Handler(BaseHTTPRequestHandler):
119
+ """
120
+ Handler HTTP que traduz requests REST ↔ chamadas ao Memory() do mem0ai.
121
+ """
122
+
123
+ # Injectados pelo servidor (antes de start).
124
+ memory: Any = None
125
+ users: dict[str, dict[str, Any]] = {}
126
+
127
+ # ── Helpers ────────────────────────────────────────────────────────
128
+
129
+ def _send_json(self, status: int, body: Any) -> None:
130
+ self.send_response(status)
131
+ self.send_header("Content-Type", "application/json; charset=utf-8")
132
+ self.send_header("Access-Control-Allow-Origin", "*")
133
+ self.end_headers()
134
+ self.wfile.write(json.dumps(body, default=str).encode("utf-8"))
135
+
136
+ def _send_no_content(self) -> None:
137
+ self.send_response(204)
138
+ self.send_header("Access-Control-Allow-Origin", "*")
139
+ self.end_headers()
140
+
141
+ def _read_body(self) -> dict[str, Any]:
142
+ length = int(self.headers.get("Content-Length", "0"))
143
+ if length == 0:
144
+ return {}
145
+ raw = self.rfile.read(length)
146
+ return json.loads(raw)
147
+
148
+ def _parse_qs(self) -> dict[str, str]:
149
+ """Extrai query params da URL (simples, sem dependência)."""
150
+ path = self.path
151
+ qs: dict[str, str] = {}
152
+ if "?" in path:
153
+ query_string = path.split("?", 1)[1]
154
+ for pair in query_string.split("&"):
155
+ if "=" in pair:
156
+ k, v = pair.split("=", 1)
157
+ qs[k] = v
158
+ elif pair:
159
+ qs[pair] = ""
160
+ return qs
161
+
162
+ # ── Routing ────────────────────────────────────────────────────────
163
+
164
+ def _route_path(self) -> str:
165
+ """Devolve o path sem query string."""
166
+ return self.path.split("?", 1)[0].rstrip("/")
167
+
168
+ def do_GET(self) -> None:
169
+ path = self._route_path()
170
+ try:
171
+ if path == "/health":
172
+ self._handle_health()
173
+ elif path == "/v1/memories":
174
+ self._handle_search()
175
+ elif path == "/v1/users":
176
+ self._handle_list_users()
177
+ else:
178
+ self._send_json(404, {"error": "not found"})
179
+ except Exception:
180
+ traceback.print_exc()
181
+ self._send_json(500, {"error": traceback.format_exc()})
182
+
183
+ def do_POST(self) -> None:
184
+ path = self._route_path()
185
+ try:
186
+ if path == "/v1/memories":
187
+ self._handle_add()
188
+ else:
189
+ self._send_json(404, {"error": "not found"})
190
+ except Exception:
191
+ traceback.print_exc()
192
+ self._send_json(500, {"error": traceback.format_exc()})
193
+
194
+ def do_DELETE(self) -> None:
195
+ path = self._route_path()
196
+ try:
197
+ if path == "/v1/memories":
198
+ self._handle_delete()
199
+ else:
200
+ self._send_json(404, {"error": "not found"})
201
+ except Exception:
202
+ traceback.print_exc()
203
+ self._send_json(500, {"error": traceback.format_exc()})
204
+
205
+ def do_OPTIONS(self) -> None:
206
+ self.send_response(200)
207
+ self.send_header("Access-Control-Allow-Origin", "*")
208
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
209
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
210
+ self.end_headers()
211
+
212
+ # ── Handlers ───────────────────────────────────────────────────────
213
+
214
+ def _handle_health(self) -> None:
215
+ """GET /health → 200 {"ok":true} (handshake do boot)."""
216
+ self._send_json(200, {"ok": True})
217
+
218
+ def _handle_add(self) -> None:
219
+ """POST /v1/memories/ → mem.add(...) → {"id":"..."}."""
220
+ body = self._read_body()
221
+ user_id = body.get("user_id", "default")
222
+ messages = body.get("messages", [])
223
+ metadata = body.get("metadata")
224
+
225
+ if not messages:
226
+ self._send_json(400, {"error": "messages é obrigatório"})
227
+ return
228
+
229
+ # infer=False: armazena o conteúdo DIRETO (embed+store), sem extração por LLM.
230
+ # Semântica da porta MemoryEngine = "guarda o que recebe"; e o judge local
231
+ # (qwen2.5:0.5b) é fraco demais p/ a extração de fatos do mem0 (retornaria vazio).
232
+ result = self.memory.add(messages, user_id=user_id, metadata=metadata, infer=False)
233
+
234
+ # mem0ai 2.0.7 retorna {"results":[{"id",...}]}; o TS espera {"id": "..."}.
235
+ first_id = None
236
+ if isinstance(result, dict):
237
+ res_list = result.get("results")
238
+ if isinstance(res_list, list) and res_list:
239
+ first_id = res_list[0].get("id")
240
+ else:
241
+ first_id = result.get("id")
242
+ elif isinstance(result, list) and result:
243
+ first_id = result[0].get("id")
244
+
245
+ # Track user
246
+ _track_user(self.users, user_id)
247
+
248
+ self._send_json(200, {"id": first_id})
249
+
250
+ def _handle_search(self) -> None:
251
+ """GET /v1/memories/?user_id&query&limit → mem.search(...) → {"results":[...]}."""
252
+ qs = self._parse_qs()
253
+ user_id = qs.get("user_id", "default")
254
+ query = qs.get("query", "")
255
+ limit = int(qs.get("limit", "10"))
256
+
257
+ # mem0ai 2.0.7: entity params via filters=, paginação via top_k= (não user_id/limit).
258
+ resp = self.memory.search(query, filters={"user_id": user_id}, top_k=limit)
259
+
260
+ # mem0ai.search retorna {"results": [...]}
261
+ results = resp.get("results", []) if isinstance(resp, dict) else []
262
+
263
+ self._send_json(200, {"results": results})
264
+
265
+ def _handle_list_users(self) -> None:
266
+ """GET /v1/users/ → lista scopes com memory_count."""
267
+ # Reconcilia tracking file com o estado real (get_all de cada user).
268
+ users_list = []
269
+ for user_id in list(self.users.keys()):
270
+ try:
271
+ # mem0ai 2.0.7: get_all usa filters= e retorna {"results": [...]}.
272
+ all_mems = self.memory.get_all(filters={"user_id": user_id})
273
+ except Exception:
274
+ # F100 — erro TRANSITÓRIO no get_all NÃO é "scope vazio". Conflar os dois
275
+ # (count=0 ⇒ untrack) DESTRÓI o tracking de um scope COM memórias num hiccup
276
+ # do backend — o scope some da lista (memórias intactas, mas invisíveis) até
277
+ # o próximo add. CA-MA8: degrada, não destrói estado. Preserva o tracking e
278
+ # OMITE só desta resposta (reaparece no próximo list bem-sucedido).
279
+ continue
280
+
281
+ if isinstance(all_mems, dict):
282
+ count = len(all_mems.get("results", []))
283
+ elif isinstance(all_mems, list):
284
+ count = len(all_mems)
285
+ else:
286
+ count = 0
287
+
288
+ created_at = self.users[user_id].get("created_at")
289
+ entry: dict[str, Any] = {
290
+ "user_id": user_id,
291
+ "memory_count": count,
292
+ }
293
+ if created_at:
294
+ entry["created_at"] = created_at
295
+
296
+ # Vazio COMPROVADO (get_all OK retornando 0) ⇒ foi deletado: untrack.
297
+ if count > 0:
298
+ users_list.append(entry)
299
+ else:
300
+ _untrack_user(self.users, user_id)
301
+
302
+ self._send_json(200, {"users": users_list})
303
+
304
+ def _handle_delete(self) -> None:
305
+ """DELETE /v1/memories/?user_id=X → mem.delete_all(user_id=X)."""
306
+ qs = self._parse_qs()
307
+ user_id = qs.get("user_id", "default")
308
+
309
+ self.memory.delete_all(user_id=user_id)
310
+ _untrack_user(self.users, user_id)
311
+
312
+ self._send_no_content()
313
+
314
+ # Suprime logs por request (stderr polui a TUI).
315
+ def log_message(self, format: str, *args: Any) -> None:
316
+ pass
317
+
318
+
319
+ # ── ThreadingHTTPServer ───────────────────────────────────────────────────
320
+ class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
321
+ """HTTPServer com thread por request (stdlib apenas)."""
322
+ daemon_threads = True
323
+
324
+
325
+ # ── Main ──────────────────────────────────────────────────────────────────
326
+ EMBEDDER_MARKER = os.path.join(ALUY_MEMORY_DIR, ".embedder")
327
+
328
+
329
+ def _migrate_embedder_if_changed() -> None:
330
+ """Se o embedder mudou desde o último boot (marca em `.embedder` ≠ atual), a dimensão do
331
+ vetor muda e o store chromadb fica incompatível ⇒ reseta proativamente. Best-effort."""
332
+ try:
333
+ prev = None
334
+ if os.path.exists(EMBEDDER_MARKER):
335
+ with open(EMBEDDER_MARKER, encoding="utf-8") as fh:
336
+ prev = fh.read().strip()
337
+ if prev is not None and prev != EMBEDDER_MODEL:
338
+ sys.stderr.write(
339
+ f"aluy-mem0: embedder mudou ({prev} → {EMBEDDER_MODEL}); "
340
+ "dimensão do vetor difere — resetando store p/ recriar.\n"
341
+ )
342
+ _reset_incompatible_stores()
343
+ os.makedirs(ALUY_MEMORY_DIR, mode=0o700, exist_ok=True)
344
+ except Exception: # noqa: BLE001 — migração best-effort, nunca derruba o boot
345
+ pass
346
+
347
+
348
+ def _write_embedder_marker() -> None:
349
+ """Grava qual embedder gerou os vetores atuais (p/ detectar troca no próximo boot)."""
350
+ try:
351
+ os.makedirs(ALUY_MEMORY_DIR, mode=0o700, exist_ok=True)
352
+ with open(EMBEDDER_MARKER, "w", encoding="utf-8") as fh:
353
+ fh.write(EMBEDDER_MODEL)
354
+ except Exception: # noqa: BLE001
355
+ pass
356
+
357
+
358
+ def _reset_incompatible_stores() -> None:
359
+ """Move os stores de memória p/ backup, p/ recriar frescos. Chamado quando a versão atual
360
+ do chromadb/mem0 não lê o store de uma versão anterior (KeyError '_type' no chromadb /
361
+ sqlite 'no such column' no history). Best-effort — nunca lança. Limpa OS DOIS lugares:
362
+ `~/.aluy/memory` (chromadb + history novo) E `~/.mem0/history.db` (history default ANTIGO,
363
+ de antes de consolidarmos o path — senão a 2ª tentativa ainda bate nele)."""
364
+ import time
365
+ try:
366
+ ts = int(time.time())
367
+ except Exception: # noqa: BLE001
368
+ ts = 0
369
+ targets = [ALUY_MEMORY_DIR, os.path.expanduser("~/.mem0/history.db")]
370
+ for path in targets:
371
+ if not os.path.exists(path):
372
+ continue
373
+ try:
374
+ os.rename(path, f"{path}.incompat-{ts}")
375
+ except OSError:
376
+ # rename pode falhar (cross-device/permissão) ⇒ apaga o conteúdo problemático.
377
+ import shutil
378
+ try:
379
+ if os.path.isdir(path):
380
+ shutil.rmtree(path, ignore_errors=True)
381
+ else:
382
+ os.remove(path)
383
+ except Exception: # noqa: BLE001
384
+ pass
385
+
386
+
387
+ def main() -> None:
388
+ parser = argparse.ArgumentParser(description="aluy-mem0-server")
389
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT,
390
+ help=f"Porta loopback (default: {DEFAULT_PORT})")
391
+ parser.add_argument("--host", type=str, default=DEFAULT_HOST,
392
+ help=f"Host (default: {DEFAULT_HOST})")
393
+ args = parser.parse_args()
394
+
395
+ # Import lazy — só depois do venv estar configurado.
396
+ try:
397
+ from mem0 import Memory # type: ignore[import-untyped]
398
+ except ImportError:
399
+ print("ERRO: mem0ai não instalado. Rode `aluy init` primeiro.", file=sys.stderr)
400
+ sys.exit(1)
401
+
402
+ # Garante o dir de store com perms corretas.
403
+ os.makedirs(ALUY_MEMORY_DIR, mode=0o700, exist_ok=True)
404
+
405
+ # MIGRAÇÃO DE EMBEDDER (proativa): trocar o embedder muda a DIMENSÃO do vetor (bge-m3/
406
+ # mxbai=1024, nomic=768). O chromadb só estoura a incompatibilidade no ADD/SEARCH (runtime),
407
+ # NÃO no boot — então o self-heal abaixo (que envolve só o `from_config`) não pegaria. Aqui,
408
+ # ANTES de subir: se o embedder mudou desde o último boot, reseta o store (a marca guarda
409
+ # qual embedder gerou os vetores atuais). Best-effort — nunca derruba o boot.
410
+ _migrate_embedder_if_changed()
411
+
412
+ # Instancia Memory() UMA vez. SELF-HEAL de upgrade: um store gravado por uma versão
413
+ # ANTERIOR do chromadb/mem0 pode ser ilegível pela atual (ex.: chromadb `KeyError: '_type'`
414
+ # na config da collection; sqlite `no such column: prev_value` no history). Em vez de
415
+ # CRASHAR (deixando o sidecar "fora" sem causa visível), movemos os stores antigos p/
416
+ # backup e RECRIAMOS frescos. Perde-se memória antiga (já ilegível de qualquer forma),
417
+ # mas o serviço SOBE. Achado ao vivo na máquina do dono (dois stores de jun/antigo).
418
+ config = _build_mem0_config()
419
+ try:
420
+ memory = Memory.from_config(config)
421
+ except Exception as exc: # noqa: BLE001 — qualquer erro ao ler o store antigo
422
+ if os.environ.get("ALUY_MEM0_RESET_DONE") == "1":
423
+ # JÁ resetamos uma vez e AINDA falha ⇒ não é store velho; propaga (não loopa).
424
+ raise
425
+ sys.stderr.write(
426
+ f"aluy-mem0: store incompatível ({type(exc).__name__}: {exc}); "
427
+ "movendo p/ backup e reiniciando fresco…\n"
428
+ )
429
+ _reset_incompatible_stores()
430
+ # RE-EXEC em vez de retry in-process: o chromadb CACHEIA o client/conexão por
431
+ # processo, então um 2º `from_config` no MESMO processo reusa o store velho. Reiniciar
432
+ # o processo garante chromadb/mem0 FRESCOS. `ALUY_MEM0_RESET_DONE` evita loop infinito.
433
+ # CROSS-SO: o supervisor health-checa a PORTA (não o PID), então trocar de processo é OK
434
+ # em Linux/macOS/Windows. `os.execv` no Windows pode falhar em casos raros (path/quoting)
435
+ # — então caímos p/ um respawn via subprocess + saída limpa; e, em último caso, p/ um
436
+ # retry in-process (melhor um chromadb possivelmente cacheado do que o sidecar morto).
437
+ os.environ["ALUY_MEM0_RESET_DONE"] = "1"
438
+ try:
439
+ os.execv(sys.executable, [sys.executable, *sys.argv])
440
+ except Exception: # noqa: BLE001 — execv indisponível/falhou (ex.: Windows)
441
+ try:
442
+ import subprocess
443
+
444
+ subprocess.Popen([sys.executable, *sys.argv], close_fds=True)
445
+ os._exit(0) # encerra ESTE processo; o novo assume a porta
446
+ except Exception: # noqa: BLE001 — sem respawn possível: tenta in-process
447
+ os.makedirs(ALUY_MEMORY_DIR, mode=0o700, exist_ok=True)
448
+ memory = Memory.from_config(config)
449
+ else:
450
+ return # inalcançável após _exit — só p/ o type-checker
451
+ else:
452
+ return # inalcançável (execv substitui o processo) — só p/ o type-checker
453
+
454
+ # Store criado/aberto OK com o embedder atual — grava a marca p/ detectar troca futura.
455
+ _write_embedder_marker()
456
+
457
+ # Injeta no handler.
458
+ Mem0Handler.memory = memory
459
+ Mem0Handler.users = _load_users()
460
+
461
+ # Cria servidor com bind explícito em loopback.
462
+ server = ThreadingHTTPServer((args.host, args.port), Mem0Handler)
463
+
464
+ # Graceful shutdown no SIGTERM (boot-supervisor mata com SIGTERM).
465
+ def _shutdown(signum: int, frame: Any) -> None:
466
+ print(f"\naluy-mem0-server: recebido sinal {signum}, desligando...", file=sys.stderr)
467
+ server.shutdown()
468
+ signal.signal(signal.SIGTERM, _shutdown)
469
+ signal.signal(signal.SIGINT, _shutdown)
470
+
471
+ print(f"aluy-mem0-server: escutando em http://{args.host}:{args.port}", file=sys.stderr)
472
+
473
+ try:
474
+ server.serve_forever()
475
+ except KeyboardInterrupt:
476
+ pass
477
+ finally:
478
+ server.server_close()
479
+ print("aluy-mem0-server: parado.", file=sys.stderr)
480
+
481
+
482
+ if __name__ == "__main__":
483
+ main()