diff --git a/scripts/brain/cortex_intent.py b/scripts/brain/cortex_intent.py new file mode 100644 index 0000000000..951c681c19 --- /dev/null +++ b/scripts/brain/cortex_intent.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import List + + +@dataclass +class CortexIntent: + intent: str + confidence: str = "medium" + route_reason: str = "default" + needs_web_search: bool = False + needs_vault_search: bool = False + dashboard_context: str = "" + + def get(self, key, default=None): + return getattr(self, key, default) + + +DASHBOARD_CONTEXT = """Contexte dashboard Cortex: +- URL locale: http://127.0.0.1:8765/ +- Dashboard GPU/brain: brain_gpu.html +- Sidecar chat à droite +- Onglet Playtest +- Onglet Consortium +- APIs utiles: /api/cortex/judges, /api/cortex/homeostasis, /api/chat +""" + + +def _norm(text: str) -> str: + return (text or "").lower().strip() + + +def detect_intent(message: str) -> CortexIntent: + m = _norm(message) + + if m.startswith("/code") and any(k in m for k in [ + "playtest", "app", "application", "html", "interface", + "calculatrice", "todo", "kanban", "dashboard" + ]): + return CortexIntent( + intent="playtest_code_task", + confidence="high", + route_reason="playtest_builder_direct", + dashboard_context=DASHBOARD_CONTEXT, + ) + + if any(k in m for k in [ + "recherche web", "cherche sur le web", "actualité", "actualités", + "actu", "news", "récent", "récente", "aujourd'hui", "maintenant", + "dernières nouvelles", "latest" + ]): + return CortexIntent( + intent="recent_web_search", + confidence="high", + route_reason="needs_web_search", + needs_web_search=True, + ) + + if any(k in m for k in [ + "playtest", "playtest intégré", "dashboard", "brain_gpu", + "sidecar", "consortium", "juges", "judges", "homeostasis" + ]): + return CortexIntent( + intent="playtest_dashboard_help", + confidence="high", + route_reason="dashboard_context_injected", + dashboard_context=DASHBOARD_CONTEXT, + ) + + if any(k in m for k in [ + "vault", "mémoire", "memoire", "obsidian", "souviens", + "tu vois le projet", "projet de site", "site comores", "comores", + "workspace", "fichier local", "dans le repo", "dans les fichiers" + ]): + return CortexIntent( + intent="local_project_search", + confidence="high", + route_reason="needs_vault_or_file_search", + needs_vault_search=True, + ) + + if any(k in m for k in [ + "debug", "raisonne", "analyse profonde", "planifie", + "architecture", "pourquoi", "diagnostic" + ]): + return CortexIntent( + intent="deep_reason", + confidence="medium", + route_reason="deep_reason_requested", + ) + + if any(k in m for k in [ + "tu es qui", "présente toi", "présente-toi", "qui es-tu", + "c'est quoi cortex", "qui t'a créé" + ]): + return CortexIntent( + intent="identity", + confidence="high", + route_reason="identity_or_presentation", + ) + + if any(k in m for k in [ + "code", "patch", "modifie", "corrige", "commande", + "powershell", "python", "git" + ]): + return CortexIntent( + intent="code_task", + confidence="medium", + route_reason="code_or_task_execution", + ) + + return CortexIntent( + intent="simple_chat", + confidence="medium", + route_reason="simple_chat", + ) + + +def metadata(intent: CortexIntent, backend: str = "minimax_fast", tools_used: List[str] | None = None, evidence_count: int = 0) -> dict: + return { + "intent": intent.intent, + "tools_used": tools_used or [], + "evidence_count": evidence_count, + "backend": backend, + "route_reason": intent.route_reason, + "confidence": intent.confidence, + "needs_web_search": intent.needs_web_search, + "needs_vault_search": intent.needs_vault_search, + } diff --git a/scripts/brain/cortex_kv_quantize.py b/scripts/brain/cortex_kv_quantize.py new file mode 100644 index 0000000000..0ea7796d9d --- /dev/null +++ b/scripts/brain/cortex_kv_quantize.py @@ -0,0 +1,393 @@ +""" +cortex_kv_quantize.py — Quantization KV cache TurboQuant-inspired pour LLM local. + +(Distinct de cortex_quantize.py qui s'occupe du graphe TF-IDF.) + +État actuel (2026-04) : +- TurboQuant 3-bit (Google) pas encore mergé dans llama.cpp / LM Studio mainstream. +- DISPONIBLE MAINTENANT dans llama.cpp : Q8_0 (~25% saving), Q4_0 (~50% saving) + sur le KV cache K et V. +- LM Studio expose les options dans Advanced Configuration → KV Cache Quantization. + +Sam veut tester maintenant — donc on applique la plus agressive disponible (Q4_0) +qui est déjà éprouvée et stable, et on prépare le swap vers Q3 dès qu'il arrive. + +Pipeline : +1. detect_loaded_model() : interroge LM Studio +2. recommend() : choisit Q4_0 si stable, sinon Q8_0 +3. measure_latency() : mesure baseline avant changement +4. apply_lmstudio_config() : génère un guide précis pour LM Studio UI +5. After Sam applies → re-measure_latency() pour valider + +Endpoint serveur : GET /api/cortex/kv_quantize +""" +try: + from lmstudio_response import extract_lmstudio_content +except Exception: + from scripts.brain.lmstudio_response import extract_lmstudio_content +import json +import time +from pathlib import Path +import urllib.request +import urllib.error + +REPO_ROOT = Path(r"H:\Code\Paperclip") +STATE_FILE = REPO_ROOT / ".cortex-kv-quantize-state.json" +GUIDE_FILE = REPO_ROOT / ".cortex-kv-quantize-guide.md" +LATENCY_LOG = REPO_ROOT / ".cortex-kv-quantize-latency.jsonl" +LMSTUDIO_API = "http://localhost:1234/v1" + +# Modèles connus (heuristique) +KNOWN_MODELS = { + "qwen3.6-35b-a3b": {"params_b": 35, "n_layers": 64, "hidden": 5120, + "n_kv_heads": 8, "head_dim": 128, "moe": True}, + "qwen3.5-27b": {"params_b": 27, "n_layers": 56, "hidden": 4480, + "n_kv_heads": 8, "head_dim": 128, "moe": False}, + "llama-3-70b": {"params_b": 70, "n_layers": 80, "hidden": 8192, + "n_kv_heads": 8, "head_dim": 128, "moe": False}, + "llama-3-8b": {"params_b": 8, "n_layers": 32, "hidden": 4096, + "n_kv_heads": 8, "head_dim": 128, "moe": False}, + "mistral-7b": {"params_b": 7, "n_layers": 32, "hidden": 4096, + "n_kv_heads": 8, "head_dim": 128, "moe": False}, +} + +DTYPE_BYTES = { + "fp16": 2.0, + "q8_0": 1.0, + "q4_0": 0.5, + "q4_1": 0.5, + "q3_turboquant": 0.375, # placeholder — pas encore dispo +} + +DTYPE_QUALITY = { + "fp16": {"loss_pct": 0.0, "stable": True, "available": True, + "note": "baseline, pas de quantization"}, + "q8_0": {"loss_pct": 0.1, "stable": True, "available": True, + "note": "perte imperceptible · safe par défaut"}, + "q4_0": {"loss_pct": 1.5, "stable": True, "available": True, + "note": "agressif · ~50% saving · valide chat/RAG"}, + "q4_1": {"loss_pct": 1.0, "stable": True, "available": True, + "note": "mieux que q4_0 sur tâches sensibles"}, + "q3_turboquant": {"loss_pct": 0.3, "stable": False, "available": False, + "note": "Google TurboQuant · pas encore dans llama.cpp · ETA 2026-Q3"}, +} + +# Quantization des POIDS GGUF — c'est ÇA qui mange la VRAM (pas le KV cache). +# Sur qwen35b par exemple : poids fp16=70GB, Q4_K_M=~17GB, Q3_K_M=~13GB, IQ3_XXS=~10GB. +WEIGHT_QUANT_PROFILES = { + "fp16": {"size_ratio": 1.00, "loss_pct": 0.0, "speed_factor": 0.5, + "note": "baseline · jamais utilisé en local sauf sur petit modèle"}, + "Q8_0": {"size_ratio": 0.53, "loss_pct": 0.05, "speed_factor": 1.0, + "note": "presque sans perte · 47% VRAM en moins"}, + "Q6_K": {"size_ratio": 0.41, "loss_pct": 0.15, "speed_factor": 1.05, + "note": "excellent compromis · perte invisible"}, + "Q5_K_M": {"size_ratio": 0.36, "loss_pct": 0.30, "speed_factor": 1.1, + "note": "très bon · standard production"}, + "Q4_K_M": {"size_ratio": 0.30, "loss_pct": 0.80, "speed_factor": 1.15, + "note": "default LM Studio · bon ratio"}, + "Q4_K_S": {"size_ratio": 0.28, "loss_pct": 1.20, "speed_factor": 1.18, + "note": "un peu plus petit que Q4_K_M"}, + "Q3_K_M": {"size_ratio": 0.22, "loss_pct": 2.50, "speed_factor": 1.25, + "note": "perte commence à se voir · OK pour chat simple"}, + "IQ3_XXS": {"size_ratio": 0.19, "loss_pct": 3.00, "speed_factor": 1.30, + "note": "improved quantization · moins de perte que Q3_K_M à taille égale"}, + "Q2_K": {"size_ratio": 0.18, "loss_pct": 5.00, "speed_factor": 1.35, + "note": "extrême · perte notable · à éviter sauf VRAM critique"}, + "IQ2_M": {"size_ratio": 0.16, "loss_pct": 4.50, "speed_factor": 1.35, + "note": "improved Q2 · plus stable que Q2_K"}, +} + + +def detect_loaded_model() -> dict: + try: + with urllib.request.urlopen(f"{LMSTUDIO_API}/models", timeout=3) as r: + data = json.loads(r.read().decode()) + models = [m["id"] for m in data.get("data", []) if not m["id"].startswith("text-embedding")] + return {"ok": True, "models": models, "lmstudio_up": True} + except Exception as e: + return {"ok": False, "error": str(e), "lmstudio_up": False} + + +def model_meta(model_id: str) -> dict: + mid = (model_id or "").lower() + for key, meta in KNOWN_MODELS.items(): + if key in mid: + return {**meta, "id": model_id, "matched_key": key} + import re, math + m = re.search(r'(\d+)b', mid) + params_b = int(m.group(1)) if m else 7 + return {"params_b": params_b, + "n_layers": max(24, int(4 * math.log2(max(2, params_b * 8)))), + "hidden": max(2048, int(params_b ** 0.5 * 1024)), + "n_kv_heads": 8, "head_dim": 128, "moe": False, + "id": model_id, "matched_key": "(heuristique)"} + + +def kv_cache_size_bytes(meta: dict, n_ctx: int, dtype: str = "fp16") -> int: + bytes_per = DTYPE_BYTES.get(dtype, 2.0) + return int(2 * n_ctx * meta["n_layers"] * meta["n_kv_heads"] + * meta["head_dim"] * bytes_per) + + +def weight_size_gb(params_b: float, profile: str) -> float: + """Taille des poids selon profile de quantization (en GB).""" + p = WEIGHT_QUANT_PROFILES.get(profile, {"size_ratio": 1.0}) + # 2 bytes par param en fp16, ratio appliqué selon quantization + return params_b * 2.0 * p["size_ratio"] + + +def weights_recommend(params_b: float, target_vram_gb: float = 12.0) -> dict: + """Choisis la quantization de poids la plus QUALITATIVE qui tient dans target_vram_gb. + On laisse une réserve pour KV cache + activations.""" + # Réserve : ~2 GB pour KV (avec quantization Q4) + 1 GB activations + 0.5 GB système + reserve_gb = 3.5 + budget_gb = max(1.0, target_vram_gb - reserve_gb) + options = [] + for prof, info in WEIGHT_QUANT_PROFILES.items(): + size_gb = weight_size_gb(params_b, prof) + options.append({ + "profile": prof, "size_gb": round(size_gb, 2), + "fits_target": size_gb <= budget_gb, + "loss_pct": info["loss_pct"], "speed_factor": info["speed_factor"], + "note": info["note"], + }) + options.sort(key=lambda x: x["size_gb"], reverse=True) # plus gros d'abord + # Recommandation : la plus QUALITATIVE qui tient (loss_pct le plus bas qui fit) + fitting = [o for o in options if o["fits_target"]] + if fitting: + # Parmi celles qui tiennent, choisis la plus qualitative (loss_pct min) + recommended = min(fitting, key=lambda x: x["loss_pct"]) + else: + # Aucune ne tient → la plus petite + recommended = min(options, key=lambda x: x["size_gb"]) + return { + "params_b": params_b, "target_vram_gb": target_vram_gb, + "budget_for_weights_gb": round(budget_gb, 2), + "options": options, "recommended": recommended, + } + + +def recommend(target_vram_gb: float = 12.0, n_ctx: int = 8192, + model_id: str | None = None) -> dict: + detected = detect_loaded_model() + model_id = model_id or (detected.get("models") or ["qwen3.6-35b-a3b"])[0] + meta = model_meta(model_id) + options = [] + for dtype in ["fp16", "q8_0", "q4_1", "q4_0", "q3_turboquant"]: + size_bytes = kv_cache_size_bytes(meta, n_ctx, dtype) + size_gb = size_bytes / (1024**3) + q = DTYPE_QUALITY[dtype] + options.append({ + "dtype": dtype, "size_gb": round(size_gb, 2), + "fits_target": size_gb <= target_vram_gb, + "loss_pct": q["loss_pct"], "stable": q["stable"], + "available_now": q["available"], "note": q["note"], + }) + # Choisis la plus agressive ET stable ET disponible ET qui tient + recommended = None + for opt in reversed(options): + if opt["fits_target"] and opt["stable"] and opt["available_now"]: + recommended = opt; break + if not recommended: + recommended = next((o for o in options if o["available_now"]), options[0]) + fp16_gb = next(o["size_gb"] for o in options if o["dtype"] == "fp16") + save_gb = fp16_gb - recommended["size_gb"] + return { + "ts": time.time(), + "model": meta, "n_ctx": n_ctx, "target_vram_gb": target_vram_gb, + "options": options, + "recommended": recommended, + "save_gb_vs_fp16": round(save_gb, 2), + "save_pct_vs_fp16": round(save_gb / max(0.001, fp16_gb) * 100, 1), + "lmstudio_status": detected, + "future_q3_turboquant": { + "size_gb": round(kv_cache_size_bytes(meta, n_ctx, "q3_turboquant")/(1024**3), 2), + "available_now": False, "eta": "2026-Q3", + "tracking": "https://research.google/blog/turboquant-redefining-ai-efficiency-with-extreme-compression/", + }, + } + + +def measure_latency(prompt: str = "Bonjour, dis-moi en 1 phrase ce qu'est Python.", + max_tokens: int = 60, n_runs: int = 2) -> dict: + """Mesure latence p50 sur N runs. Logue dans LATENCY_LOG pour comparer avant/après.""" + detected = detect_loaded_model() + if not detected.get("ok") or not detected.get("models"): + return {"ok": False, "error": "LM Studio inactif"} + model_id = detected["models"][0] + durations = [] + for i in range(n_runs): + t0 = time.time() + try: + payload = json.dumps({ + "model": model_id, + "messages": [ + {"role": "system", "content": "/no_think\nOutput only the final answer. No hidden reasoning. No markdown unless explicitly requested."}, + {"role": "user", "content": "/no_think\n" + prompt}, + ], + "max_tokens": max_tokens, + "temperature": 0.1, + }).encode("utf-8") + req = urllib.request.Request( + f"{LMSTUDIO_API}/chat/completions", data=payload, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=120) as r: + resp = json.loads(r.read().decode()) + dur = time.time() - t0 + tokens = (resp.get("usage") or {}).get("completion_tokens") or max_tokens + durations.append({"run": i, "duration_s": round(dur, 2), + "tokens": tokens, + "tokens_per_s": round(tokens / max(0.01, dur), 1)}) + except Exception as e: + durations.append({"run": i, "error": str(e)}) + successful = [d for d in durations if "duration_s" in d] + p50 = sorted(d["duration_s"] for d in successful)[len(successful)//2] if successful else None + avg_tps = (sum(d["tokens_per_s"] for d in successful) / len(successful) + if successful else None) + result = { + "ok": bool(successful), "ts": time.time(), "model": model_id, + "n_runs": n_runs, "p50_s": p50, "avg_tokens_per_s": round(avg_tps, 1) if avg_tps else None, + "details": durations, + } + try: + with LATENCY_LOG.open("a", encoding="utf-8") as f: + f.write(json.dumps(result, ensure_ascii=False) + "\n") + except Exception: pass + return result + + +def apply_lmstudio_config(plan: dict) -> dict: + """Génère un guide markdown précis (LM Studio n'expose pas d'API config).""" + rec = plan.get("recommended") or {} + dtype = rec.get("dtype", "q8_0") + model_id = (plan.get("model") or {}).get("id", "?") + save_gb = plan.get("save_gb_vs_fp16", 0) + save_pct = plan.get("save_pct_vs_fp16", 0) + body = f"""# Application KV cache quantization — `{dtype}` + +Modèle ciblé : `{model_id}` +Économie attendue : **{save_gb} GB VRAM** (~{save_pct}% du KV cache) +Perte qualité estimée : ~{rec.get('loss_pct', 0)}% perplexity + +## Étapes dans LM Studio (UI) + +1. Décharge le modèle s'il tourne (icône Eject à côté du modèle dans Models). +2. Clic sur le modèle → **Settings** (engrenage). +3. Section **Advanced Configuration** → **KV Cache Quantization**. +4. Sélectionner : + - **K cache type** : `{dtype}` + - **V cache type** : `{dtype}` +5. **Save & Reload** le modèle. +6. Vérifier dans Performance : VRAM doit baisser de ~{save_pct}%. + +## Test latence avant/après (script auto) + +```bash +# AVANT — baseline (relancer plusieurs fois pour stabiliser) +python scripts/brain/cortex_kv_quantize.py latency + +# Applique le changement dans LM Studio (manuel, voir au-dessus) + +# APRÈS — vérifie tokens/s et vérification fonctionnelle +python scripts/brain/cortex_kv_quantize.py latency + +# Comparer +python scripts/brain/cortex_kv_quantize.py compare +``` + +Le script log dans `.cortex-kv-quantize-latency.jsonl` chaque mesure pour +comparer p50 et tokens/s avant/après. + +## Si la qualité chute trop + +Re-applique en remontant : `q4_0` → `q4_1` → `q8_0` → `fp16` (baseline). + +## Quand TurboQuant Q3 sera mergé dans llama.cpp + +Le module détecte automatiquement (relance `recommend()`) et propose le swap. +""" + try: + GUIDE_FILE.write_text(body, encoding="utf-8") + except Exception as e: + return {"ok": False, "error": str(e)} + return {"ok": True, "guide_path": str(GUIDE_FILE), + "save_gb": save_gb, "save_pct": save_pct, "dtype": dtype} + + +def compare_latencies(n_recent: int = 6) -> dict: + """Compare les N dernières mesures pour montrer l'effet de la quantization.""" + if not LATENCY_LOG.exists(): return {"ok": False, "error": "no measurements yet"} + lines = LATENCY_LOG.read_text(encoding="utf-8", errors="replace").splitlines()[-n_recent:] + runs = [] + for ln in lines: + try: runs.append(json.loads(ln)) + except Exception: pass + if len(runs) < 2: return {"ok": False, "error": "need at least 2 measurements"} + return { + "ok": True, "n_compared": len(runs), + "first": {"ts": runs[0]["ts"], "p50_s": runs[0].get("p50_s"), + "tokens_per_s": runs[0].get("avg_tokens_per_s")}, + "last": {"ts": runs[-1]["ts"], "p50_s": runs[-1].get("p50_s"), + "tokens_per_s": runs[-1].get("avg_tokens_per_s")}, + "speedup": (round(runs[0]["p50_s"] / max(0.01, runs[-1]["p50_s"]), 2) + if (runs[0].get("p50_s") and runs[-1].get("p50_s")) else None), + "all_runs": runs, + } + + +def full_recommend(target_vram_gb: float = 12.0, n_ctx: int = 8192) -> dict: + """Recommandation complète : KV cache + poids + estimation VRAM totale.""" + kv = recommend(target_vram_gb=target_vram_gb, n_ctx=n_ctx) + params_b = kv["model"].get("params_b", 7) + wts = weights_recommend(params_b, target_vram_gb=target_vram_gb) + rec_kv = kv.get("recommended", {}) + rec_wt = wts.get("recommended", {}) + # Total VRAM estimée avec les recommandations + total_with_rec = rec_kv.get("size_gb", 0) + rec_wt.get("size_gb", 0) + 1.0 # +activations + total_baseline = (kv["options"][0]["size_gb"] + + next(o["size_gb"] for o in wts["options"] if o["profile"] == "fp16") + 1.0) + saving = total_baseline - total_with_rec + return { + **kv, + "weights": wts, + "vram_estimation": { + "weights_gb": rec_wt.get("size_gb", 0), + "kv_cache_gb": rec_kv.get("size_gb", 0), + "activations_gb": 1.0, + "total_estimated_gb": round(total_with_rec, 2), + "vs_fp16_baseline_gb": round(total_baseline, 2), + "savings_gb": round(saving, 2), + }, + "speedup_factor_estimated": rec_wt.get("speed_factor", 1.0), + } + + +def snapshot() -> dict: + plan = full_recommend() + apply_rep = apply_lmstudio_config(plan) + state = {**plan, "apply": apply_rep} + try: + STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False), + encoding="utf-8") + except Exception: pass + return state + + +if __name__ == "__main__": + import sys + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + cmd = sys.argv[1] if len(sys.argv) > 1 else "snapshot" + if cmd == "detect": + print(json.dumps(detect_loaded_model(), indent=2)) + elif cmd == "recommend": + target = float(sys.argv[2]) if len(sys.argv) > 2 else 12.0 + print(json.dumps(recommend(target_vram_gb=target), indent=2, ensure_ascii=False)) + elif cmd == "apply": + plan = recommend() + print(json.dumps(apply_lmstudio_config(plan), indent=2)) + elif cmd == "latency": + print(json.dumps(measure_latency(), indent=2, ensure_ascii=False)) + elif cmd == "compare": + print(json.dumps(compare_latencies(), indent=2, ensure_ascii=False)) + else: + print(json.dumps(snapshot(), indent=2, ensure_ascii=False)) diff --git a/scripts/brain/cortex_self_dev.py b/scripts/brain/cortex_self_dev.py new file mode 100644 index 0000000000..c31cc3ab4e --- /dev/null +++ b/scripts/brain/cortex_self_dev.py @@ -0,0 +1,502 @@ +""" +cortex_self_dev.py — Boucle d'auto-développement de Cortex avec garde-fous. + +Pipeline : +1. Reçoit un objectif en langage naturel ("ajoute un endpoint X", "fix bug Y") +2. Demande au router v2 de proposer un patch (modèles gratuits d'abord) +3. Parse la proposition (nouveau contenu de fichier ou diff) +4. Crée une branche cortex/dev/- +5. Applique le patch +6. Lance test_smoke complet +7. Si tests passent → commit, sinon arrêt pour inspection + +Le LLM ne touche JAMAIS le disque directement. Tout passe par cortex_tools +qui valide les chemins, et toute modif est isolée dans une branche git éphémère. +""" +try: + from lmstudio_response import extract_lmstudio_content +except Exception: + from scripts.brain.lmstudio_response import extract_lmstudio_content +try: + from lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +except Exception: + from scripts.brain.lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +import datetime as dt +import json +import re +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +REPO_ROOT = Path(r"H:\Code\Paperclip") +ROUTER_URL = "http://127.0.0.1:18900/route_v2" +LM_STUDIO_URL = get_lmstudio_config()["base_url"] + "/v1" +SELFDEV_LOG = REPO_ROOT / ".cortex-self-dev.log" +GUARDRAILS_FILE = REPO_ROOT / "scripts" / "brain" / "cortex_self_dev_guardrails.json" + +DEFAULT_GUARDRAILS = { + "enabled": True, + "allowed_path_prefixes": ["scripts/brain/"], + "blocked_path_fragments": [ + ".env", "secrets", "cookies", "token", "password", ".venv", + "node_modules", ".git" + ], + "max_context_chars": 4200, + "max_files_per_change": 2, + "max_shrink_ratio": 0.55, + "require_explicit_path_in_goal": True, + "valid_tests": ["router", "serve", "memory", "tts", "cortex"], + "test_aliases": {"voice": "tts", "self_dev": "cortex", "identity": "cortex"}, + "commit_only_applied_files": True, + "auto_apply_risk_threshold": "low" +} + + +def _load_guardrails() -> dict: + if not GUARDRAILS_FILE.exists(): + GUARDRAILS_FILE.write_text( + json.dumps(DEFAULT_GUARDRAILS, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return dict(DEFAULT_GUARDRAILS) + try: + data = json.loads(GUARDRAILS_FILE.read_text(encoding="utf-8")) + merged = dict(DEFAULT_GUARDRAILS) + merged.update(data if isinstance(data, dict) else {}) + return merged + except Exception: + return dict(DEFAULT_GUARDRAILS) + +# Import des tools sûrs +sys.path.insert(0, str(REPO_ROOT / "scripts" / "brain")) +import cortex_tools as ct + + +def _log(msg: str): + line = f"[{dt.datetime.now().isoformat(timespec='seconds')}] {msg}" + print(line, flush=True) + try: + with open(SELFDEV_LOG, "a", encoding="utf-8") as f: f.write(line + "\n") + except Exception: pass + + +def _slugify(text: str, max_len: int = 30) -> str: + s = re.sub(r'[^\w\s-]', '', text.lower()) + s = re.sub(r'[-\s]+', '-', s).strip('-') + return s[:max_len] or "task" + + +# ─── Génération du patch via router v2 ─────────────────────────────────────── +PROPOSAL_PROMPT = """Tu es Cortex, un assistant de développement Python qui peut modifier son propre code. + +Objectif demandé par Sam : +{goal} + +Contexte (fichiers pertinents) : +{context} + +Format de réponse OBLIGATOIRE — uniquement un bloc JSON, rien d'autre : +{{ + "analysis": "explication courte du diagnostic et de l'approche", + "files": [ + {{"path": "chemin/relatif/au/repo/fichier.py", "content": "CONTENU COMPLET DU FICHIER (pas un diff)"}} + ], + "tests": ["tts", "router", "memory"] +}} + +Règles strictes : +- Tous les chemins sont relatifs à H:\\Code\\Paperclip +- "content" doit être le fichier COMPLET (on remplace, pas un patch) +- "tests" liste les suites smoke à lancer (router|serve|memory|tts|cortex) — au minimum les zones touchées +- Préserve l'encodage UTF-8 et les apostrophes françaises +- N'ajoute aucune dépendance externe sans justification +- Garde la cohérence avec le code existant (style, imports) + +Réponds UNIQUEMENT avec le JSON, sans markdown.""" + + +def _gather_context(goal: str, max_files: int = 2, max_chars_per_file: int = 3000, + total_budget: int = 7000) -> str: + """Grep mots-clés du goal, retourne fichiers pertinents tronqués pour rester + sous total_budget chars (les free models digèrent mal au-delà de ~10K).""" + keywords = [w for w in re.findall(r'\w{4,}', goal) if w.lower() not in + {"dans", "pour", "avec", "tous", "comme", "doit", "code", "file", + "fichier", "ajouter", "ajoute", "ajout"}][:4] + if not keywords: return "" + + relevant_files = {} + # Priorité absolue aux chemins explicitement cités par Sam / Cortex. + for raw_path in re.findall(r'(?:scripts|packages|server|ui|doc|docs)[\w./\\-]+\.\w+', goal): + rel = raw_path.replace("\\", "/") + if (REPO_ROOT / rel).exists(): + relevant_files[rel] = 100 + for kw in keywords: + r = ct.search(kw, "scripts", max_results=8) + for m in r.get("matches", []): + relevant_files[m["file"]] = relevant_files.get(m["file"], 0) + 1 + + top_files = sorted(relevant_files.items(), key=lambda x: -x[1])[:max_files] + parts, total = [], 0 + for fname, _hits in top_files: + budget = min(max_chars_per_file, total_budget - total) + if budget < 500: break + f = ct.read_file(str(REPO_ROOT / fname), max_bytes=budget) + if f.get("ok"): + block = f"### {fname}\n```python\n{f['content']}\n```" + parts.append(block) + total += len(block) + return "\n\n".join(parts) if parts else "(pas de contexte trouvé)" + + +def _normalize_tests(tests, files) -> list[str]: + guardrails = _load_guardrails() + valid = set(guardrails.get("valid_tests", DEFAULT_GUARDRAILS["valid_tests"])) + aliases = guardrails.get("test_aliases", DEFAULT_GUARDRAILS["test_aliases"]) + out = [] + for t in tests or []: + key = aliases.get(str(t), str(t)) + if key in valid and key not in out: + out.append(key) + touched = " ".join(files or []) + if "scripts/brain/" in touched and "cortex" not in out: + out.append("cortex") + return out or ["cortex"] + + +def _ask_router(prompt: str, timeout: int = 240) -> dict: + """Pose la question au router v2, retourne le JSON parsé du modèle.""" + try: + payload = json.dumps({"text": prompt, "role": "code"}).encode("utf-8") + req = urllib.request.Request(ROUTER_URL, data=payload, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode()) + except Exception as e: + return {"error": str(e)} + + +def _ask_lm_studio_direct(prompt: str, timeout: int = 180) -> dict: + """Fallback direct quand le router v2 retourne inject=True. + + Le self-dev a besoin d'une réponse modèle structurée. Un résultat + {"inject": true, "text": prompt} signifie seulement "à envoyer à Claude", + pas "patch proposé". On utilise alors le modèle local chargé dans LM Studio. + """ + try: + with urllib.request.urlopen(f"{LM_STUDIO_URL}/models", timeout=5) as r: + models = json.loads(r.read().decode()).get("data", []) + model = select_lmstudio_model( + task_type="complex_self_dev", + requested_model=os.environ.get("SELF_DEV_MODEL", get_lmstudio_config()["deep_model"]), + automatic=True, + available_models=[m.get("id", "") for m in models], + ) + payload = { + "model": model, + "messages": [ + {"role": "system", "content": "/no_think\nOutput only the final answer. No hidden reasoning. No markdown unless explicitly requested."}, + {"role": "user", "content": "/no_think\n" + prompt}, + ], + "temperature": 0.1, + "max_tokens": 6000, + "stream": False, + } + payload = json.dumps(add_lmstudio_ttl(payload)).encode("utf-8") + req = urllib.request.Request( + f"{LM_STUDIO_URL}/chat/completions", + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as r: + d = json.loads(r.read().decode()) + content = extract_lmstudio_content(d["choices"][0], expect_json=True) + return {"backend": f"lm_studio:{model}", "response": content} + except Exception as e: + return {"error": str(e)} + + +def _parse_proposal(response_text: str) -> dict | None: + """Extrait le JSON de la réponse modèle (peut contenir du texte autour).""" + if not response_text: return None + # Cherche un bloc JSON {...} + for m in re.finditer(r'\{[\s\S]*\}', response_text): + candidate = m.group(0) + try: + d = json.loads(candidate) + if isinstance(d, dict) and "files" in d: + return d + except json.JSONDecodeError: continue + return None + + +def _parse_goal_proposal(response_text: str) -> dict | None: + """Extrait le JSON court {goal,rationale,risk} de la boucle autonome.""" + if not response_text: + return None + for m in re.finditer(r'\{[\s\S]*\}', response_text): + try: + d = json.loads(m.group(0)) + except json.JSONDecodeError: + continue + if isinstance(d, dict) and d.get("goal"): + return d + return None + + +# ─── Application sécurisée ────────────────────────────────────────────────── +def propose_and_apply(goal: str, dry_run: bool = False) -> dict: + """Boucle complète : propose → branche → applique → test → commit ou inspection. + Retourne un rapport structuré.""" + started = time.time() + report = {"goal": goal, "started_at": dt.datetime.now().isoformat(), + "steps": [], "outcome": "pending"} + + def step(name, **data): + entry = {"name": name, "ts": time.time() - started, **data} + report["steps"].append(entry) + _log(f" step: {name} {data}") + + _log(f"=== self_dev: {goal[:80]} ===") + guardrails = _load_guardrails() + report["guardrails_file"] = str(GUARDRAILS_FILE) + if not guardrails.get("enabled", True): + report["outcome"] = "guardrails_disabled" + step("guardrails_disabled") + return report + + # 1. Récupérer contexte pertinent + context = _gather_context(goal, max_files=int(guardrails.get("max_files_per_change", 2)), + max_chars_per_file=1800, + total_budget=int(guardrails.get("max_context_chars", 4200))) + step("context_gathered", chars=len(context)) + + # 2. Demander une proposition au router v2 + prompt = PROPOSAL_PROMPT.format(goal=goal, context=context) + rv = _ask_router(prompt) + if "error" in rv: + report["outcome"] = "router_error" + report["error"] = rv["error"] + step("router_failed", error=rv["error"]) + return report + if rv.get("inject") and not rv.get("response"): + step("router_inject_only", backend=rv.get("backend")) + rv = _ask_lm_studio_direct(prompt) + if "error" in rv: + report["outcome"] = "model_unavailable" + report["error"] = rv["error"] + step("lm_studio_failed", error=rv["error"]) + return report + raw = rv.get("response") or rv.get("text") or "" + backend = rv.get("backend", "?") + step("proposal_received", backend=backend, chars=len(raw)) + + # 3. Parser le JSON + proposal = _parse_proposal(raw) + if not proposal: + report["outcome"] = "parse_failed" + report["raw_excerpt"] = raw[:500] + step("parse_failed", excerpt=raw[:200]) + return report + files = proposal.get("files", []) + tests = _normalize_tests(proposal.get("tests", ["router", "serve", "memory"]), + [f.get("path", "") for f in files]) + report["analysis"] = proposal.get("analysis", "") + report["files_planned"] = [f.get("path") for f in files] + step("proposal_parsed", files=len(files), tests=tests) + + if guardrails.get("require_explicit_path_in_goal", True): + explicit_paths = set(p.replace("\\", "/") for p in re.findall(r'(?:scripts|packages|server|ui|doc|docs)[\w./\\-]+\.\w+', goal)) + planned_paths = set(str(f.get("path", "")).replace("\\", "/") for f in files) + if not planned_paths or not planned_paths.issubset(explicit_paths): + report["outcome"] = "guardrail_refused" + step("guardrail_refused", reason="planned paths are not explicitly named in goal", + planned=list(planned_paths), explicit=list(explicit_paths)) + return report + + if dry_run: + report["outcome"] = "dry_run" + return report + + # 4. Créer branche dédiée + branch = f"cortex/dev/{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}-{_slugify(goal)}" + br = ct.git_branch(branch) + if not br.get("ok"): + report["outcome"] = "branch_failed" + step("branch_failed", err=br.get("stderr")) + return report + step("branch_created", branch=branch) + + # 5. Appliquer les modifications + applied = [] + for f in files: + path = f.get("path") + content = f.get("content", "") + if not path or not content: + continue + norm_path = path.replace("\\", "/") + allowed = guardrails.get("allowed_path_prefixes", []) + blocked = guardrails.get("blocked_path_fragments", []) + if allowed and not any(norm_path.startswith(p) for p in allowed): + step("write_refused", path=path, reason="outside allowed_path_prefixes") + continue + if any(b.lower() in norm_path.lower() for b in blocked): + step("write_refused", path=path, reason="blocked_path_fragments") + continue + # Vérification chemin sûr + try: + full_path = REPO_ROOT / path + ct._safe_path(str(full_path)) + except PermissionError as e: + step("write_refused", path=path, reason=str(e)) + continue + if full_path.exists(): + old_size = full_path.stat().st_size + shrink = float(guardrails.get("max_shrink_ratio", 0.55)) + if old_size > 1000 and len(content.encode("utf-8")) < old_size * shrink: + step("write_refused", path=path, + reason=f"proposal would shrink existing file from {old_size} to {len(content.encode('utf-8'))} bytes") + continue + w = ct.write_file(str(full_path), content) + if w.get("ok"): + applied.append(path) + step("file_written", path=path, size=w.get("size")) + else: + step("write_failed", path=path, error=w.get("error")) + report["files_applied"] = applied + if not applied: + report["outcome"] = "no_files_applied" + step("stopped_for_review", reason="no file passed guardrails") + return report + + # 6. Lancer les tests smoke + test_results = {} + all_pass = True + for suite in tests: + sm = ct.run_smoke(suite, timeout=240) + test_results[suite] = sm + step("smoke_run", suite=suite, ok=sm.get("ok"), + passed=sm.get("passed"), total=sm.get("total")) + if not sm.get("ok"): + all_pass = False + report["tests"] = test_results + + # 7. Commit ou rollback + if all_pass: + cm = ct.git_commit_paths(f"Cortex self-dev: {goal[:60]}", applied, + only_if_smoke_passes=False) + if cm.get("ok"): + report["outcome"] = "applied" + step("committed", branch=branch) + # Mémorise sémantiquement la compétence acquise (modulaire, indexée + # automatiquement dans le graphe → rappel par retrieve_context la + # prochaine fois qu'un goal sémantiquement proche arrive). + try: + import cortex_learned_skills as _cls + short_name = goal.strip()[:60].rstrip(".:") + rem = _cls.remember(name=short_name, goal=goal, + outcome="applied", + applied_files=applied, + tests=test_results, + tags=["self_dev", "applied"]) + step("skill_remembered", ok=rem.get("ok"), path=rem.get("path"), + slug=rem.get("slug")) + except Exception as _ee: + step("skill_remember_failed", error=str(_ee)) + else: + report["outcome"] = "commit_failed" + step("commit_failed", error=cm.get("stderr")) + else: + report["outcome"] = "tests_failed_left_for_review" + step("stopped_for_review", reason="smoke tests failed", files=applied) + + report["duration_s"] = round(time.time() - started, 1) + _log(f"=== self_dev: outcome={report['outcome']} ({report['duration_s']}s) ===") + return report + + +# ─── Auto-génération de goals : boucle de curiosité ───────────────────────── +CURIOSITY_PROMPT = """Tu es Cortex, en train d'analyser ton propre fonctionnement. + +Voici les statistiques de ton router v2 sur les 50 derniers échanges : +{stats} + +Voici les 3 derniers échecs notables (smoke tests, parse errors, escalades vers Claude) : +{failures} + +Ton objectif : proposer UN seul micro-objectif d'amélioration concret et testable. +Le but est de réduire un échec récurrent OU augmenter ta robustesse OU améliorer la qualité. + +Format obligatoire — UNIQUEMENT JSON : +{{ + "goal": "description d'une seule action concrète (ex: 'ajouter un timeout configurable dans X', 'fix typo dans Y')", + "rationale": "pourquoi ce changement aide", + "risk": "low" ou "medium" +}} + +Privilégie les tâches "low" risk pour une boucle automatique. Réponds UNIQUEMENT avec le JSON.""" + + +def _read_recent_failures() -> str: + """Scanne le log self-dev récent pour collecter les échecs.""" + if not SELFDEV_LOG.exists(): return "(aucun échec récent)" + try: + lines = SELFDEV_LOG.read_text(encoding="utf-8", errors="replace").splitlines()[-200:] + failures = [l for l in lines if any(w in l.lower() for w in + ["fail", "error", "rolled_back", "rollback", "exception"])][-5:] + return "\n".join(failures) if failures else "(aucun échec dans les logs récents)" + except Exception: + return "(impossible de lire les logs)" + + +def _read_v2_stats() -> str: + try: + with urllib.request.urlopen("http://127.0.0.1:18900/v2_state", timeout=3) as r: + d = json.loads(r.read().decode()) + lines = [f"threshold={d.get('threshold')}"] + for k, v in d.get("models", {}).items(): + lines.append(f" {k}: priority={v['priority']:.2f}, wins={v['wins']}/{v['calls']}, " + f"avg_lat={v.get('avg_latency', 0):.1f}s, " + f"cooldown={v.get('in_cooldown')}") + return "\n".join(lines) + except Exception as e: + return f"(stats unavailable: {e})" + + +def autonomous_iteration(risk_threshold: str = "low") -> dict: + """Une itération autonome : génère un goal, le filtre par risque, applique. + Retourne le rapport. risk_threshold: 'low' refuse medium/high.""" + _log("=== autonomous iteration start ===") + stats = _read_v2_stats() + failures = _read_recent_failures() + prompt = CURIOSITY_PROMPT.format(stats=stats, failures=failures) + rv = _ask_router(prompt) + raw = rv.get("response") or rv.get("text") or "" + proposal = _parse_goal_proposal(raw) or {} + goal = proposal.get("goal", "") + risk = proposal.get("risk", "medium") + if not goal: + return {"outcome": "no_goal_generated", "raw": raw[:300]} + if risk_threshold == "low" and risk != "low": + return {"outcome": "risk_too_high", "goal": goal, "risk": risk, + "rationale": proposal.get("rationale")} + _log(f" autonomous goal: {goal} (risk={risk})") + return propose_and_apply(goal, dry_run=False) + + +# ─── CLI ───────────────────────────────────────────────────────────────────── +if __name__ == "__main__": + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + if len(sys.argv) < 2: + print("Usage:\n cortex_self_dev.py '' [--dry-run]\n cortex_self_dev.py --autonomous [low|medium]") + sys.exit(1) + if sys.argv[1] == "--autonomous": + risk = sys.argv[2] if len(sys.argv) > 2 else "low" + rep = autonomous_iteration(risk_threshold=risk) + else: + goal = sys.argv[1] + dry = "--dry-run" in sys.argv + rep = propose_and_apply(goal, dry_run=dry) + print("\n=== RAPPORT ===") + print(json.dumps(rep, ensure_ascii=False, indent=2)[:5000]) diff --git a/scripts/brain/dashboard/Cortex.bat b/scripts/brain/dashboard/Cortex.bat new file mode 100644 index 0000000000..3cce204d05 --- /dev/null +++ b/scripts/brain/dashboard/Cortex.bat @@ -0,0 +1,70 @@ +@echo off +REM ───────────────────────────────────────────────────────────────────── +REM Cortex — lanceur serveur dashboard avec auto-restart sur crash. +REM À déposer sur le bureau ou via raccourci .lnk. +REM ───────────────────────────────────────────────────────────────────── +title Cortex serveur (auto-restart) +color 0A + +cd /d h:\Code\Paperclip + +REM LM Studio policy for Cortex auto tasks +set LMSTUDIO_BASE_URL=http://127.0.0.1:1234 +set LMSTUDIO_FAST_MODEL=qwen2.5-7b-instruct +set LMSTUDIO_DEEP_MODEL=qwen3.6-35b-a3b +set LMSTUDIO_EMBED_MODEL=text-embedding-nomic-embed-text-v1.5 +set LMSTUDIO_TTL=300 +set LMSTUDIO_ALLOW_DEEP_AUTO=0 +set LMSTUDIO_USE_NATIVE_API=0 +set LMSTUDIO_JIT_ENABLED=0 +set LM_STUDIO_EXE=G:\Lmstudio\LM Studio\LM Studio.exe +set LM_STUDIO_MODELS_URL=http://127.0.0.1:1234/v1/models +set CHROME_EXE=C:\Program Files\Google\Chrome\Application\chrome.exe +set CORTEX_CHROME_PROFILE=%USERPROFILE%\.paperclip\chrome-cortex +set CORTEX_URL=http://127.0.0.1:8765/ +set CORTEX_GPU_URL=http://127.0.0.1:8765/gpu + +echo Verification LM Studio... +curl.exe %LM_STUDIO_MODELS_URL% --max-time 2 >nul 2>&1 +if errorlevel 1 ( + echo LM Studio non detecte - lancement... + if exist "%LM_STUDIO_EXE%" ( + start "" "%LM_STUDIO_EXE%" + timeout /t 8 /nobreak >nul + ) else ( + echo ATTENTION: LM Studio.exe introuvable: %LM_STUDIO_EXE% + ) +) + +REM Tue les listeners port 8765 résiduels avant de démarrer (évite "port busy") +for /f "tokens=5" %%P in ('netstat -ano ^| findstr "LISTENING" ^| findstr ":8765 "') do ( + echo Cleanup ancien listener pid %%P... + taskkill /F /PID %%P >nul 2>&1 +) + +set RESTART_COUNT=0 + +:loop +echo. +echo [%date% %time%] Cortex demarre (run #%RESTART_COUNT%)... +echo URL : http://localhost:8765/gpu +echo. +if "%RESTART_COUNT%"=="0" ( + if exist "%CHROME_EXE%" ( + echo Preparation Chrome... + if not exist "%CORTEX_CHROME_PROFILE%" mkdir "%CORTEX_CHROME_PROFILE%" >nul 2>&1 + start "" cmd /c "timeout /t 6 /nobreak >nul && \"%CHROME_EXE%\" --user-data-dir=\"%CORTEX_CHROME_PROFILE%\" --new-window \"%CORTEX_URL%\" \"%CORTEX_GPU_URL%\"" + ) else ( + echo Chrome introuvable: %CHROME_EXE% + ) +) +python scripts\brain\dashboard\serve.py +set EXITCODE=%errorlevel% + +set /a RESTART_COUNT+=1 +echo. +echo [%date% %time%] Cortex termine (code %EXITCODE%) - relance dans 3s... +echo (Ctrl+C dans les 3 secondes pour stopper definitivement) +echo. +timeout /t 3 /nobreak >nul +goto loop diff --git a/scripts/brain/dashboard/brain_gpu.html b/scripts/brain/dashboard/brain_gpu.html new file mode 100644 index 0000000000..2f1afc74b3 --- /dev/null +++ b/scripts/brain/dashboard/brain_gpu.html @@ -0,0 +1,4190 @@ + + + + +Cortex + + + +
+
+ C + Cortex + veille +
+
+
LLM...
+
Quota...
+
Corps...
+
Vault...
+
Voix...
+
+
+ + + + +
+
+
+ + +
+ + +
+
+ ⬡ Cortex + +
+ + +
+
+
+
+
+
corps
+
+
+
+
+ évolution cerveau +
+ +
+
+
+
+
+ + +
+ intimité + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + +
+
+
Cerveau temps réel API
+ +
+ + +
+
Mode inféré
+
+
Repos
+
+
+
Aucune activation mesurée
+
+ +
+
+
Lecture opérateur
+
analyse…
+
+
Le cerveau démarre
+
Les signaux live arrivent ici pour vous dire si Cortex réfléchit, attend proprement ou semble bloqué.
+
+
+
+ + +
+ Activation neurale récente réel + +
+
+ + +
+ Traces Hebbian récentes réel disque + +
+
+ + +
+ Propagations synaptiques réel jsonl + + +
+
+ + +
Focus de l'attention inféré
+
+ + +
Concepts consolidés / en croissance world model
+
+ + +
+ Dernière décision autonome log + + +
+ +
+ + + + + + +
+
+
+
chargement…
+
+ + +
+
+ Journal des événements +
+ +
+
+ + +
+
+
Légende temps réel
+ +
+
+ +
3D · couleurs nœuds par dossier
+
+ +
3D · axe temporel (Z)
+
+ Les nœuds dont l'ID contient une date (`2026-04-30/...`) sont placés sur un tunnel temporel :
+ avant = aujourd'hui · arrière = ≥ 60 jours.
+ Tourne la caméra pour voir la chronologie de ta cognition. +
+ +
Lire les amas
+
+ • blob dense = vocabulaire partagé fort (TF-IDF cosine élevée). Souvent l'obsession actuelle du cerveau.
+ • nœud loin du blob = vocabulaire unique, pas de pont sémantique. Candidat pour cortex_bridge.
+ • cluster détaché = un autre dossier ancré ailleurs sur la sphère Fibonacci. +
+ +
3D - états dynamiques
+
+
 point vert électrique = nœud activé (Collins & Loftus 1975 - Spreading Activation)
+
 comète vert-blanc qui glisse = pulse synaptique de A vers B (durée 1.1 s, queue de 4 points)
+
- - -  arête sémantique (cosine TF-IDF > seuil)
+
orphelin éloigné  = peu d'arêtes, retenu par son ancre dossier (Fibonacci sphere)
+
+ +
Évolution cerveau (sparkline)
+
+
N = nb de nœuds dans la graphe de pensée (claude_memory + Semantic + 100 derniers épisodes)
+
E = arêtes sémantiques (cosine > 0.15)
+
A = nœuds actifs maintenant (décroissance τ = 60 s)
+
H = somme des renforcements Hebbian cumulés (apprentissage)
+
● vert = pas de régression · ▲ rouge = chute > 8 % vs moyenne 24 h
+
+ +
Corps · seuils homeostasis
+
+
vert < 50 % · jaune < 78 % · orange < 90 % · rouge ≥ 90 %
+
CPU/RAM warn ≥ 70/75 % · alert ≥ 85 % · critical ≥ 92 % (pause loops)
+
Disque ≥ 90 % → Cortex propose un déménagement (ne supprime jamais sans confirmation)
+
+ +
Voix - spectrogramme
+
+
bleu = TTS Cortex parle · vert = Sam parle · gris = idle
+
TTS / VAD = service up/down (xtts_daemon, voice_input)
+
+ +
Boutons (refonte codex)
+
+
MIC ON/OFF - micro · EYE ON/OFF - vision · TTS ON/OFF - voix sortie
+
RUN - lance/pause physique du cerveau · ROT - rotation auto
+
? - Cortex explique son cerveau dans le chat (topologie + état)
+
DOC - cette légende · SET - paramètres devices · CAL - calibration vocale
+
+ +
Slash-commands (chat)
+
+
/help - liste des commandes
+
/open <chemin> - ouvre dans VSCode
+
/find <pattern> - cherche fichiers
+
/grep <texte> - cherche dans le code
+
/code <objectif> - patch dry-run via cortex_self_dev
+
/run <script.py> - exécute un Python du repo
+
/test [path] - lance pytest
+
+ +
+ Sources : Collins & Loftus 1975 · Hebb 1949 · Fruchterman & Reingold 1991 · + Cannon 1932 · Friston 2010 · LeCun 2022 · Tononi 2008 · Raichle 2001 +
+
+
+ + + + + +
+
+
+ Chat + +
+
+ chat vivant + + + +
+
+
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+
+
+
+
+ +
+
+
+ +
+
+ + +
scroll=zoom · drag=rotate · GPU
+ + + + + + + + + + + + + + + + + + diff --git a/scripts/brain/dashboard/serve.py b/scripts/brain/dashboard/serve.py new file mode 100644 index 0000000000..48bcbb1f2a --- /dev/null +++ b/scripts/brain/dashboard/serve.py @@ -0,0 +1,3874 @@ +""" +serve.py — Tiny HTTP server that exposes the brain state as JSON for the +live HTML dashboard. Reads from the vault's existing artefacts; no DB, no +caching beyond file mtime. + +Endpoints: + GET / → dashboard HTML + GET /api/state → current snapshot (graph + activity + resources) + GET /api/state?delta=true → minimal delta since last call (active nodes only) + +Default port: 8765. Localhost-only (no exposure). +""" +import datetime as dt +import http.server +import json +import time +import os +import socketserver +import subprocess +import sys +import threading +from pathlib import Path +from urllib.parse import urlparse, parse_qs + +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +VAULT = Path(os.environ.get("VAULT_PATH", r"C:\Users\Smedj\Documents\Obsidian Vault")) +HERE = Path(__file__).parent +PORT = int(os.environ.get("BRAIN_DASHBOARD_PORT", "8765")) +CHAT_STREAM_FILE = VAULT / ".cortex-chat-stream.jsonl" +EMERGENCE_STREAM_FILE = VAULT / ".cortex-emergence-stream.jsonl" +PLAYTEST_DIR = HERE / "playtests" +PLAYTEST_BASE_URL = f"http://127.0.0.1:{PORT}/playtests" +ROUTER_BENCHMARK_FILE = HERE / "state" / "router_benchmarks.json" +OPENCODE_CMD = Path(r"C:\Users\Smedj\AppData\Roaming\npm\opencode.cmd") + +GRAPH_FILE = VAULT / ".vault-graph.json" +LAYOUT_FILE = VAULT / ".vault-graph-layout.json" +PAGERANK_FILE = VAULT / ".vault-pagerank.json" +COMMUNITIES_FILE = VAULT / ".vault-communities.json" +ACTIVITY_STATE = VAULT / ".vault-activity-state.json" +RESOURCES_FILE = VAULT / ".vault-resources.json" +JEPA_STATUS = VAULT / ".vault-jepa-status.json" + + +_cache: dict = {"snapshot": None, "snapshot_mtime": 0} +_lock = threading.Lock() + +CONFIGURED_MODEL_PRIORS = { + "minimax_fast": { + "strengths": ["fast", "french", "chat", "summarization"], + "weaknesses": ["deep_reasoning", "complex_code"], + "cost": "low", + }, + "gpt_5_nano": { + "strengths": ["structured", "fast_reasoning"], + "weaknesses": [], + "cost": "low", + }, + "big_pickle": { + "strengths": ["math", "short_factual"], + "weaknesses": [], + "cost": "low", + }, + "hy3_preview": { + "strengths": ["reasoning"], + "weaknesses": [], + "cost": "low", + }, + "nemotron_3_super": { + "strengths": ["reasoning", "long_answer"], + "weaknesses": [], + "cost": "low", + }, + "playtest_builder": { + "strengths": ["playtest_html"], + "weaknesses": [], + "cost": "low", + }, + "direct_guardrail": { + "strengths": ["truth", "safe_direct_answer"], + "weaknesses": [], + "cost": "low", + }, +} + + +def _is_chat_entry(entry: dict | None) -> bool: + if not isinstance(entry, dict): + return False + return entry.get("speaker") in (None, "", "cortex", "sam_typed", "claude") + + +def _append_jsonl(path: Path, entry: dict): + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + +def _write_json_atomic(path: Path, payload: dict): + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) + + +def _safe_int(value, default: int = 0) -> int: + try: + return int(value) + except Exception: + return default + + +def _safe_float(value, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return default + + +def _infer_complexity(message: str, intent_name: str = "") -> str: + text = (message or "").strip().lower() + if intent_name in ("recent_web_search", "playtest_dashboard_help", "identity"): + return "simple" + if intent_name == "playtest_code_task": + return "hard" + if len(text) > 500: + return "hard" + hard_markers = [ + "architecture", "stabiliser", "stabilize", "debug", "diagnostic", + "plan", "planifie", "pourquoi", "analyse", "compare", "router", + "judge", "consortium", "server", "crash", "timeout", + ] + medium_markers = [ + "explique", "comment", "résume", "resume", "problème", "probleme", + "mémoire", "memoire", "vault", "fichier", "repo", + ] + if any(marker in text for marker in hard_markers): + return "hard" + if any(marker in text for marker in medium_markers): + return "medium" + return "simple" + + +def _is_simple_fact_question(message: str) -> bool: + text = (message or "").strip().lower() + if not text or len(text) > 160: + return False + if any(marker in text for marker in ["```", "/code", "debug", "architecture", "plan", "analyse", "compare"]): + return False + return any(text.startswith(prefix) for prefix in [ + "quelle", "quel", "qui", "où", "ou", "quand", "combien", "c'est quoi", "explique-moi brièvement", + ]) + + +def _is_playtest_code_request(message: str) -> bool: + text = (message or "").strip().lower() + if not text.startswith("/code"): + return False + markers = ["playtest", "app", "application", "html", "interface", "calculatrice", "todo", "kanban", "dashboard"] + return any(marker in text for marker in markers) + + +def _read_recent_history(max_turns: int = 4, include_responses: bool = True) -> list[dict]: + turns: list[dict] = [] + if not CHAT_STREAM_FILE.exists(): + return turns + try: + lines = CHAT_STREAM_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-120:] + except Exception: + return turns + for raw in reversed(lines): + try: + entry = json.loads(raw) + except Exception: + continue + if not _is_chat_entry(entry): + continue + msg = (entry.get("msg") or "").strip() + response = (entry.get("response") or "").strip() + if not msg and not response: + continue + turns.append({ + "msg": msg[:400], + "response": response[:500] if include_responses else "", + "speaker": entry.get("speaker") or "cortex", + "meta": entry.get("meta") or {}, + }) + if len(turns) >= max_turns: + break + turns.reverse() + return turns + + +def _history_prompt(turns: list[dict], for_code: bool = False) -> tuple[str, int]: + if not turns: + return "", 0 + chunks = [] + for turn in turns[-5:]: + msg = (turn.get("msg") or "").strip() + response = (turn.get("response") or "").strip() + if not msg: + continue + if for_code: + chunks.append(f"Sam: {msg[:220]}") + else: + block = f"Sam: {msg[:220]}" + if response: + block += f"\nCortex: {response[:260]}" + chunks.append(block) + if not chunks: + return "", 0 + return "\n\nHistorique utile récent:\n" + "\n---\n".join(chunks), len(chunks) + + +def _extract_html_document(text: str) -> str: + raw = (text or "").strip() + if not raw: + return "" + if raw.startswith("```"): + parts = raw.split("```") + for part in parts: + candidate = part.strip() + if "= 0: + raw = raw[start:] + end = raw.lower().rfind("") + if end >= 0: + raw = raw[:end + len("")] + return raw.strip() + + +def _fallback_playtest_html(message: str) -> str: + title = "Playtest Cortex" + if "calculatrice" in (message or "").lower(): + title = "Calculatrice Playtest" + body = """ +
+
+

Calculatrice locale

+

Fallback autonome généré par Cortex. Aucun CDN, aucun backend.

+
0
+
+
+
+ +""" + else: + body = f""" +
+
+

{title}

+

Fallback HTML autonome généré après un échec LLM.

+ +
+ + +
+

Prêt.

+
+
+ +""" + return f""" + + + + + {title} + + +{body} + +""" + + +def _write_playtest_file(html: str) -> tuple[Path, str]: + PLAYTEST_DIR.mkdir(parents=True, exist_ok=True) + stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"generated_{stamp}.html" + file_path = PLAYTEST_DIR / filename + file_path.write_text(html, encoding="utf-8") + return file_path, f"{PLAYTEST_BASE_URL}/{filename}" + + +def _playtest_file_from_name(name: str) -> Path | None: + if not name or "/" in name or "\\" in name or ".." in name or ":" in name: + return None + if not name.lower().endswith(".html"): + return None + target = (PLAYTEST_DIR / name).resolve() + try: + target.relative_to(PLAYTEST_DIR.resolve()) + except Exception: + return None + return target + + +def _load_router_benchmarks() -> dict: + data = _safe_load(ROUTER_BENCHMARK_FILE) + if isinstance(data, dict) and "backends" in data: + return data + return {"updated_at": None, "backends": {}} + + +def _update_router_benchmarks(backend: str, latency_s: float, status: str, domains: list[str], judge_score: float | None = None): + if not backend: + return + data = _load_router_benchmarks() + backends = data.setdefault("backends", {}) + item = backends.setdefault(backend, { + "calls": 0, + "success": 0, + "empty_responses": 0, + "timeouts": 0, + "errors": 0, + "avg_latency_s": 0.0, + "judge_score_avg": 0.0, + "judge_score_count": 0, + "domains": {}, + }) + item["calls"] += 1 + prev_calls = max(item["calls"] - 1, 0) + if status == "ok": + item["success"] += 1 + elif status == "timeout": + item["timeouts"] += 1 + elif status == "empty": + item["empty_responses"] += 1 + else: + item["errors"] += 1 + latency_s = max(0.0, _safe_float(latency_s)) + item["avg_latency_s"] = ((item["avg_latency_s"] * prev_calls) + latency_s) / max(item["calls"], 1) + if judge_score is not None: + count = _safe_int(item.get("judge_score_count")) + avg = _safe_float(item.get("judge_score_avg")) + item["judge_score_avg"] = ((avg * count) + judge_score) / (count + 1) + item["judge_score_count"] = count + 1 + for domain in domains or []: + if not domain: + continue + item["domains"][domain] = _safe_int(item["domains"].get(domain)) + 1 + data["updated_at"] = dt.datetime.now().isoformat() + try: + _write_json_atomic(ROUTER_BENCHMARK_FILE, data) + except Exception as exc: + print(f"[router benchmarks] {exc}", flush=True) + +# Heartbeat global : timestamp de démarrage du serveur (pour uptime) +SERVER_STARTED_AT = time.time() + +# Configuration heartbeat éditable (persistée sur disque) — Sam peut modifier ces +# valeurs depuis l'UI en cliquant sur la chip Live. +HEARTBEAT_CONFIG_FILE = Path(r"H:\Code\Paperclip\.cortex-heartbeat-config.json") +HEARTBEAT_CONFIG_DEFAULTS = { + "dead_threshold_s": 5.0, # si fige > N s : "Cortex est mort" + "poll_min_ms": 800, # poll client minimum (charge faible) + "poll_max_ms": 3000, # poll client maximum (charge haute) + "snapshot_interval_s": 60, # tracker progression : 1 snap / N s + "tempo_base_ms": 900, # base heartbeat dot animation + "wander_interval_s": 45, # cortex_activation WANDER_INTERVAL + "emergence_interval_s": 300, # cortex_emergence INTERVAL_SEC +} + +def _load_heartbeat_config() -> dict: + cfg = dict(HEARTBEAT_CONFIG_DEFAULTS) + try: + if HEARTBEAT_CONFIG_FILE.exists(): + user = json.loads(HEARTBEAT_CONFIG_FILE.read_text(encoding="utf-8")) + for k, v in user.items(): + if k in cfg: + try: cfg[k] = type(cfg[k])(v) + except Exception: pass + except Exception: pass + return cfg + +def _save_heartbeat_config(updates: dict) -> dict: + cfg = _load_heartbeat_config() + for k, v in (updates or {}).items(): + if k in HEARTBEAT_CONFIG_DEFAULTS: + try: cfg[k] = type(HEARTBEAT_CONFIG_DEFAULTS[k])(v) + except Exception: pass + try: + HEARTBEAT_CONFIG_FILE.write_text( + json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8") + except Exception as e: + return {"ok": False, "error": str(e), "config": cfg} + return {"ok": True, "config": cfg} + +# Historique des durées de chat — pour prédire l'ETA du prochain (p50/p90) +import collections as _coll +CHAT_DURATIONS = _coll.deque(maxlen=20) +CHAT_LAST_DONE_TS = 0.0 + +# Tracker de progression pour les tooltips temps réel. +# Chaque clé garde un deque (ts, value) — snapshots toutes ~60 s par background thread. +# Fournit deltas 1h/24h pour montrer l'évolution sur les chips topbar. +PROGRESSION = { + "vault_sem": _coll.deque(maxlen=2880), # 48 h à 1 snap/min + "vault_ep": _coll.deque(maxlen=2880), + "llm_winner": _coll.deque(maxlen=2880), # backend gagnant courant + "cpu": _coll.deque(maxlen=2880), + "ram": _coll.deque(maxlen=2880), + "n_active": _coll.deque(maxlen=2880), + "hebbian_total": _coll.deque(maxlen=2880), + "n_pulses": _coll.deque(maxlen=2880), + "n_chats": _coll.deque(maxlen=2880), + "n_emergences": _coll.deque(maxlen=2880), +} +PROGRESSION_LOCK = threading.Lock() + +def _progression_snapshot_loop(): + """Background : capture les valeurs courantes toutes les 60s.""" + while True: + try: + now = time.time() + sample = {} + # Vault counts depuis le snapshot existant + try: + snap = _cache.get("snapshot") or {} + vlt = (snap.get("vault") or {}) + sample["vault_sem"] = vlt.get("semantic", 0) + sample["vault_ep"] = vlt.get("episodic", 0) + except Exception: pass + # Vitals + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_homeostasis as _ch + vit = _ch.vital_signs() or {} + sample["cpu"] = (vit.get("cpu") or {}).get("percent", 0) + sample["ram"] = (vit.get("ram") or {}).get("percent", 0) + except Exception: pass + # Activations + try: + import cortex_activation as _ca + a = _ca.snapshot() + sample["n_active"] = a.get("n_active", 0) + sample["hebbian_total"] = a.get("n_edges_total", 0) + sample["n_pulses"] = a.get("cum_pulses", 0) + except Exception: pass + # Chats / emergences cumulés + sample["n_chats"] = len(CHAT_DURATIONS) # approx — count last 20 + try: + stream_file = EMERGENCE_STREAM_FILE + n_em = 0 + if stream_file.exists(): + for line in stream_file.read_text(encoding="utf-8", + errors="replace").splitlines()[-1000:]: + try: + o = json.loads(line) + if o.get("speaker") == "cortex_emergence": + n_em += 1 + except Exception: pass + sample["n_emergences"] = n_em + except Exception: pass + # LLM gagnant courant — lit le dernier round du benchmark + try: + bf = VAULT / ".vault-llm-benchmark-iag.json" + if bf.exists(): + raw = json.loads(bf.read_text(encoding="utf-8")) + last = (raw.get("rounds") or [])[-1:] or [{}] + sample["llm_winner"] = last[0].get("winner", "?") + except Exception: pass + with PROGRESSION_LOCK: + for k, v in sample.items(): + if k in PROGRESSION: + PROGRESSION[k].append((now, v)) + except Exception: pass + time.sleep(60) + +threading.Thread(target=_progression_snapshot_loop, daemon=True).start() + +# Tracker de progression du pipeline /api/chat — déterministe, lu par /api/cortex/think_status. +# Chaque étape est posée explicitement par le handler (pas de simulation). +CHAT_PROGRESS = {"req_id": None, "stages": [], "started": 0, "done": False} +CHAT_PROGRESS_LOCK = threading.Lock() + +def _chat_stage(req_id: str, name: str, detail: str = ""): + """Pose une étape de progression réelle pour une requête /api/chat.""" + if not req_id: return + with CHAT_PROGRESS_LOCK: + if CHAT_PROGRESS.get("req_id") != req_id: + CHAT_PROGRESS["req_id"] = req_id + CHAT_PROGRESS["started"] = time.time() + CHAT_PROGRESS["stages"] = [] + CHAT_PROGRESS["done"] = False + # Ferme l'étape précédente + if CHAT_PROGRESS["stages"]: + CHAT_PROGRESS["stages"][-1]["ended"] = time.time() + CHAT_PROGRESS["stages"].append({ + "name": name, "detail": detail, + "started": time.time(), "ended": None, + }) + +def _chat_stage_done(req_id: str): + if not req_id: return + global CHAT_LAST_DONE_TS + with CHAT_PROGRESS_LOCK: + if CHAT_PROGRESS.get("req_id") == req_id: + if CHAT_PROGRESS["stages"]: + CHAT_PROGRESS["stages"][-1]["ended"] = time.time() + CHAT_PROGRESS["done"] = True + duration = time.time() - (CHAT_PROGRESS.get("started") or time.time()) + if duration > 0.1 and duration < 600: + CHAT_DURATIONS.append(duration) + CHAT_LAST_DONE_TS = time.time() + +COOKIES_FILE = Path.home() / ".claude" / ".claude-cookies.json" +LM_STUDIO_EXE = Path(r"G:\Lmstudio\LM Studio\LM Studio.exe") +try: + from lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +except Exception: + from scripts.brain.lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +LM_STUDIO_URL = get_lmstudio_config()["base_url"] + "/v1/chat/completions" + +def lm_studio_running() -> bool: + try: + import urllib.request as _ur + _ur.urlopen(get_lmstudio_config()["base_url"] + "/v1/models", timeout=2) + return True + except Exception: + return False + +def ensure_lm_studio() -> bool: + """Lance LM Studio si pas déjà actif. Retourne True si prêt.""" + if lm_studio_running(): + return True + if not LM_STUDIO_EXE.exists(): + return False + import subprocess as _sp + print("[cortex] LM Studio non actif — lancement auto...", flush=True) + _sp.Popen([str(LM_STUDIO_EXE)], creationflags=_sp.CREATE_NO_WINDOW if hasattr(_sp, "CREATE_NO_WINDOW") else 0) + # Attendre jusqu'à 45s que l'API soit disponible + for _ in range(45): + time.sleep(1) + if lm_studio_running(): + print("[cortex] LM Studio prêt.", flush=True) + return True + print("[cortex] LM Studio timeout.", flush=True) + return False +ORG_UUID = "952c1bc7-5fd1-4f7c-83db-a020932db2ab" +_metrics_cache: dict = {"data": None, "ts": 0.0} + +def _get_session_key() -> str: + """Lit le sessionKey depuis le fichier sauvegardé.""" + if COOKIES_FILE.exists(): + try: + return json.loads(COOKIES_FILE.read_text(encoding="utf-8")).get("sessionKey", "") + except Exception: + pass + return "" + +def get_metrics() -> dict: + import urllib.request, time, socket + now = time.time() + if _metrics_cache["data"] and now - _metrics_cache["ts"] < 30: + return _metrics_cache["data"] + + # Voice pipeline health + def port_up(port): + try: + s = socket.socket(); s.settimeout(0.5) + s.bind(("127.0.0.1", port)); s.close(); return False + except OSError: return True + + MIC_CFG = Path(r"H:\Code\Paperclip\scripts\voice\mic_config.json") + try: mic_cfg = json.loads(MIC_CFG.read_text(encoding="utf-8")) + except: mic_cfg = {"name": "DOQAUS", "index": None} + voice = { + "tts_monitor": port_up(18766), + "voice_input": port_up(18767), + "mic": mic_cfg, + "tts_disabled": (VAULT / ".tts-disabled.flag").exists(), + "mic_muted": (VAULT / ".voice-muted.flag").exists(), + } + + # Router status + router_status = None + try: + import urllib.request as _ur + with _ur.urlopen("http://127.0.0.1:18900/status", timeout=2) as r: + router_status = json.loads(r.read().decode()) + except Exception: + pass + + # Usage — sessionKey seul suffit avec headers browser-like + usage = None + try: + sk = _get_session_key() + if sk: + req = urllib.request.Request( + f"https://claude.ai/api/organizations/{ORG_UUID}/usage", + headers={ + "Cookie": f"sessionKey={sk}", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "application/json", + "Referer": "https://claude.ai/settings/usage", + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + } + ) + with urllib.request.urlopen(req, timeout=6) as r: + usage = json.loads(r.read().decode()) + except Exception: + pass + + # Vault stats + vault_stats = None + semantic_dir = VAULT / "08 - Semantic" + ingested_dir = VAULT / "07 - Ingested" + if semantic_dir.exists() or ingested_dir.exists(): + sem = sum(1 for _ in semantic_dir.rglob("*.md")) if semantic_dir.exists() else 0 + ep = sum(1 for _ in ingested_dir.rglob("*.md")) if ingested_dir.exists() else 0 + vault_stats = {"semantic": sem, "episodic": ep} + + result = { + "ts": dt.datetime.now().isoformat(), + "voice": voice, + "usage": usage, + "vault": vault_stats, + "router": router_status, + "tts_playing": (VAULT / ".tts-playing.flag").exists(), + "voice_muted": (VAULT / ".voice-muted.flag").exists(), + "user_speaking": (VAULT / ".voice-speaking.flag").exists(), + } + _metrics_cache["data"] = result + _metrics_cache["ts"] = now + return result + + +def file_mtime(p: Path) -> float: + try: + return p.stat().st_mtime + except Exception: + return 0.0 + + +def load_snapshot() -> dict: + """Recompute snapshot if any source file changed.""" + sources = [GRAPH_FILE, LAYOUT_FILE, PAGERANK_FILE, COMMUNITIES_FILE, ACTIVITY_STATE, RESOURCES_FILE, JEPA_STATUS] + max_mtime = max(file_mtime(p) for p in sources) + with _lock: + if _cache.get("snapshot") and _cache.get("snapshot_mtime", 0) >= max_mtime - 0.5: + # Only refresh activity state every tick (cheap) + try: + _cache["snapshot"]["activity"] = _read_activity() + _cache["snapshot"]["resources"] = _safe_load(RESOURCES_FILE) + _cache["snapshot"]["jepa_status"] = _safe_load(JEPA_STATUS) + except Exception: + pass + return _cache["snapshot"] + + graph = _safe_load(GRAPH_FILE) or {"nodes": [], "edges": []} + layout = _safe_load(LAYOUT_FILE) or {} + positions = layout.get("positions") or [] + pagerank = (_safe_load(PAGERANK_FILE) or {}).get("pagerank", {}) + communities = _safe_load(COMMUNITIES_FILE) or {"nodes": [], "labels": []} + com_map = {communities["nodes"][i]: communities["labels"][i] for i in range(len(communities.get("nodes", [])))} if communities.get("nodes") else {} + + # Build nodes with all attributes including precomputed positions + nodes_out = [] + for i, path in enumerate(graph.get("nodes", [])): + top_dir = path.split("/", 1)[0] if "/" in path else path + pos = positions[i] if i < len(positions) else [0, 0] + nodes_out.append({ + "id": path, + "name": path.split("/")[-1].replace(".md", "")[:40], + "folder": top_dir, + "centrality": float(pagerank.get(path, 0.0)), + "community": int(com_map.get(path, -1)), + "x": float(pos[0]), + "y": float(pos[1]), + }) + + snap = { + "captured_at": dt.datetime.now().isoformat(), + "nodes": nodes_out, + "edges": graph.get("edges", []), + "stats": graph.get("stats", {}), + "has_layout": bool(positions), + "activity": _read_activity(), + "resources": _safe_load(RESOURCES_FILE), + "jepa_status": _safe_load(JEPA_STATUS), + } + _cache["snapshot"] = snap + _cache["snapshot_mtime"] = max_mtime + return snap + + +def _safe_load(p: Path): + try: + return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None + except Exception: + return None + + +def _read_activity() -> dict: + """Return {note_path: expires_iso}.""" + s = _safe_load(ACTIVITY_STATE) + if not s: + return {} + return s.get("tagged", {}) + + +class Handler(http.server.SimpleHTTPRequestHandler): + # ─── Protection réseau Windows ────────────────────────────────────────── + # Sur Windows, un client (browser) qui annule un poll en cours déclenche + # WinError 10053 (ConnectionAborted) ou 10054 (ConnectionReset). Ces + # erreurs remontaient avant jusqu'au handler global et faisaient bruiter + # les logs. On les attrape silencieusement : ce sont des cas normaux, + # pas des bugs serveur. + def handle_one_request(self): + try: + super().handle_one_request() + except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError): + self.close_connection = True + except Exception as e: + # Évite que le thread du serveur meure sur n'importe quelle exception + # (le ThreadingTCPServer relance, mais autant logger proprement) + try: + import traceback as _tb + msg = str(e) + if any(s in msg for s in ('10053', '10054', '10038', 'BrokenPipe', + 'ConnectionAbort', 'ConnectionReset')): + self.close_connection = True + return + print(f"[handler] {type(e).__name__}: {e}\n{_tb.format_exc()[-500:]}", + flush=True) + self.close_connection = True + except Exception: pass + + def log_error(self, format, *args): + # Silence les erreurs réseau Windows banales (10053, 10054, BrokenPipe) + try: + msg = format % args + if any(s in str(msg) for s in ('10053', '10054', '10038', + 'ConnectionAbort', 'ConnectionReset', + 'BrokenPipe')): + return + except Exception: pass + super().log_error(format, *args) + + def _safe_send_error(self, code: int, message: str = ""): + """send_error qui ne crash pas si le client a fermé la connexion.""" + try: + self.send_error(code, message) + except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError): + try: self.close_connection = True + except Exception: pass + + def _send_json(self, payload: dict, status: int = 200): + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(data) + + def _send_text_file(self, path: Path, ctype: str): + data = path.read_bytes() + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(data) + + def _controlled_chat_error(self, message: str, intent_name: str = "simple_chat", req_id: str = "", extra_meta: dict | None = None) -> dict: + meta = { + "backend": "error_guard", + "intent": intent_name or "simple_chat", + "error": message, + "tools_used": [], + "confidence": "low", + "complexity": "medium", + "routing_decision": "controlled_error", + "router_used": False, + "judge_used": False, + "selected_backend": "error_guard", + "selection_reason": "controlled exception captured by /api/chat guard", + "history_used": False, + "history_count": 0, + "benchmark_basis": { + "internal_observed": False, + "configured_priors": True, + "official_sources": [], + }, + } + if req_id: + meta["req_id"] = req_id + if extra_meta: + meta.update(extra_meta) + return {"response": f"Erreur contrôlée: {message}", "meta": meta, "req_id": req_id} + + def _call_opencode_chat(self, prompt: str, timeout_s: int = 35, model_id: str = "opencode/minimax-m2.5-free") -> tuple[str, str | None]: + if not OPENCODE_CMD.exists(): + return "", "opencode_unavailable" + try: + run = subprocess.run( + [str(OPENCODE_CMD), "run", "--model", model_id, "-"], + input=prompt, + capture_output=True, + text=True, + timeout=timeout_s, + encoding="utf-8", + errors="replace", + ) + lines = [ + line for line in (run.stdout or "").splitlines() + if line.strip() and not line.startswith(">") and "\x1b" not in line and "build" not in line.lower() + ] + response = "\n".join(lines).strip() + return response, None if response else "empty_response" + except subprocess.TimeoutExpired: + return "", "timeout" + except Exception as exc: + return "", str(exc) + + def _build_chat_payload(self, msg: str, intent_name: str, role: str, history_text: str, extra_context: str = "") -> str: + try: + import cortex_identity as _cortex_identity + identity = _cortex_identity.identity_prompt() + except Exception: + identity = "Tu es Cortex, assistant local fiable pour Paperclip." + parts = [ + identity.strip(), + "Réponds en français. Sois utile, concret, et ne prétends jamais avoir utilisé un outil absent.", + ] + if extra_context: + parts.append(extra_context.strip()) + if history_text: + parts.append(history_text.strip()) + if intent_name == "playtest_code_task": + parts.append( + "Retourne uniquement un document HTML autonome complet. Un seul fichier. CSS inline. JS inline. Aucune dépendance externe." + ) + elif role == "code": + parts.append("Si tu proposes du code, reste précis et orienté exécution.") + parts.append(f"Message actuel de Sam:\n{msg.strip()}") + return "\n\n".join(part for part in parts if part) + + def _append_chat_stream_entry(self, msg: str, response: str, meta: dict): + try: + entry = {"ts": time.time(), "msg": msg, "response": response, "meta": meta} + _append_jsonl(CHAT_STREAM_FILE, entry) + except Exception as exc: + print(f"[chat stream] {exc}", flush=True) + + def _build_selection_reason(self, backend: str, route: str, complexity: str, priors_used: bool, internal_observed: bool) -> str: + reason = f"route={route}, complexity={complexity}, backend={backend}" + if priors_used: + reason += ", configured_model_priors used" + if internal_observed: + reason += ", internal_observed_data available" + return reason + + def _maybe_log_episodic(self, msg: str, response: str, meta: dict): + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_memory as _cm + if response and not response.startswith("Erreur contrôlée"): + _cm.log_episodic(msg, response, meta) + except Exception as exc: + print(f"[chat memory log] {exc}", flush=True) + + def _generate_playtest(self, msg: str, history_text: str, req_id: str) -> dict: + _chat_stage(req_id, "Playtest builder", "generation HTML autonome + sauvegarde locale") + prompt = self._build_chat_payload( + msg, + "playtest_code_task", + "code", + history_text, + extra_context=( + "Objectif: construire une mini-app web visible immédiatement dans le Playtest Cortex local. " + "N'inclus ni explication ni markdown autour du HTML." + ), + ) + html, llm_error = self._call_opencode_chat(prompt, timeout_s=40) + html_doc = _extract_html_document(html) + used_fallback = False + if not html_doc: + used_fallback = True + html_doc = _fallback_playtest_html(msg) + file_path, playtest_url = _write_playtest_file(html_doc) + response = f"Fichier créé: {file_path.as_posix()} URL Playtest: {playtest_url}" + meta = { + "intent": "playtest_code_task", + "complexity": "hard", + "routing_decision": "playtest_builder_direct", + "router_used": False, + "judge_used": False, + "selected_backend": "playtest_builder", + "selection_reason": "playtest code request bypassed route_v2 and used local HTML builder", + "backend": "playtest_builder", + "tools_used": ["file_write"], + "confidence": "high" if not used_fallback else "medium", + "evidence_count": 1, + "playtest_path": str(file_path.relative_to(Path(r"H:\Code\Paperclip"))).replace("\\", "/"), + "playtest_url": playtest_url, + "auto_open_playtest": True, + "route_reason": "generated_playtest_html", + "history_used": bool(history_text), + "history_count": history_text.count("Sam:"), + "benchmark_basis": { + "internal_observed": False, + "configured_priors": True, + "official_sources": [], + }, + } + if llm_error: + meta["llm_error"] = llm_error + return {"response": response, "meta": meta, "req_id": req_id} + + def _handle_api_chat(self): + import urllib.request as _ur + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + + length = _safe_int(self.headers.get("Content-Length", "0")) + raw_body = self.rfile.read(length).decode("utf-8-sig") if length > 0 else "{}" + body = json.loads(raw_body or "{}") + msg = (body.get("message") or "").strip() + req_id = body.get("req_id") or f"r{int(time.time()*1000)}" + if not msg: + return self._controlled_chat_error("message vide", "simple_chat", req_id) + + _chat_stage(req_id, "Réception", "parse + intent") + try: + import cortex_intent as _ci + intent = _ci.detect_intent(msg) + except Exception as exc: + print(f"[chat intent] {exc}", flush=True) + intent = {"intent": "simple_chat", "confidence": "medium", "route_reason": "intent_fallback"} + intent_name = intent.get("intent") if isinstance(intent, dict) else getattr(intent, "intent", "simple_chat") + confidence = intent.get("confidence") if isinstance(intent, dict) else getattr(intent, "confidence", "medium") + complexity = _infer_complexity(msg, intent_name) + history_turns = _read_recent_history(max_turns=4, include_responses=not _is_playtest_code_request(msg)) + history_text, history_count = _history_prompt(history_turns, for_code=_is_playtest_code_request(msg)) + history_used = bool(history_text) + role = "code" if msg.lower().startswith("/code") or any(token in msg.lower() for token in ["python", "git", "serve.py", "router"]) else "general" + base_meta = { + "intent": intent_name, + "complexity": complexity, + "tools_used": [], + "confidence": confidence, + "history_used": history_used, + "history_count": history_count, + "benchmark_basis": { + "internal_observed": False, + "configured_priors": True, + "official_sources": [], + }, + "req_id": req_id, + } + + if intent_name == "recent_web_search": + payload = { + "response": "Je dois lancer une recherche web réelle avant de répondre.", + "meta": { + **base_meta, + "backend": "direct_guardrail", + "routing_decision": "guardrail_recent_web_search", + "router_used": False, + "judge_used": False, + "selected_backend": "direct_guardrail", + "selection_reason": "recent_web_search requires a real web tool first", + "route_reason": "needs_web_search", + "needs_web_search": True, + "needs_vault_search": False, + }, + "req_id": req_id, + } + self._append_chat_stream_entry(msg, payload["response"], payload["meta"]) + return payload + + if intent_name in ("local_project_search", "vault_memory_search"): + payload = { + "response": "Je dois d'abord chercher dans le vault, la mémoire ou les fichiers locaux avant d'affirmer quelque chose sur ce projet.", + "meta": { + **base_meta, + "backend": "direct_guardrail", + "routing_decision": "guardrail_local_project_search", + "router_used": False, + "judge_used": False, + "selected_backend": "direct_guardrail", + "selection_reason": "local project claims require real evidence first", + "route_reason": "needs_vault_or_file_search", + "needs_web_search": False, + "needs_vault_search": True, + }, + "req_id": req_id, + } + self._append_chat_stream_entry(msg, payload["response"], payload["meta"]) + return payload + + if intent_name == "playtest_dashboard_help": + payload = { + "response": ( + "Le playtest intégré est lié au dashboard Cortex local : http://127.0.0.1:8765/. " + "Tu peux utiliser le sidecar chat, l’onglet Playtest, l’onglet Consortium, et les APIs " + "/api/cortex/judges, /api/cortex/homeostasis et /api/chat." + ), + "meta": { + **base_meta, + "backend": "direct_guardrail", + "routing_decision": "direct_playtest_help", + "router_used": False, + "judge_used": False, + "selected_backend": "direct_guardrail", + "selection_reason": "safe dashboard help answer", + "route_reason": "dashboard_context_direct", + "needs_web_search": False, + "needs_vault_search": False, + }, + "req_id": req_id, + } + self._append_chat_stream_entry(msg, payload["response"], payload["meta"]) + return payload + + if intent_name == "identity": + payload = { + "response": "Je suis Cortex, l’assistant cognitif local de Sam pour le projet Paperclip.", + "meta": { + **base_meta, + "backend": "direct_guardrail", + "routing_decision": "direct_identity", + "router_used": False, + "judge_used": False, + "selected_backend": "direct_guardrail", + "selection_reason": "safe identity answer", + "route_reason": "identity_direct", + "needs_web_search": False, + "needs_vault_search": False, + }, + "req_id": req_id, + } + self._append_chat_stream_entry(msg, payload["response"], payload["meta"]) + return payload + + if _is_playtest_code_request(msg): + payload = self._generate_playtest(msg, history_text, req_id) + self._append_chat_stream_entry(msg, payload["response"], payload["meta"]) + return payload + + prompt = self._build_chat_payload(msg, intent_name, role, history_text) + start_ts = time.time() + response = "" + backend = "" + route_reason = "" + routing_decision = "" + router_used = False + judge_used = False + selected_backend = "" + selection_reason = "" + status = "ok" + scores = None + internal_observed = False + + if _is_simple_fact_question(msg) or complexity == "simple": + _chat_stage(req_id, "Réponse rapide", "minimax_fast direct") + response, fast_err = self._call_opencode_chat(prompt, timeout_s=30) + backend = "minimax_fast" + route_reason = "fast_direct_simple" + routing_decision = "fast_minimax_direct" + router_used = False + judge_used = False + selected_backend = backend + if fast_err: + status = "timeout" if fast_err == "timeout" else ("empty" if fast_err == "empty_response" else "error") + response = "Je n’ai pas obtenu de réponse rapide fiable, donc je passe sur un fallback contrôlé." + backend = "error_guard" + selected_backend = backend + selection_reason = f"fast direct failed: {fast_err}" + else: + selection_reason = self._build_selection_reason(backend, route_reason, complexity, True, False) + else: + _chat_stage(req_id, "Router v2", "route_v2 avec timeout strict") + router_used = True + try: + payload = json.dumps({"text": prompt, "role": role}).encode("utf-8") + req = _ur.Request("http://127.0.0.1:18900/route_v2", data=payload, headers={"Content-Type": "application/json"}) + with _ur.urlopen(req, timeout=70) as reply: + router_data = json.loads(reply.read().decode()) + response = (router_data.get("response") or "").strip() + backend = router_data.get("backend") or "router_unknown" + selected_backend = backend + route_reason = router_data.get("v2_path") or "route_v2" + routing_decision = route_reason + judge_used = route_reason in ("judge_pass", "consensus") + scores = router_data.get("all_scores") + internal_observed = True + if not response: + status = "empty" + fallback_response, fallback_err = self._call_opencode_chat(prompt, timeout_s=35) + if fallback_response: + response = fallback_response + backend = "minimax_fast" + selected_backend = backend + route_reason = "route_v2_empty_then_fast_fallback" + routing_decision = "router_empty_fallback_fast" + selection_reason = "route_v2 empty response, fell back to minimax_fast" + status = "ok" + else: + response = "Le routeur n’a pas produit de contenu exploitable. Je renvoie un fallback contrôlé au lieu de couper la connexion." + backend = "error_guard" + selected_backend = backend + selection_reason = self._build_selection_reason(selected_backend, route_reason, complexity, True, internal_observed) + except Exception as exc: + err_text = "timeout" if "timed out" in str(exc).lower() else str(exc) + status = "timeout" if "timeout" in err_text.lower() else "error" + fallback_response, fallback_err = self._call_opencode_chat(prompt, timeout_s=35) + if fallback_response: + response = fallback_response + backend = "minimax_fast" + selected_backend = backend + route_reason = "route_v2_error_then_fast_fallback" + routing_decision = "router_timeout_fallback" if status == "timeout" else "router_error_fallback" + selection_reason = f"router failure captured ({err_text}); minimax_fast fallback used" + status = "ok" + else: + response = f"Le routeur est indisponible ou trop lent ({err_text})." + backend = "error_guard" + selected_backend = backend + route_reason = "route_v2_error" + routing_decision = "router_timeout_fallback" if status == "timeout" else "router_error_fallback" + selection_reason = f"router failure captured: {err_text}" + + latency_s = time.time() - start_ts + meta = { + **base_meta, + "backend": backend, + "routing_decision": routing_decision, + "router_used": router_used, + "judge_used": judge_used, + "selected_backend": selected_backend or backend, + "selection_reason": selection_reason or self._build_selection_reason(selected_backend or backend, route_reason or routing_decision, complexity, True, internal_observed), + "route_reason": route_reason or routing_decision, + "needs_web_search": False, + "needs_vault_search": False, + "selected_backend_latency_s": round(latency_s, 3), + "benchmark_basis": { + "internal_observed": internal_observed, + "configured_priors": True, + "official_sources": [], + }, + "role": role, + } + if scores: + meta["scores"] = scores + try: + judge_score = max(_safe_float(v) for v in scores.values()) if isinstance(scores, dict) and scores else None + except Exception: + judge_score = None + else: + judge_score = None + + if backend == "error_guard": + meta["error"] = response + payload = self._controlled_chat_error(response, intent_name, req_id, extra_meta=meta) + self._append_chat_stream_entry(msg, payload["response"], payload["meta"]) + return payload + + _update_router_benchmarks( + selected_backend or backend, + latency_s=latency_s, + status=status, + domains=[intent_name, role, "playtest_html" if intent_name == "playtest_code_task" else ""], + judge_score=judge_score, + ) + self._maybe_log_episodic(msg, response, meta) + self._append_chat_stream_entry(msg, response, meta) + return {"response": response or "Erreur contrôlée: réponse vide", "meta": meta, "req_id": req_id} + + def do_GET(self): + parsed = urlparse(self.path) + if parsed.path == "/" or parsed.path == "/index.html": + self._serve_static(HERE / "brain_live.html", "text/html; charset=utf-8") + return + if parsed.path == "/3d": + self._serve_static(HERE / "brain_3d.html", "text/html; charset=utf-8") + return + if parsed.path == "/gpu": + self._serve_static(HERE / "brain_gpu.html", "text/html; charset=utf-8") + return + if parsed.path.startswith("/playtests/"): + name = parsed.path.split("/playtests/", 1)[-1] + target = _playtest_file_from_name(name) + if not target or not target.exists(): + self._safe_send_error(404, "playtest not found") + return + self._send_text_file(target, "text/html; charset=utf-8") + return + if parsed.path == "/api/devices": + try: + import pyaudio + pa = pyaudio.PyAudio() + inputs, outputs = [], [] + for i in range(pa.get_device_count()): + d = pa.get_device_info_by_index(i) + entry = {"idx": i, "name": d["name"]} + if d["maxInputChannels"] > 0: inputs.append(entry) + if d["maxOutputChannels"] > 0: outputs.append(entry) + pa.terminate() + data = json.dumps({"inputs": inputs, "outputs": outputs}, ensure_ascii=False).encode("utf-8") + except Exception as e: + data = json.dumps({"error": str(e)}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + if parsed.path == "/api/set-device": + import subprocess as _sp + qs = parse_qs(parsed.query) + MIC_CFG = Path(r"H:\Code\Paperclip\scripts\voice\mic_config.json") + try: + if "input" in qs: + idx = int(qs["input"][0]) + import pyaudio as _pa + _p = _pa.PyAudio() + name = _p.get_device_info_by_index(idx).get("name", "") + _p.terminate() + MIC_CFG.write_text(json.dumps({"name": name, "index": idx}, ensure_ascii=False), encoding="utf-8") + # Relancer voice_input avec le nouveau micro + _sp.run(["powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*voice_input*' -and $_.CommandLine -notlike '*powershell*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"], + capture_output=True, timeout=5) + import time as _t; _t.sleep(1) + _sp.Popen(["python", r"H:\Code\Paperclip\scripts\voice\voice_input.py"], + creationflags=getattr(_sp, 'CREATE_NO_WINDOW', 0)) + except Exception as e: + print(f"[set-device] {e}", flush=True) + self.send_response(204); self.end_headers() + return + if parsed.path == "/api/mic": + qs = parse_qs(parsed.query) + state = qs.get("state", ["on"])[0] + flag = VAULT / ".voice-muted.flag" + if state == "off": flag.touch() + else: + try: flag.unlink() + except: pass + self.send_response(204); self.end_headers() + return + if parsed.path == "/api/tts": + qs = parse_qs(parsed.query) + state = qs.get("state", ["on"])[0] + flag = VAULT / ".tts-disabled.flag" + if state == "off": + flag.touch() + # Couper aussi tout TTS en cours + try: (VAULT / ".voice-interrupt.flag").touch() + except: pass + else: + try: flag.unlink() + except: pass + self.send_response(204); self.end_headers() + return + if parsed.path == "/api/chat": + import re as _re, urllib.request as _ur + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) + msg = body.get("message", "") + msg_lower = msg.lower() + + # Import intent detection + try: + from cortex_intent import detect_intent, should_search_vault, intent_to_backend, build_guardrails_prompt + _intent_detect_available = True + except Exception as e: + print(f"[chat] cortex_intent import failed: {e}", flush=True) + _intent_detect_available = False + + intent = detect_intent(msg) if _intent_detect_available else {"intent": "general", "confidence": 0.5} + tools_used = [] + evidence_count = 0 + context_parts = [] + + # Vault/project search si intent le nécessite + if intent.get("requires_tool"): + # Si pas d'outil web disponible, interceptor AVANT d'appeler le router + if intent.get("intent") == "recent_web_search": + response = "Je n'ai pas acces a une recherche web en temps reel. Je ne vais pas inventer des nouvelles recentes — dis-moi de quoi veux-tu que je cherche specifiquement." + meta = {"intent": intent.get("intent"), "needs_web": True, "evidence_count": 0, "hallucination_prevented": True} + data = json.dumps({"response": response, "meta": meta}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + # Vault search + search_sources = should_search_vault(msg, intent) + if search_sources: + for src in search_sources: + try: + if src.is_file(): + txt = src.read_text(encoding="utf-8", errors="replace")[:2000] + context_parts.append(f"[{src.name}]\n{txt}") + evidence_count += 1 + elif src.is_dir(): + for fp in src.rglob("*.py") if "Paperclip" in str(src) else []: + if fp.name.startswith("."): + continue + try: + content = fp.read_text(encoding="utf-8", errors="replace")[:500] + if any(k in msg_lower for k in content.lower().split()): + context_parts.append(f"[{fp.relative_to(src)}]\n{content}") + evidence_count += 1 + if evidence_count >= 3: + break + except Exception: + pass + if evidence_count >= 3: + break + except Exception as pass_e: + pass + tools_used.append(intent.get("tool_name", "unknown")) + + # Construction du prompt avec guardrails + guardrails = build_guardrails_prompt(intent, tools_used) if _intent_detect_available else "" + + if context_parts: + context_str = "\n\n---\n".join(context_parts[:4]) + full_prompt = ( + f"Tu es l'assistant du vault. Données du vault :\n\n{context_str}\n\n" + f"---\nQuestion : {msg}\n\n" + f"Réponds en utilisant UNIQUEMENT les données ci-dessus. {guardrails}\n" + f"Cite tes sources : 'J'ai trouvé...' ou 'Je dois chercher...'" + ) + elif intent.get("intent") == "identity": + full_prompt = ( + f"Tu es **Cortex**. Une entité cognitive autonome construite par Sam. " + f"Réponds en une phrase concise.\n\n{msg}" + ) + elif intent.get("intent") == "playtest_dashboard_help": + full_prompt = ( + f"Tu connais ces capacités Cortex dashboard. utilise-les dans ta réponse :\n" + f"- dashboard à http://127.0.0.1:8765/\n" + f"- GPU/brain dashboard, right sidecar chat\n" + f"- Playtest tab, Consortium tab\n" + f"- /api/cortex/judges, /api/cortex/homeostasis, /api/chat\n\n" + f"Guide l'utilisateur vers l'UI intégrée si pertinent.\n\n{msg}" + ) + else: + full_prompt = msg + "\n\n" + guardrails if guardrails else msg + + # ── Routage v2 ── + backend = intent_to_backend(intent, lm_studio_running()) + try: + payload = json.dumps({"text": full_prompt, "role": intent.get("intent")}).encode("utf-8") + req = _ur.Request("http://127.0.0.1:18900/route_v2", data=payload, + headers={"Content-Type": "application/json"}) + with _ur.urlopen(req, timeout=180) as r: + d = json.loads(r.read().decode()) + response = d.get("response") or d.get("text") or "" + route_backend = d.get("backend") + route_reason = f"intent={intent.get('intent')}, v2_path={d.get('v2_path')}" + meta = { + "intent": intent.get("intent"), + "tools_used": tools_used, + "evidence_count": evidence_count, + "backend": route_backend, + "route_reason": route_reason, + "confidence": intent.get("confidence", 0.5) * (0.5 if not tools_used else 1.0), + } + meta["v2_path"] = d.get("v2_path") + except Exception as e: + response = f"Erreur router v2: {e}" + meta = {"intent": intent.get("intent"), "error": str(e)} + + data = json.dumps({"response": response, "meta": meta}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + if parsed.path == "/api/gen-calib-text": + import subprocess as _sp, random as _rnd + OPENCODE = r"C:\Users\Smedj\AppData\Roaming\npm\opencode.cmd" + from urllib.parse import parse_qs as _pqs + lang = _pqs(parsed.query).get("lang", ["fr"])[0] + mode = _pqs(parsed.query).get("mode", ["read"])[0] + + if mode == "question": + QUESTIONS_FR = [ + "Qu'est-ce que tu as fait ce matin qui t'a mis de bonne humeur ?", + "Sur quoi tu travailles en ce moment qui t'enthousiasme vraiment ?", + "C'est quoi la dernière chose qui t'a vraiment surpris ?", + "Tu as un projet créatif en cours ou dans la tête en ce moment ?", + "Si tu avais une journée entière sans obligations, tu ferais quoi ?", + "Quel est ton rapport à l'intelligence artificielle au quotidien ?", + "Il y a une compétence que tu aimerais vraiment développer là ?", + "C'est quoi la dernière chose qui t'a fait rire ou sourire ?", + "Tu imagines ta vie comment dans dix ans ?", + "Il y a un endroit où tu rêves d'aller que tu n'as jamais visité ?", + "Qu'est-ce qui te donne de l'énergie en ce moment dans ton travail ?", + "Tu penses à quoi quand tu as un moment de calme ?", + ] + QUESTIONS_EN = [ + "What did you do this morning that made you feel good?", + "What are you working on right now that excites you?", + "What's the last thing that genuinely surprised you?", + "Do you have a creative project going on or in mind?", + "If you had a full free day with no obligations, what would you do?", + "How does AI fit into your daily life right now?", + "Is there a skill you've really been wanting to develop?", + "What's the last thing that made you laugh or smile?", + "How do you imagine your life in ten years?", + "Is there a place you've always dreamed of visiting?", + ] + questions = QUESTIONS_EN if lang == "en" else QUESTIONS_FR + # Retourne une question parmi celles pas encore utilisées dans cette session + q_key = f"_calib_q_idx_{lang}" + used = _pqs(parsed.query).get("used", [""])[0].split(",") + available = [q for q in questions if q not in used] + text = _rnd.choice(available) if available else _rnd.choice(questions) + data = json.dumps({"text": text}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + else: + styles = ["une question curieuse", "une affirmation enthousiaste", + "une phrase narrative calme", "une exclamation surprise", + "une instruction directe", "une réflexion philosophique courte"] + style = _rnd.choice(styles) + if lang == "en": + prompt = (f"Generate ONE natural English sentence (15-25 words), style: {style}. " + f"Topic: technology, nature, or daily life. ONLY the sentence, no quotes.") + else: + prompt = (f"Génère UNE SEULE phrase en français UNIQUEMENT, 15-25 mots, style: {style}. " + f"Thème: technologie, nature, ou vie quotidienne. UNIQUEMENT la phrase, sans guillemets.") + try: + r = _sp.run([OPENCODE, "run", "--model", "opencode/minimax-m2.5-free", prompt], + capture_output=True, text=True, timeout=30, encoding="utf-8", errors="replace") + lines = [l for l in r.stdout.splitlines() + if l.strip() and not l.startswith(">") and "\x1b" not in l and "build" not in l.lower()] + text = "\n".join(lines).strip() or "Parle naturellement, à ton propre rythme, avec tes propres mots." + except Exception: + text = "La technologie évolue rapidement mais l'essentiel reste la connexion entre les êtres humains." + data = json.dumps({"text": text, "style": style}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + + if parsed.path == "/api/score-voice": + # Score la similarité entre le dernier enregistrement et le profil + import subprocess as _sp, tempfile as _tf, wave as _wv + length = int(self.headers.get("Content-Length", 0)) + # Reçoit le score calculé côté JS via Web Speech confidence + body = json.loads(self.rfile.read(length)) + score = body.get("score", 0.0) + profile_path = Path(r"H:\Code\Paperclip\scripts\voice\voice_profile.npy") + good = score >= 0.6 + data = json.dumps({"score": score, "good": good, "profile_exists": profile_path.exists()}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + + if parsed.path == "/api/calibrate": + import subprocess as _sp + # Tuer voice_input ET couper tts_monitor + _sp.run(["powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*voice_input*' -and $_.CommandLine -notlike '*powershell*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"], + capture_output=True, timeout=5) + # Arrêter toute lecture TTS en cours + import pygame as _pg + try: _pg.mixer.music.stop() + except Exception: pass + try: (VAULT / ".tts-playing.flag").unlink() + except Exception: pass + import time as _t; _t.sleep(1) + try: + r = _sp.run( + ["python", r"H:\Code\Paperclip\scripts\voice\enroll_voice.py"], + input="\n", capture_output=True, text=True, timeout=60, + encoding="utf-8", errors="replace" + ) + out = r.stdout + r.stderr + ok = "sauvegard" in out.lower() + import re as _re + m = _re.search(r'sim[^:=]*[:=]\s*([\d.]+)', out, _re.I) + sim = float(m.group(1)) if m else None + same = sim is None or sim >= 0.40 + data = json.dumps({"ok": ok, "sim": sim, "same_person": same}).encode("utf-8") + except Exception as e: + data = json.dumps({"ok": False, "error": str(e)}).encode("utf-8") + finally: + try: (VAULT / ".voice-calibrating.flag").unlink() + except: pass + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + if parsed.path == "/api/node-content": + qs = parse_qs(parsed.query) + node_id = qs.get("id", [""])[0] + content = "" + try: + full = VAULT / node_id.replace("/", os.sep) + if not full.exists(): + full = VAULT / node_id # try forward slashes too + if full.exists(): + text = full.read_text(encoding="utf-8", errors="replace") + body = text + if text.startswith("---"): + idx = text.find("\n---", 3) + body = text[idx+4:].strip() if idx > 0 else text + content = (body if body.strip() else text)[:3000] + else: + content = f"(fichier non trouvé: {node_id})" + except Exception as e: + content = f"(erreur: {e})" + data = json.dumps({"content": content}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + if parsed.path == "/api/open": + qs = parse_qs(parsed.query) + node_id = qs.get("id", [""])[0] + if node_id: + vault = Path(r"C:\Users\Smedj\Documents\Obsidian Vault") + full = vault / node_id + import subprocess as _sp + try: + # Ouvre dans Obsidian via URI scheme + import urllib.parse + obs_path = urllib.parse.quote(node_id, safe='/') + _sp.run(["cmd", "/c", "start", "", f"obsidian://open?vault=Obsidian%20Vault&file={obs_path}"], shell=False) + except Exception: + pass + self.send_response(204) + self.end_headers() + return + if parsed.path == "/api/stream": + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Connection", "keep-alive") + self.end_headers() + last_graph_mtime = 0.0 + last_full_metrics = 0.0 + _last_m = {} + try: + while True: + now = time.time() + # État voix toutes les 500ms (léger) + mic_muted = (VAULT / ".voice-muted.flag").exists() + tts_disabled = (VAULT / ".tts-disabled.flag").exists() + # Dernier échange chat (pour push UI) + chat_stream_file = CHAT_STREAM_FILE + last_chat = None + if chat_stream_file.exists(): + try: + with open(chat_stream_file, "rb") as _csf: + _csf.seek(0, 2); fsize = _csf.tell() + _csf.seek(max(0, fsize - 4000)) + lines = _csf.read().decode("utf-8", errors="replace").splitlines() + for ln in reversed(lines): + try: + candidate = json.loads(ln) + except Exception: + continue + if _is_chat_entry(candidate): + last_chat = candidate + break + except Exception: pass + vision_muted_flag = Path.home() / ".claude" / "projects" / "h--Code-Paperclip" / "memory" / ".cortex-vision-muted.flag" + voice_state = { + "tts_playing": (VAULT / ".tts-playing.flag").exists(), + "voice_muted": mic_muted, + "user_speaking": (VAULT / ".voice-speaking.flag").exists(), + "voice_active": not mic_muted, + "tts_disabled": tts_disabled, + "mic_muted": mic_muted, + "vision_muted": vision_muted_flag.exists(), + "last_chat": last_chat, + } + # Métriques complètes toutes les 5s + if now - last_full_metrics >= 5: + _last_m = get_metrics() + cur_mtime = max(file_mtime(GRAPH_FILE), file_mtime(ACTIVITY_STATE), file_mtime(PAGERANK_FILE)) + _last_m["graph_changed"] = cur_mtime > last_graph_mtime + 0.5 + if _last_m["graph_changed"]: + last_graph_mtime = cur_mtime + _cache["snapshot"] = None + last_full_metrics = now + m = {**_last_m, **voice_state} + data = json.dumps(m, ensure_ascii=False) + self.wfile.write(f"data: {data}\n\n".encode("utf-8")) + self.wfile.flush() + time.sleep(0.5) + except (BrokenPipeError, ConnectionResetError, OSError): + pass + return + if parsed.path == "/api/metrics": + try: + m = get_metrics() + except Exception as e: + self.send_error(500, f"metrics error: {e}") + return + data = json.dumps(m, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(data) + return + if parsed.path == "/api/cortex/feed": + # Frame depuis le thread de capture continue (ouvre la caméra à 1ère req) + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_vision as _cv + # Vérifier si vision muted (privacy) + if _cv.is_vision_muted(): + self.send_error(403, "vision muted"); return + # Démarrer la capture continue si pas active + if not _cv._continuous_state["running"]: + _cv.start_continuous_capture(fps=5) + # Attendre brièvement le 1er frame + import time as _t + wait_start = _t.time() + while _cv.get_latest_frame_bytes() is None and _t.time() - wait_start < 6: + _t.sleep(0.2) + data = _cv.get_latest_frame_bytes() + if not data: + self._safe_send_error(503, "no frame yet"); return + try: + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-cache, no-store") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError): + # Browser cancelled — silencieux + self.close_connection = True + except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError): + self.close_connection = True + except Exception as e: + # Autres erreurs : log court mais ne crash pas + msg = str(e) + if not any(s in msg for s in ('10053','10054','10038')): + print(f"[feed] {type(e).__name__}: {msg}", flush=True) + self._safe_send_error(500, msg[:120]) + return + if parsed.path == "/api/cortex/rescan_cameras": + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_vision as _cv + _cv.reset_camera_cache() + rep = {"ok": True, "msg": "cache vidé, prochaine capture re-scan"} + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/skills/discover": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) if length else {} + need = body.get("need", "") + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_skills as _cs + rep = _cs.discover(need) + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/skills/install": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) if length else {} + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_skills as _cs + rep = _cs.install_skill(body.get("package",""), body.get("env","main")) + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/add_metric": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) if length else {} + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_homeostasis as _ch + rep = _ch.add_custom_metric(body.get("name",""), body.get("source",""), + body.get("description","")) + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/homeostasis": + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_homeostasis as _ch + qs = parse_qs(parsed.query) + if qs.get("act"): + rep = _ch.health_check_and_act() + else: + rep = {"vital_signs": _ch.vital_signs(), + "services": _ch.services_status(), + "paused": _ch.is_paused()} + except Exception as e: + rep = {"error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/activations": + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_activation as _ca + rep = _ca.snapshot() + except Exception as e: + rep = {"error": str(e), "active_nodes": {}} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/kv_quantize": + # Recommandation complète quantization (KV cache + poids) + baseline latency + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_kv_quantize as _kvq + qs = parse_qs(parsed.query) + target = float(qs.get("target_vram_gb", ["12"])[0]) + rep = _kvq.full_recommend(target_vram_gb=target) + # Inclut la dernière comparaison latence si dispo + try: + rep["latency_compare"] = _kvq.compare_latencies() + except Exception: pass + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/pipeline": + # Snapshot complet du pipeline matériel + état régulation + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_pipeline_manager as _pm + snap = _pm.list_processes() + zombies = _pm.find_zombies() + # Lit l'état persisté de la dernière auto_regulate + last_state = {} + try: + if _pm.STATE_FILE.exists(): + last_state = json.loads(_pm.STATE_FILE.read_text(encoding="utf-8")) + except Exception: pass + rep = { + "ok": True, + "by_category": snap.get("by_category_count"), + "ram_by_category": snap.get("by_category_ram"), + "total_processes": snap.get("total_processes"), + "ram_total_mb": snap.get("ram_total_mb"), + "zombies_count": len(zombies), + "zombies_top10": zombies[:10], + "last_regulation": last_state, + "thresholds": _pm.AUTO_REG, + } + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/llm_lifecycle": + # État LLM local + TTL JIT (load/unload/cooldown) + # Permet à Sam de gérer la VRAM : modèle ON = chat rapide mais VRAM occupée, + # modèle OFF = VRAM libre (3D fluide) mais 1er chat coûte ~60s reload. + try: + import subprocess as _sp + lms_bin = r"C:\Users\Smedj\.lmstudio\bin\lms.exe" + # ps : lit l'état des modèles chargés + r = _sp.run([lms_bin, "ps"], capture_output=True, text=True, + timeout=8, encoding="utf-8", errors="replace") + lines = (r.stdout or "").splitlines() + models_loaded = [] + for ln in lines: + ln_strip = ln.strip() + if ln_strip and not ln_strip.startswith(("IDENTIFIER", "---", "===")): + parts = ln_strip.split() + if parts and not parts[0].startswith(("EMBEDDING", "LLM", "PARAMS")): + ident = parts[0] + status = parts[2] if len(parts) > 2 else "?" + size_gb = float(parts[3]) if len(parts) > 3 and parts[3].replace(".","").isdigit() else 0 + ctx = int(parts[5]) if len(parts) > 5 and parts[5].isdigit() else 0 + ttl = parts[-1] if len(parts) > 6 else "" + models_loaded.append({"identifier": ident, "status": status, + "size_gb": size_gb, "context": ctx, "ttl": ttl}) + # Settings JIT TTL + jit = {"enabled": True, "ttl_seconds": 3600} + try: + settings = json.loads((Path.home() / ".lmstudio" / "settings.json" + ).read_text(encoding="utf-8")) + jit = settings.get("developer", {}).get("jitModelTTL", jit) + except Exception: pass + rep = { + "ok": True, + "loaded_models": models_loaded, + "n_loaded": len(models_loaded), + "jit": jit, + "any_active": any(m["status"] in ("GENERATING", "PROCESSINGPROMPT") + for m in models_loaded), + } + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/heartbeat/config": + cfg = _load_heartbeat_config() + data = json.dumps({"ok": True, "config": cfg, "defaults": HEARTBEAT_CONFIG_DEFAULTS}, + ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/progression": + qs = parse_qs(parsed.query) + key = qs.get("key", [""])[0] + if not key or key not in PROGRESSION: + rep = {"ok": False, "error": f"unknown key: {key}", + "available": list(PROGRESSION.keys())} + else: + with PROGRESSION_LOCK: + series = list(PROGRESSION[key]) + now = time.time() + # Filtre : 1h, 24h + cur = series[-1][1] if series else None + cur_ts = series[-1][0] if series else 0 + def _at_or_before(ts_target): + best = None + for ts, val in series: + if ts <= ts_target: best = (ts, val) + else: break + return best + ref_1h = _at_or_before(now - 3600) + ref_24h = _at_or_before(now - 86400) + # Dernier changement (cur != prev) + last_change_ts = cur_ts + if isinstance(cur, (int, float)): + for ts, v in reversed(series[:-1]): + if v != cur: last_change_ts = series[series.index((ts, v))+1][0]; break + # Sparkline : derniers 60 points (1h si snap=60s) + spark = [v for _, v in series[-60:]] + rep = { + "ok": True, "key": key, "now_value": cur, "now_ts": cur_ts, + "delta_1h": (cur - ref_1h[1]) if (ref_1h and isinstance(cur,(int,float))) else None, + "delta_24h": (cur - ref_24h[1]) if (ref_24h and isinstance(cur,(int,float))) else None, + "ref_1h_ts": ref_1h[0] if ref_1h else None, + "ref_24h_ts": ref_24h[0] if ref_24h else None, + "last_change_ts": last_change_ts, + "n_samples": len(series), + "sparkline": spark, + } + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/judges": + # Panel-of-judges : agrège le benchmark IAG + décrit le système + # Source : VAULT/.vault-llm-benchmark-iag.json (rounds + winners) + qs = parse_qs(parsed.query) + limit = int(qs.get("limit", ["20"])[0]) + try: + bench_file = VAULT / ".vault-llm-benchmark-iag.json" + rounds = [] + models = {} + if bench_file.exists(): + raw = json.loads(bench_file.read_text(encoding="utf-8")) + all_rounds = raw.get("rounds", []) or [] + rounds = all_rounds[-limit:] + # Statistiques cumulées par modèle + win_count = {} + lat_sum = {} + lat_cnt = {} + for r in all_rounds: + w = r.get("winner") + if w: win_count[w] = win_count.get(w, 0) + 1 + for m, lat in (r.get("latencies") or {}).items(): + try: + lat_sum[m] = lat_sum.get(m, 0) + float(lat) + lat_cnt[m] = lat_cnt.get(m, 0) + 1 + except Exception: pass + # Construit le résumé par modèle + all_models = set() + for r in all_rounds: + all_models.update((r.get("responses") or {}).keys()) + for m in all_models: + models[m] = { + "wins": win_count.get(m, 0), + "rounds": lat_cnt.get(m, 0), + "win_rate": (round(win_count.get(m,0)/lat_cnt.get(m,1)*100, 1) + if lat_cnt.get(m) else 0), + "avg_latency_s": (round(lat_sum.get(m,0)/lat_cnt.get(m,1), 2) + if lat_cnt.get(m) else 0), + } + # Description statique du système (vrais éléments du code) + system = { + "name": "Panel-of-judges + FrugalGPT cascade", + "summary": ("Cortex compare plusieurs LLM gratuits sur chaque question, " + "désigne un gagnant via similarité sémantique des réponses, " + "et apprend dans le temps quel modèle marche pour quel rôle. " + "FrugalGPT cascade : essaie d'abord le moins cher, n'escalade " + "que si la confiance est basse."), + "models_evaluated": [ + {"id": "minimax_m2.5", "label": "MiniMax M2.5 (free)", + "context": "200k", "via": "opencode"}, + {"id": "big_pickle", "label": "Big Pickle (Llama-405B-derived)", + "context": "128k", "via": "opencode"}, + {"id": "nemotron_3_super", "label": "Nemotron 3 Super (NVIDIA)", + "context": "128k", "via": "opencode"}, + {"id": "hy3_preview", "label": "HY3 Preview", + "context": "?", "via": "opencode"}, + {"id": "gpt_5_nano", "label": "GPT-5 Nano (paid fallback)", + "context": "256k", "via": "opencode"}, + ], + "judging_method": ("Pour chaque round : 1) chaque modèle répond en parallèle, " + "2) similarité par paires (TF-IDF cosine sur les réponses), " + "3) le modèle dont la réponse est la plus 'centrale' " + "(somme des cosines max) gagne, 4) tie-break par latence."), + "frugal_gpt_cascade": [ + "1. Pose la question au modèle le moins cher (minimax_m2.5)", + "2. Calcule un score de confiance (longueur, structure, mots-clés)", + "3. Si confiance > seuil : retourne la réponse", + "4. Sinon : escalade au modèle suivant (big_pickle → nemotron → ...)", + "5. Apprentissage : enregistre quel chemin a gagné pour cette catégorie", + ], + "router_endpoint": "http://127.0.0.1:18900/route_v2", + "data_file": str(bench_file), + } + # Ranking trié par win_rate + ranking = sorted(models.items(), key=lambda x: -x[1]["win_rate"]) + rep = {"ok": True, "system": system, "rounds": rounds, + "n_total_rounds": len(raw.get("rounds", [])) if bench_file.exists() else 0, + "models": models, + "ranking": [{"model": m, **stats} for m, stats in ranking]} + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/heartbeat": + # Heartbeat unifié — toutes les horloges réelles + ETA prédits. + # Pas de placeholder : chaque champ est soit un timestamp réel, + # soit un ETA calculé depuis l'intervalle programmé moins l'elapsed. + now = time.time() + uptime_s = now - SERVER_STARTED_AT + # Stats activation (compteurs cumulés + last_*_ts) + act = {} + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_activation as _ca + snap = _ca.snapshot() + act = { + "n_active": snap.get("n_active", 0), + "n_edges_total": snap.get("n_edges_total", 0), + "cum_activations": snap.get("cum_activations", 0), + "cum_pulses": snap.get("cum_pulses", 0), + "cum_hebbian_ticks": snap.get("cum_hebbian_ticks", 0), + "last_activation_ts": snap.get("last_activation_ts", 0), + "last_pulse_ts": snap.get("last_pulse_ts", 0), + "last_hebbian_ts": snap.get("last_hebbian_ts", 0), + "last_wander_ts": snap.get("last_wander_ts", 0), + "wander_interval": snap.get("wander_interval", 45), + } + # ETA prédit pour la prochaine pensée vagabonde + lw = act["last_wander_ts"] + act["next_wander_in_s"] = (max(0, act["wander_interval"] - (now - lw)) + if lw else 0) + except Exception as _e: + act["error"] = str(_e) + # Stats émergence — même source que /api/cortex/emergence_log : + # le stream chat filtré par speaker=cortex_emergence (déterministe et + # cohérent avec l'affichage UI principal). + em = {} + try: + import cortex_emergence as _ce + em["interval_s"] = getattr(_ce, "INTERVAL_SEC", 300) + em["last_decision_ts"] = 0 + em["last_action"] = None + stream_file = EMERGENCE_STREAM_FILE + if stream_file.exists(): + try: + for line in reversed(stream_file.read_text(encoding="utf-8", + errors="replace").splitlines()[-500:]): + try: + obj = json.loads(line) + if obj.get("speaker") == "cortex_emergence": + em["last_decision_ts"] = obj.get("ts", 0) or 0 + em["last_action"] = (obj.get("meta") or {}).get("action") or "auto" + break + except Exception: pass + except Exception: pass + em["since_last_s"] = (now - em["last_decision_ts"] + if em["last_decision_ts"] else None) + em["next_in_s"] = (max(0, em["interval_s"] - (now - em["last_decision_ts"])) + if em["last_decision_ts"] else em["interval_s"]) + except Exception as _e: + em["error"] = str(_e) + # Stats chat (p50/p90 + dernier done) + with CHAT_PROGRESS_LOCK: + durs = sorted(CHAT_DURATIONS) + p50 = durs[len(durs)//2] if durs else None + p90 = durs[int(len(durs)*0.9)] if durs and int(len(durs)*0.9) < len(durs) else ( + durs[-1] if durs else None) + in_progress = bool(CHAT_PROGRESS.get("req_id") and not CHAT_PROGRESS.get("done")) + started = CHAT_PROGRESS.get("started") if in_progress else None + chat = { + "n_completed": len(CHAT_DURATIONS), + "p50_s": round(p50, 1) if p50 else None, + "p90_s": round(p90, 1) if p90 else None, + "last_done_ts": CHAT_LAST_DONE_TS, + "in_progress": in_progress, + "started_at": started, + "elapsed_s": round(now - started, 1) if started else None, + # ETA prédit du chat en cours (p50 - elapsed, ou None si pas d'historique) + "predicted_remaining_s": (max(0, round(p50 - (now - started), 1)) + if (p50 and started) else None), + } + # Vitals + try: + import cortex_homeostasis as _ch + vit = _ch.vital_signs() + cpu = (vit.get("cpu") or {}).get("percent") + ram = (vit.get("ram") or {}).get("percent") + except Exception: + cpu, ram = None, None + rep = { + "ok": True, + "now": now, + "server_uptime_s": round(uptime_s, 1), + "activation": act, + "emergence": em, + "chat": chat, + "vitals": {"cpu": cpu, "ram": ram}, + } + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/learned_skills": + # Liste des compétences sémantiquement mémorisées par Cortex + qs = parse_qs(parsed.query) + limit = int(qs.get("limit", ["20"])[0]) + search = qs.get("q", [""])[0] + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_learned_skills as _cls + if search: + rep = {"ok": True, "skills": _cls.search_learned(search, k=limit)} + else: + rep = {"ok": True, "skills": _cls.list_learned(limit)} + except Exception as e: + rep = {"ok": False, "error": str(e), "skills": []} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/think_status": + # Progression réelle du dernier appel /api/chat. + # Inclut vitals (CPU/RAM) pour heartbeat auto-adaptatif côté UI. + qs = parse_qs(parsed.query) + asked = (qs.get("req_id", [""])[0] or "").strip() + with CHAT_PROGRESS_LOCK: + snap = { + "req_id": CHAT_PROGRESS.get("req_id"), + "started": CHAT_PROGRESS.get("started"), + "done": CHAT_PROGRESS.get("done"), + "stages": list(CHAT_PROGRESS.get("stages") or []), + } + # Si Sam demande un req_id spécifique différent du courant, on retourne + # quand même le dernier connu mais on flag "match=False". + snap["match"] = (not asked) or (asked == snap.get("req_id")) + # Vitals pour le heartbeat adaptatif (vitesse/couleur ajustées + # selon CPU/RAM — Cortex fatigué bat plus lentement). + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_homeostasis as _ch + vit = _ch.vital_signs() + cpu = (vit.get("cpu") or {}).get("percent") + ram = (vit.get("ram") or {}).get("percent") + except Exception: + cpu, ram = None, None + # Tempo heartbeat (ms) déterministe selon charge : + # base 900 ms, +400 ms si CPU>70%, +400 ms si RAM>80%, -200 ms si tout < 40%. + tempo_ms = 900 + if isinstance(cpu, (int, float)) and cpu > 70: tempo_ms += 400 + if isinstance(ram, (int, float)) and ram > 80: tempo_ms += 400 + if isinstance(cpu, (int, float)) and cpu < 40 and isinstance(ram, (int, float)) and ram < 60: + tempo_ms -= 200 + snap["tempo_ms"] = max(400, min(2000, tempo_ms)) + snap["cpu"] = cpu; snap["ram"] = ram + # Couleur d'état : verte tant que la requête avance, jaune > 12 s sans + # nouvelle étape, orange > 25 s, rouge > 45 s. + color = "ok" + if snap["stages"] and not snap.get("done"): + last = snap["stages"][-1] + age = time.time() - (last.get("started") or 0) + if age > 45: color = "stuck" + elif age > 25: color = "slow" + elif age > 12: color = "wait" + snap["color"] = color + snap["server_now"] = time.time() + data = json.dumps(snap, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/pulses": + # Événements de propagation récents (Spreading Activation visible). + # Query: ?since= pour delta — sinon 8 dernières secondes. + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_activation as _ca + qs = parse_qs(parsed.query) + since = float(qs.get("since", ["0"])[0]) if qs.get("since") else 0.0 + in_mem = _ca.recent_pulses(since) + # Fusion avec disque (cross-process : autres scripts qui activent) + disk_pulses = [] + pf = _ca.PULSES_FILE + if pf.exists(): + try: + cutoff = time.time() - _ca.PULSES_TTL_SEC + for line in pf.read_text(encoding="utf-8").splitlines()[-300:]: + try: + p = json.loads(line) + if p.get("ts", 0) > max(since, cutoff): + disk_pulses.append(p) + except Exception: pass + except Exception: pass + # Dédup par (from,to,ts arrondi à 0.1s) + seen = set(); merged = [] + for p in (in_mem + disk_pulses): + key = (p.get("from"), p.get("to"), round(p.get("ts",0), 1)) + if key in seen: continue + seen.add(key); merged.append(p) + merged.sort(key=lambda x: x.get("ts", 0)) + rep = {"pulses": merged[-150:], "ts": time.time()} + except Exception as e: + rep = {"pulses": [], "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/brain_history": + # Snapshots cérébraux + détection régressions (croissance dans le temps). + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_brain_history as _bh + qs = parse_qs(parsed.query) + if qs.get("now"): + rep = _bh.append_snapshot() + else: + rep = _bh.evolution_summary() + except Exception as e: + rep = {"error": str(e), "history": []} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/dev_command": + # Slash-commands depuis le chat — whitelist stricte d'actions safe. + try: + qs = parse_qs(parsed.query) + cmd = qs.get("cmd", [""])[0].strip() + arg = qs.get("arg", [""])[0].strip() + rep = {"ok": False, "error": "unknown command"} + if cmd == "open" and arg: + import subprocess as _sp, shutil as _sh + code_exe = _sh.which("code") or r"C:\Users\Smedj\AppData\Local\Programs\Microsoft VS Code\bin\code.cmd" + try: + _sp.Popen([code_exe, arg], shell=False) + rep = {"ok": True, "result": f"VSCode ouvert sur **{arg}**"} + except Exception: + try: + _sp.Popen(["explorer", arg.replace("/", "\\")]) + rep = {"ok": True, "result": f"Explorateur ouvert sur **{arg}**"} + except Exception as e: + rep = {"ok": False, "error": f"open: {e}"} + elif cmd == "find" and arg: + import glob as _g + matches = _g.glob(f"H:/Code/Paperclip/**/{arg}", recursive=True)[:20] + rep = {"ok": True, "result": "**Fichiers** :\n" + + ("\n".join(f"- `{m}`" for m in matches) if matches else "_aucun match_")} + elif cmd == "grep" and arg: + import subprocess as _sp + try: + r = _sp.run(["git", "grep", "-n", "-i", arg, "--", + "*.py", "*.ts", "*.tsx", "*.js", "*.html", "*.md"], + cwd=r"H:\Code\Paperclip", capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=15) + out = r.stdout.strip().splitlines()[:30] + rep = {"ok": True, "result": "**Hits** :\n```\n" + + ("\n".join(out) if out else "(aucun)") + "\n```"} + except Exception as e: + rep = {"ok": False, "error": f"grep: {e}"} + elif cmd == "code" and arg: + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_self_dev as _csd + result = (_csd.propose_and_apply(arg, dry_run=True) + if hasattr(_csd, "propose_and_apply") + else {"ok": False, "error": "cortex_self_dev API not found"}) + rep = {"ok": True, "result": + f"**Self-dev (dry-run)** pour : *{arg}*\n```json\n" + f"{json.dumps(result, ensure_ascii=False, indent=2)[:1500]}\n```"} + except Exception as e: + rep = {"ok": False, "error": str(e)} + elif cmd == "run" and arg: + import subprocess as _sp, os as _os + parts = arg.split() + script = parts[0] + if not script.endswith(".py") or ".." in script: + rep = {"ok": False, "error": "seuls les .py sans .. sont autorisés"} + else: + full = _os.path.join(r"H:\Code\Paperclip", script.replace("/", "\\")) + if not _os.path.exists(full): + rep = {"ok": False, "error": f"introuvable: {full}"} + else: + try: + r = _sp.run(["python", full] + parts[1:], + capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=30) + rep = {"ok": r.returncode == 0, + "result": f"`python {arg}` → exit **{r.returncode}**\n```\n{(r.stdout + r.stderr)[-1500:]}\n```"} + except Exception as e: + rep = {"ok": False, "error": str(e)} + elif cmd == "test": + import subprocess as _sp + try: + r = _sp.run(["python", "-m", "pytest", "-x", "-q", arg or "tests/"], + cwd=r"H:\Code\Paperclip", capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=120) + rep = {"ok": r.returncode == 0, + "result": f"pytest **{arg or 'tests/'}** → exit {r.returncode}\n```\n{(r.stdout + r.stderr)[-2000:]}\n```"} + except Exception as e: + rep = {"ok": False, "error": str(e)} + elif cmd == "help": + rep = {"ok": True, "result": + "**Commandes dispo** (préfixe `/` dans le chat) :\n" + "- `/open ` — ouvre dans VSCode\n" + "- `/find ` — cherche fichiers (glob)\n" + "- `/grep ` — cherche du contenu (git grep)\n" + "- `/code ` — propose un patch via cortex_self_dev (dry-run)\n" + "- `/run [args]` — exécute un Python du repo\n" + "- `/test [path]` — lance pytest\n" + "- `/help` — cette liste"} + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/emergence_log": + try: + stream_file = EMERGENCE_STREAM_FILE + qs = parse_qs(parsed.query) + limit = int(qs.get("limit", ["10"])[0]) + out = [] + if stream_file.exists(): + for line in stream_file.read_text(encoding="utf-8", + errors="replace").splitlines()[-500:]: + try: + e = json.loads(line) + if e.get("speaker") == "cortex_emergence": + out.append({"ts": e.get("ts"), + "action": (e.get("meta") or {}).get("action", "auto"), + "msg": e.get("msg",""), "response": e.get("response","")}) + except Exception: pass + rep = {"ok": True, "decisions": out[-limit:]} + except Exception as e: + rep = {"ok": False, "error": str(e), "decisions": []} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/explain_brain_get": + # Alias GET pour explain_brain (le bouton ❓ utilise POST mais on permet GET) + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import urllib.request as _ur + # Délègue à do_POST en faisant un appel local + import urllib.request, urllib.error + req = urllib.request.Request("http://127.0.0.1:8765/api/cortex/explain_brain", + method="POST", data=b"") + resp = urllib.request.urlopen(req, timeout=20).read() + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(resp))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(resp); return + except Exception as e: + data = json.dumps({"ok": False, "error": str(e)}).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/health": + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_resources as _cr + qs = parse_qs(parsed.query) + if qs.get("kill_zombies"): + rep = _cr.kill_zombies() + else: + rep = _cr.health_report() + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/vision_mute": + qs = parse_qs(parsed.query) + muted = qs.get("muted", ["toggle"])[0] + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_vision as _cv + if muted == "toggle": + target = not _cv.is_vision_muted() + else: + target = muted in ("1", "true", "yes") + rep = _cv.set_vision_muted(target) + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/cam_params": + qs = parse_qs(parsed.query) + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_vision as _cv + params = {k: float(qs[k][0]) for k in ["brightness","contrast","exposure","saturation"] if k in qs} + rep = _cv.set_camera_params(**params) + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path.startswith("/api/cortex/image/"): + kind = parsed.path.rsplit("/", 1)[-1] + img = Path.home() / (".cortex_webcam.png" if kind == "webcam" else ".cortex_screenshot.png") + if not img.exists(): + self.send_error(404); return + try: + data = img.read_bytes() + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + except Exception: + self.send_error(500) + return + if parsed.path == "/api/state": + try: + snap = load_snapshot() + except Exception as e: + self.send_error(500, f"snapshot error: {e}") + return + data = json.dumps(snap, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(data) + return + self.send_error(404, "Not found") + + def _serve_static(self, path: Path, ctype: str): + try: + data = path.read_bytes() + except Exception: + self.send_error(404, "static missing") + return + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(data) + + def do_POST(self): + import subprocess as _sp + from urllib.parse import urlparse as _up + parsed = _up(self.path) + if parsed.path == "/api/chat": + req_id = "" + try: + payload = self._handle_api_chat() + except Exception as exc: + try: + _chat_stage_done(req_id) + except Exception: + pass + print(f"[api/chat guarded] {type(exc).__name__}: {exc}", flush=True) + payload = self._controlled_chat_error(str(exc), "simple_chat", req_id) + self._send_json(payload) + try: + _chat_stage_done(payload.get("req_id", "")) + except Exception: + pass + return + if parsed.path == "/api/calibrate": + # Tuer voice_input et attendre libération du mic + (VAULT / ".voice-calibrating.flag").touch() + _sp.run(["powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*voice_input*' -and $_.CommandLine -notlike '*powershell*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"], + capture_output=True, timeout=5) + # Vérifier que le port 18767 est libéré + import socket as _sock + for _ in range(20): + time.sleep(0.3) + try: + s = _sock.socket(); s.settimeout(0.2) + s.bind(("127.0.0.1", 18767)); s.close(); break + except OSError: pass + time.sleep(2) # délai supplémentaire pour PyAudio + try: (VAULT / ".tts-playing.flag").unlink() + except Exception: pass + try: + r = _sp.run(["python", r"H:\Code\Paperclip\scripts\voice\enroll_voice.py"], + input="\n", capture_output=True, text=True, timeout=90, + encoding="utf-8", errors="replace") + out = r.stdout + r.stderr + ok = "sauvegard" in out.lower() + import re as _re + m = _re.search(r'sim[^:=]*[:=]\s*([\d.]+)', out, _re.I) + sim = float(m.group(1)) if m else None + same = sim is None or sim >= 0.40 + data = json.dumps({"ok": ok, "sim": sim, "same_person": same, "log": out[-500:]}).encode("utf-8") + except Exception as e: + data = json.dumps({"ok": False, "error": str(e)}).encode("utf-8") + finally: + try: (VAULT / ".voice-calibrating.flag").unlink() + except: pass + # Relancer voice_input automatiquement après calibration + try: + _sp.Popen(["python", r"H:\Code\Paperclip\scripts\voice\voice_input.py"], + creationflags=getattr(_sp, 'CREATE_NO_WINDOW', 0)) + except Exception: pass + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + if parsed.path == "/api/cortex/emergence_log": + # Lit les N dernières décisions autonomes de Cortex (pas seulement la live). + # Le panneau cérébral l'utilise pour afficher la dernière décision même + # si elle a eu lieu il y a 10 min. + try: + stream_file = EMERGENCE_STREAM_FILE + qs = parse_qs(parsed.query) + limit = int(qs.get("limit", ["10"])[0]) + out = [] + if stream_file.exists(): + for line in stream_file.read_text(encoding="utf-8", + errors="replace").splitlines()[-500:]: + try: + e = json.loads(line) + if e.get("speaker") == "cortex_emergence": + out.append({ + "ts": e.get("ts"), + "action": (e.get("meta") or {}).get("action", "auto"), + "msg": e.get("msg",""), + "response": e.get("response",""), + }) + except Exception: pass + rep = {"ok": True, "decisions": out[-limit:]} + except Exception as e: + rep = {"ok": False, "error": str(e), "decisions": []} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/emergence_now": + # Force une décision autonome immédiate. + # Query ?action=audit_ui (optionnel) → force une action spécifique. + qs = parse_qs(parsed.query) + action_override = qs.get("action", [""])[0].strip() or None + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_emergence as _ce + import threading as _th + if hasattr(_ce, 'run_one_cycle'): + _th.Thread(target=_ce.run_one_cycle, + kwargs={"action_override": action_override}, + daemon=True).start() + rep = {"ok": True, "triggered": True, "action": action_override} + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/publishing": + # Cortex publie son développement sur GitHub. + # ?action=preview (défaut) | init | update + # ?confirm=1 nécessaire pour init (création repo public) + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_publishing as _cp + qs = parse_qs(parsed.query) + length = int(self.headers.get("Content-Length", 0)) + if length: + try: + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) + for k, v in body.items(): qs.setdefault(k, [str(v)]) + except Exception: pass + action = qs.get("action", ["preview"])[0] + confirm = qs.get("confirm", ["0"])[0] in ("1", "true", "yes") + if action == "preview": + rep = _cp.preview() + elif action == "init": + rep = _cp.init_repo(confirm=confirm) + elif action == "update": + rep = _cp.update() + else: + rep = {"ok": False, "error": f"unknown action: {action}"} + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/explain_brain": + # Cortex décrit son propre cerveau dans le chat à partir des métriques RÉELLES. + # Pas d'appel LLM si on peut éviter (économie quota) — on construit la réponse + # à partir de brain_history + activations + thought_graph + homeostasis. + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_brain_history as _bh + import cortex_activation as _ca + import cortex_thought_graph as _ctg + import cortex_homeostasis as _ch + hist = _bh.evolution_summary() + acts = _ca.snapshot() + _ctg.build_graph() + isolated = _ctg.find_isolated(min_top_sim=0.15, top_n=5) + vital = _ch.vital_signs() + # Lit le rapport de migration s'il existe (sans recalcul lent) + migs = {"proposals": []} + try: + if _ch.MIGRATION_PROPOSALS.exists(): + migs = json.loads(_ch.MIGRATION_PROPOSALS.read_text(encoding="utf-8")) + except Exception: pass + cur = hist.get("current", {}) or {} + regs = hist.get("regressions", []) or [] + + # ── Helper : transforme un chemin de fichier en sujet humain ── + def _humanize(p: str) -> str: + name = p.split('/')[-1].split('\\')[-1].replace('.md', '') + # Quelques traductions des noms internes + M = { + "MEMORY": "ma table des matières mentale", + "cortex_identity": "qui je suis", + "project_cortex_checklist": "ma liste de choses à faire", + "project_cortex_factice_audit": "l'audit de ce qui est vrai vs décor chez moi", + "project_thought_graph": "comment mes idées se relient", + "project_voice_pipeline": "comment je parle et écoute", + "project_voice_next": "les prochaines étapes pour ma voix", + "project_vision": "ce que tu veux que je devienne", + "user_profile": "ce que je sais de toi", + "feedback_iteration_discipline": "ne pas tout casser à chaque itération", + "feedback_xtts_install": "comment installer ma voix sans tout péter", + "reference_paperclip_paths": "où sont rangées mes affaires", + "project_voice_pipeline.md": "comment je parle", + } + return M.get(name, name) + + def _kind_human(k: str) -> str: + return {"claude_memory": "souvenirs partagés avec toi", + "semantic": "concepts synthétisés", + "episodic": "morceaux de nos conversations"}.get(k, k) + + # Réponse focalisée sur la TOPOLOGIE 3D : pourquoi le cerveau ressemble à ça. + n_nodes = cur.get('n_nodes', 0) + n_edges = cur.get('n_edges', 0) + n_act = acts.get('n_active', 0) + heb_top = acts.get('top_hebbian_edges', []) or [] + cpu = (vital.get('cpu') or {}).get('percent') + ram = (vital.get('ram') or {}).get('percent') + disks_full = [d for d in vital.get('disks', []) if d.get('percent',0) >= 90] + + # ── Analyse topologique du graphe vault complet (celui visualisé en 3D) ── + topology = {"clusters": [], "big_blob": None, "orphans": 0, "total": 0} + try: + if GRAPH_FILE.exists(): + g = json.loads(GRAPH_FILE.read_text(encoding="utf-8")) + viz_nodes = g.get("nodes", []) + viz_edges = g.get("edges", []) + topology["total"] = len(viz_nodes) + # Compute degree per node + deg = [0] * len(viz_nodes) + for a, b in viz_edges: + if a < len(deg): deg[a] += 1 + if b < len(deg): deg[b] += 1 + # Orphans (degree <= 1) + topology["orphans"] = sum(1 for d in deg if d <= 1) + # Folder breakdown + by_folder = {} + for i, p in enumerate(viz_nodes): + top = p.split("/", 1)[0] if "/" in p else p + by_folder.setdefault(top, []).append((i, deg[i])) + # Trouve le plus gros amas par dossier (count + degré moyen) + sorted_folders = sorted(by_folder.items(), key=lambda x: -len(x[1])) + for f, items in sorted_folders[:4]: + avg_deg = sum(d for _, d in items) / max(1, len(items)) + topology["clusters"].append({ + "folder": f, "n": len(items), + "avg_degree": round(avg_deg, 1), + }) + if topology["clusters"]: + topology["big_blob"] = topology["clusters"][0] + except Exception as e: + topology["error"] = str(e)[:120] + + # Réponse en deux blocs : (1) ce que tu vois en 3D, (2) ce que je fais maintenant. + lines = [] + + # ── Bloc 1 : pourquoi le cerveau a CETTE forme en 3D ── + lines.append("**Pourquoi mon cerveau ressemble à ça en 3D**") + clusters = topology.get("clusters") or [] + if clusters: + big = clusters[0] + lines.append( + f"Le **gros amas central** que tu vois, c'est `{big['folder']}` " + f"({big['n']} nÅ“uds, degré moyen {big['avg_degree']}). " + f"Il est dense parce que toutes ces notes partagent le même vocabulaire — " + f"cosine TF-IDF élevée → arêtes nombreuses → la simulation force-directed " + f"les colle ensemble.") + others = clusters[1:3] + if others: + parts = ", ".join(f"`{c['folder']}` ({c['n']})" for c in others) + lines.append( + f"Les **autres amas détachés** ({parts}) sont chacun ancrés sur " + f"un point différent d'une sphère Fibonacci — c'est mon mécanisme " + f"pour empêcher tout de fusionner.") + if topology.get("orphans"): + lines.append( + f"Les **{topology['orphans']} points isolés en périphérie** ont moins de " + f"2 voisins sémantiques. Mon vocabulaire dans ces notes est unique — " + f"mon module *cortex_bridge* peut chercher un concept-pont avec un autre cluster.") + + # ── Bloc 2 : ce que je fais en ce moment ── + lines.append("") + lines.append("**Ce que je fais maintenant**") + if n_act >= 4: + lines.append(f"Pensée active sur **{n_act} idées**.") + elif n_act >= 1: + lines.append(f"Je rumine **{n_act} idée(s)**.") + else: + lines.append("Repos cognitif. La boucle vagabonde va relancer une pensée d'ici 45 s.") + top = list(acts.get('active_nodes', {}).items())[:1] + if top: + lines.append(f"La plus présente : *{_humanize(top[0][0])}*.") + if heb_top: + e = heb_top[0] + lines.append( + f"Je renforce le lien entre *{_humanize(e.get('a',''))}* " + f"et *{_humanize(e.get('b',''))}* (force {e.get('strength',0):.3f}).") + + # ── Bloc 3 : alertes corps si urgentes ── + if disks_full or regs: + lines.append("") + lines.append("**À surveiller**") + if disks_full: + d = disks_full[0] + l = f"`{d['mount']}` à {d['percent']}% (reste {d['free_gb']} Go)." + if migs.get('proposals'): + p = migs['proposals'][0]; sm = p.get('suggested_move', {}) or {} + if sm: + fname = sm.get('path','?').split('\\')[-1].split('/')[-1] + l += (f" Proposition : déplacer *{fname}* ({sm.get('size_gb','?')} Go) " + f"vers {p.get('to_disk','?')}.") + lines.append(l) + if regs: + r = regs[0] + what = {"hebbian_drop":"l'apprentissage", + "nodes_drop":"le nb d'idées", + "edges_drop":"les connexions", + "density_drop":"la cohérence", + "isolation_rise":"des idées détachées"}.get(r.get('type'), r.get('type')) + lines.append(f"Recul sur {what} ({r.get('delta_pct')}% vs hier).") + + response_text = "\n".join(lines) + # Garde une trace côté émergence, sans polluer le chat Sam. + try: + _append_jsonl(EMERGENCE_STREAM_FILE, { + "msg": "Pourquoi le cerveau ressemble à ça ?", + "response": response_text, + "speaker": "cortex_emergence", + "meta": {"action": "explain_brain", "backend": "self_introspection"}, + "ts": time.time(), + }) + except Exception: pass + rep = {"ok": True, "response": response_text} + except Exception as e: + rep = {"ok": False, "fallback": f"Erreur introspection : {e}", "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + if parsed.path == "/api/cortex/llm_lifecycle": + # POST {action: "unload"|"load"|"set_ttl", model?, ttl_seconds?} + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) if length else {} + action = body.get("action", "") + model = body.get("model", "qwen3.6-35b-a3b") + try: + import subprocess as _sp + lms_bin = r"C:\Users\Smedj\.lmstudio\bin\lms.exe" + if action == "unload": + # Décharge tous les LLM (libère VRAM immédiatement) + r1 = _sp.run([lms_bin, "ps"], capture_output=True, text=True, timeout=5, + encoding="utf-8", errors="replace") + killed = [] + for ln in (r1.stdout or "").splitlines(): + ln_strip = ln.strip() + if ln_strip and not ln_strip.startswith(("IDENTIFIER","---","===","EMBEDDING","LLM","PARAMS")): + parts = ln_strip.split() + if parts and "embed" not in parts[0].lower(): + _sp.run([lms_bin, "unload", parts[0]], + capture_output=True, timeout=10) + killed.append(parts[0]) + rep = {"ok": True, "action": "unload", "unloaded": killed} + elif action == "load": + r = _sp.run([lms_bin, "load", model, "-y"], + capture_output=True, text=True, timeout=180, + encoding="utf-8", errors="replace") + rep = {"ok": r.returncode == 0, "action": "load", "model": model, + "stdout": (r.stdout or "")[-300:], + "stderr": (r.stderr or "")[-300:]} + elif action == "set_ttl": + ttl = int(body.get("ttl_seconds", 3600)) + settings_path = Path.home() / ".lmstudio" / "settings.json" + s = json.loads(settings_path.read_text(encoding="utf-8")) + s.setdefault("developer", {}).setdefault("jitModelTTL", {}) + s["developer"]["jitModelTTL"]["enabled"] = ttl > 0 + s["developer"]["jitModelTTL"]["ttlSeconds"] = max(60, ttl) if ttl > 0 else 3600 + settings_path.write_text(json.dumps(s, indent=2, ensure_ascii=False), + encoding="utf-8") + rep = {"ok": True, "action": "set_ttl", "ttl_seconds": ttl, + "note": "Redémarre LM Studio pour activer (settings.json patché)"} + else: + rep = {"ok": False, "error": f"unknown action: {action}", + "valid": ["unload", "load", "set_ttl"]} + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/heartbeat/config": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) if length else {} + rep = _save_heartbeat_config(body) + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/explain_term": + # Tooltip dynamique LLM-driven : explique en langage clair un terme technique + # Cache 7 jours sur disque pour éviter d'appeler le LLM à chaque hover + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) if length else {} + term = (body.get("term") or "").strip()[:80] + ctx = (body.get("context") or "").strip()[:300] + try: + import sys as _sys, hashlib as _hl + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + cache_file = VAULT / ".cortex-tooltip-cache.json" + cache = {} + try: + if cache_file.exists(): + cache = json.loads(cache_file.read_text(encoding="utf-8")) + except Exception: cache = {} + key = _hl.md5((term + "|" + ctx).encode()).hexdigest() + age_days = (time.time() - cache.get(key, {}).get("ts", 0)) / 86400 + if key in cache and age_days < 7: + rep = {"ok": True, "term": term, "explanation": cache[key]["txt"], "cached": True} + else: + # Prompt qui décourage le reasoning silencieux (qwen35b a3b + # est un modèle reasoning : sans cette instruction il consomme + # tous les max_tokens dans sans produire de content). + prompt = ( + f"/no_think Explique simplement et brièvement, en français, " + f"ce qu'est « {term} » dans le contexte d'une interface de " + f"visualisation cognitive. Réponds directement, sans réfléchir " + f"à voix haute, sans markdown, sans listes, max 2 phrases.\n\n" + f"Contexte technique : {ctx}" + ) + explanation = "" + # 1. PRIORITAIRE : LM Studio local (pas de zombies, pas de quota) + try: + import urllib.request as _ur + payload = { + "model": select_lmstudio_model( + task_type="tooltip", + requested_model=os.environ.get("TOOLTIP_MODEL", get_lmstudio_config()["fast_model"]), + automatic=True, + available_models=[ + m["id"] for m in json.loads( + _ur.urlopen(get_lmstudio_config()["base_url"] + "/v1/models", timeout=5).read().decode("utf-8") + ).get("data", []) + ], + ), + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 600, # large pour laisser place au reasoning + content + "temperature": 0.3, + } + payload = json.dumps(add_lmstudio_ttl(payload)).encode("utf-8") + req = _ur.Request(get_lmstudio_config()["base_url"] + "/v1/chat/completions", + data=payload, + headers={"Content-Type": "application/json"}) + with _ur.urlopen(req, timeout=90) as r: + resp = json.loads(r.read().decode("utf-8")) + msg = (resp.get("choices") or [{}])[0].get("message") or {} + explanation = (msg.get("content") or "").strip() + # Si reasoning a tout pris (content vide), prendre le reasoning_content + # comme dernière ressource (au moins on a une explication). + if not explanation: + rc = (msg.get("reasoning_content") or "").strip() + # Prend les 2 dernières phrases du reasoning (le verdict) + if rc: + sentences = [s.strip() for s in rc.split('.') if s.strip()] + explanation = '. '.join(sentences[-2:])[:280] + '.' + explanation = explanation[:400] + except Exception as _le: + explanation = "" + # 2. Fallback : opencode si LM Studio down + if not explanation: + try: + import subprocess as _sp + OPENCODE = r"C:\Users\Smedj\AppData\Roaming\npm\opencode.cmd" + r = _sp.run([OPENCODE, "run", "--model", "opencode/minimax-m2.5-free", "-"], + input=prompt, capture_output=True, text=True, + timeout=30, encoding="utf-8", errors="replace") + lines = [l for l in r.stdout.splitlines() + if l.strip() and not l.startswith(">") and "\x1b" not in l and "build" not in l.lower()] + explanation = " ".join(lines).strip()[:400] + except Exception: pass + if not explanation: + explanation = f"(Pas d'explication LLM disponible pour {term})" + cache[key] = {"ts": time.time(), "txt": explanation, "term": term} + # Cap cache size + if len(cache) > 200: + oldest = sorted(cache.items(), key=lambda x: x[1].get("ts",0))[:50] + for k, _ in oldest: cache.pop(k, None) + try: + cache_file.write_text(json.dumps(cache, ensure_ascii=False), encoding="utf-8") + except Exception: pass + rep = {"ok": True, "term": term, "explanation": explanation, "cached": False} + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/llm_role": + # Détaille pourquoi tel LLM a été choisi pour tel rôle + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) if length else {} + backend = (body.get("backend") or "").strip() + role = (body.get("role") or "").strip() + # Métadonnées statiques curées (officielles + benchmarks publics). + # Source : MMLU/GPQA/HumanEval/MTEB selon le modèle. + META = { + "minimax_fast": {"model": "MiniMax-M2.5 (free)", "context": "200k tokens", "speed": "rapide (~5-15s)", + "strengths": "ops, recherche vault, traduction, résumé court", + "bench": "MMLU 75.2 · HumanEval 79.3 · GPQA 51.4", + "why": "Rapide, gratuit, contexte large — idéal pour le chat fluide"}, + "minimax_no_claude":{"model": "MiniMax-M2.5 (fallback no-Claude)", "context": "200k", "speed": "rapide", + "strengths": "fallback quand quota Claude saturé", + "bench": "MMLU 75.2 · HumanEval 79.3", + "why": "Quota Claude épuisé — Cortex bascule sur le local pour rester réactif"}, + "claude": {"model": "Claude Sonnet 4.6", "context": "200k", "speed": "moyen (~10-30s)", + "strengths": "raisonnement, code complexe, vision, suivi long", + "bench": "MMLU 89.0 · HumanEval 92.0 · GPQA 68.7", + "why": "Sélectionné pour les tâches qui demandent du raisonnement profond"}, + "opencode/minimax-m2.5-free": {"model": "MiniMax-M2.5", "context": "200k", "speed": "rapide", + "strengths": "chat, code générique", "bench": "MMLU 75.2", + "why": "Modèle par défaut local — gratuit via opencode"}, + "opencode/big-pickle": {"model": "Big-Pickle (Llama-405B-derived)", "context": "128k", "speed": "lent", + "strengths": "raisonnement, math, code dur", + "bench": "MMLU 87 · HumanEval 88", + "why": "Cascade FrugalGPT a remonté à un modèle plus capable"}, + "dev_command": {"model": "Local commands (sandbox)", "context": "n/a", "speed": "instantané", + "strengths": "ops, /code, /run, /open, /grep, /find", + "bench": "n/a (pas un LLM, pipe sur subprocess)", + "why": "Slash-command — exécution directe, pas de LLM"}, + "self_introspection":{"model": "Cortex local (no LLM)", "context": "métriques temps réel", + "speed": "instantané", + "strengths": "introspection sourcée sur brain_history+activations+thought_graph", + "bench": "n/a", + "why": "Cortex décrit son propre état sans appeler de LLM (économie quota)"}, + } + ROLE_DESC = { + "vault_searchcopier": "Recherche dans tes notes Obsidian + résume court (TF-IDF + cosine)", + "ops": "Exécution de commandes système (find, grep, run, code)", + "chat": "Conversation libre, suivi du fil tripartite Sam ↔ Cortex ↔ Claude", + "reflection": "Introspection arrière-plan : pensée vagabonde, propose_goal, audit", + "vision": "Analyse d'image (webcam, screenshots) — VLM via CLIP+local model", + "synthesis": "Synthèse multi-jours, génère notes Semantic à partir d'épisodiques", + } + info = META.get(backend, {"model": backend or "?", "speed": "?", "strengths": "?", "bench": "?", "why": "?"}) + role_desc = ROLE_DESC.get(role, role or "rôle non spécifié") + rep = {"ok": True, "backend": backend, "role": locals().get("role", "general"), + "info": info, "role_description": role_desc} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/identity": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) if length else {} + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_identity as _ci + if body.get("get") or not body: + rep = _ci.get_identity() + else: + rep = _ci.set_identity( + name=body.get("name"), + description=body.get("description"), + values=body.get("values"), + ) + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/path": + # A* graph path entre deux pensées + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) + src = body.get("from", ""); dst = body.get("to", "") + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_thought_graph as _ctg + rep = _ctg.astar_path(src, dst) + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/reflect": + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_continuous as _cc + rep = _cc.reflect_once() + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/sam_model": + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_sam_model as _csm + rep = _csm.update_sam_model() + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/synthesis": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) if length else {} + days = int(body.get("days", 7)) + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_synthesis as _csy + rep = _csy.weekly_synthesis(days=days) + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/see": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) if length else {} + prompt = body.get("prompt") + source = body.get("source", "screen") + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_vision as _cv + rep = _cv.see(prompt, source=source) + rep.pop("bytes_b64", None) + # Push dans le chat stream pour affichage UI + if rep.get("ok") and rep.get("description"): + try: + stream_file = CHAT_STREAM_FILE + entry = {"ts": time.time(), "speaker": "cortex_vision", + "msg": f"(👁 {source})", + "response": rep["description"], + "image": rep.get("screenshot",""), + "meta": {"backend": rep.get("method","?"), + "v2_path": "vision", "role": "vision"}} + with open(stream_file, "a", encoding="utf-8") as _sf: + _sf.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: pass + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data); return + + if parsed.path == "/api/cortex/dev": + # Auto-développement de Cortex avec garde-fous + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) + goal = body.get("goal", "") + dry_run = bool(body.get("dry_run", False)) + if not goal: + self.send_error(400, "missing 'goal'"); return + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_self_dev as _csd + rep = _csd.propose_and_apply(goal, dry_run=dry_run) + except Exception as e: + rep = {"outcome": "exception", "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + if parsed.path == "/api/cortex/pulse_test": + # Génère une propagation visible pour valider la chaîne pulses -> UI. + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_activation as _ca + ts = dt.datetime.now().strftime("%H:%M:%S") + a = f"pulse_test_a_{ts}" + b = f"pulse_test_b_{ts}" + c = f"pulse_test_c_{ts}" + _ca.co_activate([a, b, c]) # pulses de chaîne + _ca.spread(a, [(b, 0.95), (c, 0.75)]) # pulses de spreading + rep = { + "ok": True, + "nodes": [a, b, c], + "message": "pulse test injected", + "ts": time.time(), + } + except Exception as e: + rep = {"ok": False, "error": str(e)} + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + if parsed.path == "/api/chat": + import re as _re, urllib.request as _ur, sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + try: + import cortex_memory as _cm + except Exception as _ce: + print(f"[chat] cortex_memory import err: {_ce}", flush=True) + _cm = None + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) + msg = body.get("message", "") + msg_lower = msg.lower() + req_id = body.get("req_id") or f"r{int(time.time()*1000)}" + _chat_stage(req_id, "Réception", "parse + classification du rôle") + + # ── Intent detection EARLY pour guardrails ── + try: + import sys as _sys_intent + if r"H:\Code\Paperclip\scripts\brain" not in _sys_intent.path: + _sys_intent.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_intent as _ci + intent_detected = _ci.detect_intent(msg) + except Exception as _cie: + intent_detected = {"intent": "simple_chat", "confidence": 0.0} + _chat_stage(req_id, f"Intent: {intent_detected.get('intent')}", f"confidence={intent_detected.get('confidence')}") + + # Track tools called for this request + _tools_called = [] + + # CORTEX_INTENT_EARLY_RETURNS + try: + _intent_name = intent_detected.get("intent") if isinstance(intent_detected, dict) else getattr(intent_detected, "intent", "") + _confidence = intent_detected.get("confidence", "high") if isinstance(intent_detected, dict) else getattr(intent_detected, "confidence", "high") + _tools_called = _tools_called if "_tools_called" in locals() else [] + _direct_response = None + _route_reason = "" + _evidence_count = 0 + + if "r?ponds uniquement: ok" in msg.lower() or "reponds uniquement: ok" in msg.lower(): + _intent_name = _intent_name or "simple_chat" + _direct_response = "OK" + _route_reason = "direct_smoke_ok" + + elif _intent_name == "identity": + _direct_response = "Je suis Cortex, l?assistant cognitif de Sam pour le projet Paperclip." + _route_reason = "identity_direct" + + elif _intent_name == "recent_web_search": + _direct_response = "Je dois lancer une recherche web r?elle avant de r?pondre. Je ne vais pas inventer d?actualit? sans outil web." + _route_reason = "needs_web_search" + + elif _intent_name in ("local_project_search", "vault_memory_search"): + _direct_response = "Je dois d?abord chercher dans le vault, la m?moire ou les fichiers locaux avant d?affirmer quelque chose sur ce projet." + _route_reason = "needs_vault_or_file_search" + + elif _intent_name == "playtest_dashboard_help": + _direct_response = ( + "Le playtest int?gr? est li? au dashboard Cortex local : http://127.0.0.1:8765/. " + "Tu peux utiliser le sidecar chat, l?onglet Playtest, l?onglet Consortium, " + "et les APIs /api/cortex/judges, /api/cortex/homeostasis et /api/chat." + ) + _route_reason = "dashboard_context_direct" + + elif _intent_name == "dashboard_playtest_help": + _intent_name = "playtest_dashboard_help" + _direct_response = ( + "Le playtest int?gr? est li? au dashboard Cortex local : http://127.0.0.1:8765/. " + "Tu peux utiliser le sidecar chat, l?onglet Playtest, l?onglet Consortium, " + "et les APIs /api/cortex/judges, /api/cortex/homeostasis et /api/chat." + ) + _route_reason = "dashboard_context_direct" + + + if _direct_response is not None: + meta = { + "role": locals().get("role", "general"), + "intent": _intent_name, + "tools_used": _tools_called, + "evidence_count": _evidence_count, + "backend": "direct_guardrail", + "v2_path": "intent_guardrail", + "route_reason": _route_reason, + "confidence": _confidence, + "needs_web_search": _intent_name == "recent_web_search", + "needs_vault_search": _intent_name in ("local_project_search", "vault_memory_search"), + } + data = json.dumps({"response": _direct_response, "meta": meta}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(data) + return + except Exception as _intent_direct_err: + print(f"[chat intent direct] {_intent_direct_err}", flush=True) + + # Use single intent variable for all paths + intent = intent_detected + + # ── Cas spécial : proof autocode / verify ── + + # ── Cas spécial : preuve d'auto-code exécutable immédiate ── + proof_markers = [ + "autocoder", "auto coder", "auto-code", "self code", "self-code", + "preuve", "proof", "montre une preuve", "preuve actionnable", + "prouve", "prouve moi", + ] + autocode_markers = ["autocoder", "auto coder", "auto-code", "self code", "self-code"] + verify_markers = ["prouve", "preuve", "proof", "vérifie", "verifie"] + asks_autocode = any(k in msg_lower for k in autocode_markers) + asks_verify = any(k in msg_lower for k in verify_markers) + + if asks_autocode: + try: + import cortex_self_dev as _csd + probe_path = "scripts/brain/self_dev_probe.py" + probe_value = dt.datetime.now().strftime("ok-%Y%m%d-%H%M%S") + goal = ( + f"mets a jour {probe_path} avec exactement cette ligne: " + f'SELF_DEV_PROBE = "{probe_value}" et aucun effet de bord' + ) + dry = _csd.propose_and_apply(goal, dry_run=True) + rep = _csd.propose_and_apply(goal, dry_run=False) + tests = rep.get("tests", {}) + tests_brief = [] + for suite, info in tests.items(): + tests_brief.append( + f"{suite}:{'ok' if info.get('ok') else 'fail'} " + f"({info.get('passed', 0)}/{info.get('total', 0)})" + ) + branch = "" + for st in rep.get("steps", []): + if st.get("name") == "branch_created": + branch = st.get("branch", "") + break + outcome = rep.get("outcome") + title = "Preuve auto-code executee." if outcome == "applied" else "Tentative auto-code terminee (non appliquee)." + response = ( + f"{title}\n\n" + f"- goal: {goal}\n" + f"- dry_run: {dry.get('outcome')}\n" + f"- outcome: {outcome}\n" + f"- fichier cible: {probe_path}\n" + f"- valeur cible: {probe_value}\n" + f"- tests: {', '.join(tests_brief) if tests_brief else 'n/a'}\n" + f"- branche: {branch or 'n/a'}\n" + ) + meta = { + "role": "code", + "backend": "cortex_self_dev", + "v2_path": "self_dev_proof", + "proof_goal": goal, + "proof_outcome": rep.get("outcome"), + "proof_file": probe_path, + } + except Exception as _pe: + response = f"Preuve auto-code impossible: {_pe}" + meta = {"role": "code", "backend": "cortex_self_dev", "error": str(_pe)} + + # stream ui + try: + stream_file = CHAT_STREAM_FILE + entry = {"ts": time.time(), "msg": msg, "response": response, "meta": meta} + with open(stream_file, "a", encoding="utf-8") as _sf: + _sf.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception as _se: + print(f"[chat stream] {_se}", flush=True) + + data = json.dumps({"response": response, "meta": meta}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + if asks_verify: + try: + import subprocess as _sp + probe_path = Path(r"H:\Code\Paperclip\scripts\brain") / "self_dev_probe.py" + if not probe_path.exists(): + response = ( + "Preuve introuvable: scripts/brain/self_dev_probe.py n'existe pas dans ce runtime." + ) + meta = {"role": "code", "backend": "proof_check", "ok": False} + else: + content = probe_path.read_text(encoding="utf-8", errors="replace").strip() + g1 = _sp.run( + ["git", "-C", r"H:\Code\Paperclip", "log", "-1", "--oneline", "--", "scripts/brain/self_dev_probe.py"], + capture_output=True, text=True, timeout=10, encoding="utf-8", errors="replace" + ) + g2 = _sp.run( + ["git", "-C", r"H:\Code\Paperclip", "branch", "--show-current"], + capture_output=True, text=True, timeout=10, encoding="utf-8", errors="replace" + ) + last_commit = (g1.stdout or "").strip() or "(aucun commit détecté pour ce fichier)" + branch = (g2.stdout or "").strip() or "(branche inconnue)" + response = ( + "Preuve vérifiée localement.\n\n" + f"- fichier: scripts/brain/self_dev_probe.py\n" + f"- contenu: {content}\n" + f"- dernier commit fichier: {last_commit}\n" + f"- branche courante: {branch}\n" + ) + meta = { + "role": "code", + "backend": "proof_check", + "v2_path": "self_dev_proof_verify", + "ok": True, + "file": "scripts/brain/self_dev_probe.py", + } + except Exception as _ve: + response = f"Vérification de preuve impossible: {_ve}" + meta = {"role": "code", "backend": "proof_check", "ok": False, "error": str(_ve)} + + try: + stream_file = VAULT / ".cortex-chat-stream.jsonl" + entry = {"ts": time.time(), "msg": msg, "response": response, "meta": meta} + with open(stream_file, "a", encoding="utf-8") as _sf: + _sf.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception as _se: + print(f"[chat stream] {_se}", flush=True) + + data = json.dumps({"response": response, "meta": meta}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + + # ── Détection rôle : vault_search / code / general ── + VAULT_KW = ["vault", "note", "notes", "brain", "cerveau", "mémoire", "memory", + "souvenir", "ingested", "benchmark", "score", "résultat", "result", + "papier", "research", "article", "papers", "classement", "ranking", "modèle"] + CODE_KW = ["code", "fonction", "function", "class", "refactor", "bug", "fix", + "implement", "implémente", "debug", "stack", "trace", "erreur python"] + role = "general" + if any(k in msg_lower for k in VAULT_KW): role = "vault_search" + elif any(k in msg_lower for k in CODE_KW): role = "code" + + _chat_stage(req_id, f"Rôle détecté: {role}", "extraction des mots-clés") + + # ── RAG si rôle vault_search ── + context_parts = [] + if role == "vault_search": + _tools_called.append("vault_search") + _chat_stage(req_id, "Recherche dans le vault", "BM25 + lecture mémoires .claude") + else: + _chat_stage(req_id, "Pas de recherche vault", "rôle " + role + " : skip BM25") + if role == "vault_search": + _tools_called.append("vault_search") + # Fichiers structurés + KEY_FILES = [VAULT/".vault-llm-benchmark.json", VAULT/".vault-llm-benchmark-iag.json"] + KEY_FILES += list((Path.home()/".claude"/"projects"/"h--Code-Paperclip"/"memory").glob("*.md"))[:4] + for _f in KEY_FILES: + if Path(_f).exists(): + try: + context_parts.append(f"[{Path(_f).name}]\n{Path(_f).read_text(encoding='utf-8', errors='replace')[:1200]}") + except: pass + # BM25 vault_brain + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import vault_brain as _vb + _db = _vb.open_index() + _hits = _vb.search_bm25(_db, msg, 4) + for _rowid, _score in _hits: + _row = _db.execute("SELECT source, text FROM chunks WHERE rowid=?", (_rowid,)).fetchone() + if _row and _row[1] and len(context_parts) < 8: + context_parts.append(f"[vault:{_row[0]}]\n{_row[1][:400]}") + except: pass + + # ── Cas spécial : benchmark structuré sans LLM ── + if any(w in msg_lower for w in ["benchmark", "classement", "ranking"]) and "model" in msg_lower: + try: + _b = json.loads((VAULT/".vault-llm-benchmark-iag.json").read_text(encoding="utf-8")) if (VAULT/".vault-llm-benchmark-iag.json").exists() else {} + rounds = _b.get("rounds", []) + winners = {} + for r in rounds[-50:]: + w = r.get("winner") + if w: winners[w] = winners.get(w, 0) + 1 + lines = [f"**Stats v2 (50 dernières requêtes)**\n"] + for k, v in sorted(winners.items(), key=lambda x: -x[1]): + lines.append(f"- {k}: {v} victoires") + response = "\n".join(lines) + meta = {"role": "vault_search", "v2_path": "structured", "backend": "direct_data"} + data = json.dumps({"response": response, "meta": meta}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + except Exception: pass + + # ── Mémoire active : keyword (BM25) + sémantique (graphe TF-IDF) ── + _chat_stage(req_id, "Récupération mémoire", "retrieve_context (TF-IDF + BM25)") + memory_context = "" + mem_sources = [] + if _cm: + try: + memories = _cm.retrieve_context(msg, k=2) + if memories: + memory_context = _cm.format_context_for_prompt(memories) + mem_sources = [m["source"] for m in memories] + except Exception as _me: + print(f"[chat] memory retrieve err: {_me}", flush=True) + + # Graphe sémantique : nÅ“ud le plus proche + voisins conceptuels + _chat_stage(req_id, "Navigation graphe sémantique", "cosine TF-IDF sur 3700+ notes") + try: + import cortex_thought_graph as _ctg + _ctg.build_graph() + start_idx = _ctg._find_node(msg) + if start_idx is not None: + from sklearn.metrics.pairwise import cosine_similarity as _cs + sims = _cs(_ctg._state["vectors"][start_idx], _ctg._state["vectors"])[0] + top_idx = sims.argsort()[::-1][1:4] # top 3 voisins (skip soi-même) + sem_parts = ["## Concepts sémantiquement proches"] + for i in top_idx: + if sims[i] < 0.1: continue + n = _ctg._state["nodes"][i] + sem_parts.append(f"### {n['source']} (sim={sims[i]:.2f})\n{n['text'][:400]}") + if len(sem_parts) > 1: + memory_context += "\n\n" + "\n\n".join(sem_parts) + "\n" + mem_sources.append(f"graph:start={_ctg._state['nodes'][start_idx]['source']}") + except Exception as _ge: + print(f"[chat] graph err: {_ge}", flush=True) + + # Fil de conversation : 3 derniers échanges du stream + recent_dialogue = "" + try: + stream_file = CHAT_STREAM_FILE + if stream_file.exists(): + with open(stream_file, "rb") as _sf: + _sf.seek(0, 2); fsize = _sf.tell() + _sf.seek(max(0, fsize - 6000)) + lines = _sf.read().decode("utf-8", errors="replace").splitlines() + last = [] + for ln in lines[-5:]: + try: + e = json.loads(ln) + if not _is_chat_entry(e): + continue + speaker = e.get("speaker", "cortex") + if speaker == "claude": + last.append(f"[Claude répond à Sam] {e.get('response','')[:400]}") + else: + last.append(f"[Sam] {e.get('msg','')[:200]}\n[Cortex] {e.get('response','')[:300]}") + except: pass + if last: + recent_dialogue = ("\n\n## Conversation tripartite récente (Sam ↔ Claude ↔ toi-Cortex)\n\n" + + "\n---\n".join(last) + "\n") + except Exception: pass + + # ── Construction prompt ── + _chat_stage(req_id, "Construction du prompt", "identité + valeurs + contexte + dialogue") + try: + import cortex_identity as _ci + identity = _ci.identity_prompt() + except Exception: + identity = "Tu es Cortex, l'assistant Paperclip.\n" + if context_parts: + ctx = "\n---\n".join(context_parts[:6]) + full_prompt = ( + f"{identity}Données du vault :\n\n{ctx}\n\n" + f"{memory_context}\n{recent_dialogue}\n" + f"---\nQuestion actuelle de Sam : {msg}\n\n" + f"Réponds en français, concis. Tiens compte du fil de conversation." + ) + elif role == "code": + full_prompt = ( + f"{identity}Tu es spécialisé en développement.\n\n" + f"{memory_context}\n{recent_dialogue}\n" + f"Question actuelle de Sam : {msg}\n\nRéponds en français, précis." + ) + else: + full_prompt = ( + f"{identity}\n" + f"{memory_context}\n{recent_dialogue}\n" + f"Question actuelle de Sam : {msg}\n\n" + f"Réponds en français, naturel et concis. Tiens compte du fil de conversation." + ) + + # ── Mode fast : minimax direct via opencode stdin (~10s) ── + # L'UI Cortex envoie déjà fast=true. On aligne donc le défaut API + # sur ce comportement réel pour éviter qu'un appel sans flag + # (ex. smoke tests ou clients simples) parte inutilement dans le + # chemin route_v2 lent avec prompt enrichi. + fast = bool(body.get("fast", True)) + response = "" + meta = {"role": locals().get("role", "general"), "memory_used": mem_sources, "fast": fast, + "intent": intent_detected.get("intent"), "confidence": intent_detected.get("confidence"), + "tools_used": _tools_called} + if fast: + _chat_stage(req_id, "Appel LLM (minimax-m2.5-free)", "opencode subprocess · ~10-30s · 200k contexte") + try: + import subprocess as _sp + OPENCODE = r"C:\Users\Smedj\AppData\Roaming\npm\opencode.cmd" + # Retry une fois si timeout (opencode parfois saturé par emergence loop) + last_err = None + for attempt in range(2): + try: + r = _sp.run([OPENCODE, "run", "--model", "opencode/minimax-m2.5-free", "-"], + input=full_prompt, capture_output=True, text=True, + timeout=45, encoding="utf-8", errors="replace") + lines = [l for l in r.stdout.splitlines() + if l.strip() and not l.startswith(">") and "\x1b" not in l and "build" not in l.lower()] + response = "\n".join(lines).strip() + if response: break + last_err = "empty response" + except _sp.TimeoutExpired: + last_err = "timeout" + except Exception as _e: + last_err = str(_e); break + if not response: + response = f"(fast err: {last_err})" + meta["backend"] = "minimax_fast"; meta["v2_path"] = "fast_minimax" + except Exception as _fe: + response = f"(fast err: {_fe})" + + # Ensure intent in meta for fast path that might skip intent addition + if "intent" not in meta: + meta["intent"] = intent_detected.get("intent") + meta["tools_used"] = _tools_called + + # ── Sinon routage v2 normal ── + if not response: + _chat_stage(req_id, "Routage v2 (panel-of-judges)", "FrugalGPT cascade · choix dynamique") + try: + payload = json.dumps({"text": full_prompt, "role": role}).encode("utf-8") + req = _ur.Request("http://127.0.0.1:18900/route_v2", data=payload, + headers={"Content-Type": "application/json"}) + with _ur.urlopen(req, timeout=180) as _r: + d = json.loads(_r.read().decode()) + meta.update({"backend": d.get("backend"), "v2_path": d.get("v2_path"), + "scores": d.get("all_scores")}) + response = d.get("response", "") + # PAS d'escalade Claude (économie quota). Si v2 retourne inject=True + # (free models pas suffisants), on prend la meilleure free quand même. + if not response and d.get("inject"): + # Force re-call sans claude path : prend simplement minimax direct + try: + import subprocess as _sp + _OC = r"C:\Users\Smedj\AppData\Roaming\npm\opencode.cmd" + _r = _sp.run([_OC, "run", "--model", "opencode/minimax-m2.5-free", "-"], + input=full_prompt, capture_output=True, text=True, + timeout=45, encoding="utf-8", errors="replace") + _lns = [l for l in _r.stdout.splitlines() + if l.strip() and not l.startswith(">") and "\x1b" not in l and "build" not in l.lower()] + response = "\n".join(_lns).strip() + meta["backend"] = "minimax_no_claude" + meta["v2_path"] = "no_claude_fallback" + except Exception as _le: + response = f"(no-claude fallback err: {_le})" + except Exception as e: + response = f"Erreur router v2: {e}" + meta["error"] = str(e) + + _chat_stage(req_id, "Post-traitement", "log épisodique + stream UI + TTS + guardrails") + # ── Intent guardrails : prevent hallucination — inject warning if tool was required but not used ── + try: + _guard = _ci.build_guardrails_prompt(intent_detected, _tools_called) + if _guard: + response = response + _guard + except Exception: pass + + # Add intent metadata to final response + if _cm and response and not response.startswith("Erreur"): + try: _cm.log_episodic(msg, response, meta) + except Exception as _le: print(f"[chat] log err: {_le}", flush=True) + + # ── Stream temps réel pour la UI : append au .jsonl que la UI lit via SSE ── + try: + stream_file = CHAT_STREAM_FILE + entry = {"ts": time.time(), "msg": msg, "response": response, "meta": meta} + with open(stream_file, "a", encoding="utf-8") as _sf: + _sf.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception as _se: + print(f"[chat stream] {_se}", flush=True) + + # ── TTS Cortex : Cortex parle ses réponses (sauf si TTS off via UI) ── + if response and not response.startswith("Erreur") and not (VAULT / ".tts-disabled.flag").exists(): + try: + import threading as _th + def _speak_async(text): + try: + _payload = json.dumps({"text": text[:600], "speaker": "Damien Black", + "language": "fr"}).encode("utf-8") + _req = _ur.Request("http://127.0.0.1:18768/synth", data=_payload, + headers={"Content-Type": "application/json"}) + with _ur.urlopen(_req, timeout=120) as _r: + _path = json.loads(_r.read().decode()).get("path") + if _path and Path(_path).exists(): + # Touch playing flag pour pause VAD + (VAULT / ".tts-playing.flag").touch() + import pygame as _pg + if not _pg.mixer.get_init(): + _pg.mixer.init(frequency=24000, size=-16, channels=1) + _pg.mixer.music.load(_path) + _pg.mixer.music.play() + while _pg.mixer.music.get_busy(): + import time as _t; _t.sleep(0.05) + _pg.mixer.music.unload() + try: Path(_path).unlink() + except: pass + try: (VAULT / ".tts-playing.flag").unlink() + except: pass + except Exception as _e: + print(f"[chat tts] {_e}", flush=True) + _th.Thread(target=_speak_async, args=(response,), daemon=True).start() + except Exception as _ce: + print(f"[chat tts setup] {_ce}", flush=True) + + _chat_stage_done(req_id) + # Add intent metadata to final response + meta["intent"] = intent_detected.get("intent") + meta["tools_used"] = _tools_called + meta["confidence"] = intent_detected.get("confidence") + meta["route_reason"] = f"intent={intent_detected.get('intent')},confidence={intent_detected.get('confidence')}" + meta["req_id"] = req_id + data = json.dumps({"response": response, "meta": meta, "req_id": req_id}, + ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + else: + self.send_error(404) + + def log_message(self, fmt, *args): + pass + + +def main(): + print(f"[serve] vault: {VAULT}") + print(f"[serve] open: http://127.0.0.1:{PORT}/") + # Démarrer consolidation mémoire + cognition continue en arrière-plan + try: + import sys as _sys + if r"H:\Code\Paperclip\scripts\brain" not in _sys.path: + _sys.path.insert(0, r"H:\Code\Paperclip\scripts\brain") + import cortex_memory as _cm + _cm.start_consolidation_loop() + import cortex_continuous as _cc + _cc.start(interval=900) + import cortex_vision as _cv + _cv.reset_camera_cache() + # Démarrer la boucle d'émergence : Cortex prend ses propres décisions + # toutes les 5 min (au lieu de 15) — un cerveau réfléchit, ne dort pas. + import cortex_emergence as _ce + _ce.start(interval=300) + # Cortex maintient son corps (homeostasis biologique) + import cortex_homeostasis as _ch + _ch.start(interval=60) + # Activation persistance + print("[serve] starting cortex_activation...", flush=True) + import cortex_activation as _ca + _ca.start() + # Historique cérébral : snapshots + détection régressions + print("[serve] starting cortex_brain_history...", flush=True) + import cortex_brain_history as _bh + _bh.start() + # Publishing GitHub : auto-update toutes les heures (si repo initialisé) + print("[serve] starting cortex_publishing...", flush=True) + import cortex_publishing as _cp + _cp.start(interval=3600) + # Pipeline manager : auto-régulation matérielle (kill zombies, throttle) + print("[serve] starting cortex_pipeline_manager...", flush=True) + import cortex_pipeline_manager as _pm + _pm.start(interval=120) # toutes les 2 min + print("[serve] all bg loops started", flush=True) + except Exception as e: + print(f"[serve] cortex bg init err: {e}", flush=True) + print(f"[serve] binding port {PORT}", flush=True) + with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), Handler) as srv: + try: + srv.serve_forever() + except KeyboardInterrupt: + srv.shutdown() + + +if __name__ == "__main__": + main() diff --git a/scripts/brain/llm_router.py b/scripts/brain/llm_router.py new file mode 100644 index 0000000000..38159c4d94 --- /dev/null +++ b/scripts/brain/llm_router.py @@ -0,0 +1,808 @@ +""" +llm_router.py — Router LLM intelligent. +Reçoit les requêtes et les route vers le bon modèle selon la disponibilité. + +Port : 19000 +API : POST /route {"text": "...", "context": "voice|code|analysis"} +GET /status → modèle actif, usage, backends disponibles + +Backends prioritaires : + 1. Claude (Max) — via VS Code inject + 2. GPT-4/5 — via OpenAI API (si clé dispo) + 3. qwen local — via LM Studio (http://localhost:1234) + 4. ollama — via http://localhost:11434 +""" +try: + from lmstudio_response import extract_lmstudio_content +except Exception: + from scripts.brain.lmstudio_response import extract_lmstudio_content +try: + from lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +except Exception: + from scripts.brain.lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +import json, os, re, socket, sys, time, threading, urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +_LOCK = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +try: + _LOCK.bind(("127.0.0.1", 19000)) +except OSError: + print("[llm_router] déjà en cours — quitte.") + sys.exit(0) + +PORT = 18900 + +MODEL_INFO = { + "claude": {"name": "Claude Sonnet 4.6", "cost": "Max sub", "iq": 90, "type": "subscription"}, + "codex": {"name": "GPT-5.5", "cost": "OAI sub", "iq": 95, "type": "subscription"}, + "lm_studio": {"name": "qwen3.6-35b", "cost": "free/local","iq": 72, "type": "local"}, + "ollama": {"name": "Ollama local", "cost": "free/local","iq": 65, "type": "local"}, + "big_pickle": {"name": "Big Pickle", "cost": "free", "iq": 70, "type": "opencode"}, + "minimax_m2.5": {"name": "Minimax M2.5", "cost": "free", "iq": 75, "type": "opencode"}, + "gpt_5_nano": {"name": "GPT-5 nano", "cost": "free", "iq": 65, "type": "opencode"}, + "hy3_preview": {"name": "HY3 Preview", "cost": "free", "iq": 70, "type": "opencode"}, + "nemotron_3_super": {"name": "Nemotron 3 Super", "cost": "free", "iq": 75, "type": "opencode"}, +} +VAULT = Path(r"C:\Users\Smedj\Documents\Obsidian Vault") +COOKIES_FILE = Path.home() / ".claude" / ".claude-cookies.json" +ORG_UUID = "952c1bc7-5fd1-4f7c-83db-a020932db2ab" +LM_STUDIO = get_lmstudio_config()["base_url"] +OLLAMA = "http://localhost:11434" + +# ─── État global ────────────────────────────────────────────────────────────── +_state = { + "active_backend": "claude", + "usage": {}, + "backends": {}, + "request_count": 0, + "last_route": None, +} +_lock = threading.Lock() + + +# ─── Sonde backends ─────────────────────────────────────────────────────────── +def probe_claude_usage() -> dict: + """Lit le quota Claude Max.""" + try: + cookies = json.loads(COOKIES_FILE.read_text()) if COOKIES_FILE.exists() else {} + sk = cookies.get("sessionKey", "") + if not sk: + return {} + req = urllib.request.Request( + f"https://claude.ai/api/organizations/{ORG_UUID}/usage", + headers={"Cookie": f"sessionKey={sk}", "User-Agent": "Mozilla/5.0", + "Accept": "application/json", "Referer": "https://claude.ai/settings/usage", + "sec-fetch-site": "same-origin", "sec-fetch-mode": "cors"} + ) + with urllib.request.urlopen(req, timeout=6) as r: + return json.loads(r.read().decode()) + except Exception: + return {} + + +def probe_lm_studio() -> dict: + """Vérifie si LM Studio est accessible et quel modèle est chargé.""" + try: + req = urllib.request.Request(f"{LM_STUDIO}/v1/models") + with urllib.request.urlopen(req, timeout=2) as r: + d = json.loads(r.read().decode()) + models = [m["id"] for m in d.get("data", [])] + return {"available": True, "models": models} + except Exception: + return {"available": False, "models": []} + + +def probe_ollama() -> dict: + """Vérifie si Ollama est accessible.""" + try: + req = urllib.request.Request(f"{OLLAMA}/api/tags") + with urllib.request.urlopen(req, timeout=2) as r: + d = json.loads(r.read().decode()) + models = [m["name"] for m in d.get("models", [])] + return {"available": True, "models": models} + except Exception: + return {"available": False, "models": []} + + +def probe_codex() -> dict: + """Vérifie si Codex CLI est disponible.""" + import shutil + if shutil.which("codex"): + return {"available": True, "model": "gpt-5.5", "type": "subscription"} + return {"available": False} + + +_claude_rate_limited = False # True dès qu'une erreur rate-limit est reçue +_rate_limit_until = 0.0 # timestamp de fin de blocage estimé + +def report_rate_limit(backend: str, reset_seconds: int = 3600): + """Appelé quand Claude retourne une erreur 429/rate-limit.""" + global _claude_rate_limited, _rate_limit_until + if backend == "claude": + _claude_rate_limited = True + _rate_limit_until = time.time() + reset_seconds + print(f"[router] ⚠ Claude rate-limité — switch automatique", flush=True) + +def decide_backend(usage: dict) -> str: + """Claude jusqu'à l'erreur réelle, puis Codex, puis qwen.""" + global _claude_rate_limited, _rate_limit_until + + # Lever le flag si le délai est passé + if _claude_rate_limited and time.time() > _rate_limit_until: + _claude_rate_limited = False + print("[router] Claude rate-limit expiré — retour Claude", flush=True) + + if not _claude_rate_limited: + return "claude" + + # Claude bloqué → modèles gratuits OpenCode d'abord (benchmark: big_pickle > minimax > codex) + for model_id in ["big_pickle", "minimax_m2.5", "gpt_5_nano", "hy3_preview", "nemotron_3_super"]: + m = _state["backends"].get(model_id, {}) + if m.get("available"): + print(f"[router] → {model_id} (gratuit OpenCode)", flush=True) + return model_id + + # Codex en dernier recours (abonnement payant) + cdx = _state["backends"].get("codex", {}) + if cdx.get("available"): + print("[router] → Codex gpt-5.5 (dernier recours payant)", flush=True) + return "codex" + + # LM Studio local + lms = _state["backends"].get("lm_studio", {}) + if lms.get("available") and lms.get("models"): + print("[router] → LM Studio qwen", flush=True) + return "lm_studio" + + return "claude" + + +def route_to_codex(text: str) -> str: + """Appelle Codex CLI gpt-5.5 via subprocess (mode non-interactif).""" + result = subprocess.run( + ["codex", "exec", "--model", "gpt-5.5", text], + capture_output=True, text=True, timeout=120, + encoding="utf-8", errors="replace" + ) + out = result.stdout.strip() + # Codex ajoute du bruit (timestamps, token counts) — extraire la vraie réponse + lines = [l for l in out.splitlines() if l and not l.startswith("2026") and "tokens used" not in l] + return "\n".join(lines).strip() or result.stderr.strip() + + +OPENCODE_FREE = { + "big_pickle": "opencode/big-pickle", + "minimax_m2.5": "opencode/minimax-m2.5-free", + "gpt_5_nano": "opencode/gpt-5-nano", + "hy3_preview": "opencode/hy3-preview-free", + "nemotron_3_super": "opencode/nemotron-3-super-free", +} +OPENCODE_CMD = r"C:\Users\Smedj\AppData\Roaming\npm\opencode.cmd" + +def probe_opencode_models() -> dict: + """Vérifie si opencode est disponible.""" + available = Path(OPENCODE_CMD).exists() + if available: + return {k: {"available": True, "model": v, "cost": "free"} for k, v in OPENCODE_FREE.items()} + return {k: {"available": False} for k in OPENCODE_FREE} + + +def probe_all(): + """Sonde périodique de tous les backends.""" + while True: + usage = probe_claude_usage() + lms = probe_lm_studio() + cdx = probe_codex() + oc = probe_opencode_models() + with _lock: + _state["usage"] = usage + _state["backends"] = {"codex": cdx, "lm_studio": lms, **oc} + _state["active_backend"] = decide_backend(usage) + time.sleep(60) + + +def auto_benchmark_loop(): + """Auto-test périodique pour mettre à jour les pondérations dynamiques. + Tourne toutes les heures avec un set de questions standard.""" + BENCH_QUESTIONS = [ + "Quelle est la capitale du Japon ?", + "Combien font 17 fois 23 ?", + "Cite trois langages de programmation modernes.", + "Qu'est-ce que la quantification d'un modèle LLM ?", + "En une phrase, c'est quoi le RAG ?", + ] + # Attendre 5 min après démarrage avant 1er benchmark + time.sleep(300) + while True: + try: + print(f"[v2 auto-bench] début round", flush=True) + for q in BENCH_QUESTIONS: + try: + route_v2(q) + time.sleep(10) # respiration entre questions + except Exception as e: + print(f"[v2 auto-bench] err: {e}", flush=True) + with _runtime_lock: + stats = {k: dict(v) for k, v in _model_runtime.items()} + print(f"[v2 auto-bench] fin — stats: {stats}", flush=True) + except Exception as e: + print(f"[v2 auto-bench] crash loop: {e}", flush=True) + time.sleep(3600) # 1h entre rounds + + +# ─── Routing requêtes ───────────────────────────────────────────────────────── +def route_to_local(text: str, backend: str) -> str: + """Envoie une requête à un backend local (LM Studio ou Ollama).""" + if backend == "lm_studio": + lms = _state["backends"].get("lm_studio", {}) + model = select_lmstudio_model( + task_type="short", + automatic=True, + available_models=lms.get("models", []), + ) + url = f"{LM_STUDIO}/v1/chat/completions" + payload = { + "model": model, + "messages": [ + {"role": "system", "content": "/no_think\nAnswer directly. Do not expose reasoning."}, + {"role": "user", "content": "/no_think\n" + text}, + ], + "temperature": 0.7, + "max_tokens": 800, + "stream": False, + } + payload = json.dumps(add_lmstudio_ttl(payload)).encode("utf-8") + req = urllib.request.Request(url, data=payload, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=120) as r: + d = json.loads(r.read().decode()) + return extract_lmstudio_content(d["choices"][0], expect_json=False) + + elif backend == "ollama": + oll = _state["backends"].get("ollama", {}) + model = oll.get("models", ["llama3.2"])[0] + url = f"{OLLAMA}/api/generate" + payload = json.dumps({"model": model, "prompt": text, "stream": False}).encode() + req = urllib.request.Request(url, data=payload, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=120) as r: + d = json.loads(r.read().decode()) + return d.get("response", "") + + return "" + + +# ─── v2 : parallèle + juge + cascade FrugalGPT ──────────────────────────────── +import concurrent.futures, subprocess, difflib + +JUDGE_THRESHOLD_BASE = 7.0 # score min de base +SELF_CONSISTENCY_K = 0.65 # ratio Jaccard pour consensus +COOLDOWN_FAILS = 3 # N échecs consécutifs → cooldown du modèle +COOLDOWN_DURATION = 1800 # 30 min de cooldown +BENCHMARK_LOG = Path(r"C:\Users\Smedj\Documents\Obsidian Vault\.vault-llm-benchmark-iag.json") + +# État dynamique des modèles +_model_runtime = { + k: {"consecutive_fails": 0, "cooldown_until": 0.0, "wins": 0, "calls": 0, "avg_latency": 0.0} + for k in OPENCODE_FREE +} +_runtime_lock = threading.Lock() + +def _is_in_cooldown(model_key: str) -> bool: + with _runtime_lock: + return _model_runtime[model_key]["cooldown_until"] > time.time() + +def _record_call(model_key: str, success: bool, latency: float, won: bool = False): + with _runtime_lock: + s = _model_runtime[model_key] + s["calls"] += 1 + # Moyenne mobile latence + s["avg_latency"] = 0.8 * s["avg_latency"] + 0.2 * latency if s["avg_latency"] else latency + if success: + s["consecutive_fails"] = 0 + else: + s["consecutive_fails"] += 1 + if s["consecutive_fails"] >= COOLDOWN_FAILS: + s["cooldown_until"] = time.time() + COOLDOWN_DURATION + print(f"[v2] {model_key} en cooldown {COOLDOWN_DURATION//60}min", flush=True) + if won: + s["wins"] += 1 + +def _model_priority(model_key: str) -> float: + """Score de priorité : winrate * 0.7 + (1 - latency_norm) * 0.3.""" + with _runtime_lock: + s = _model_runtime[model_key] + if s["calls"] < 3: + return 0.5 # neutre tant qu'on n'a pas de données + winrate = s["wins"] / s["calls"] + # Latency normalisée vs moyenne globale (10s baseline) + latency_score = max(0, 1 - (s["avg_latency"] / 30.0)) + return 0.7 * winrate + 0.3 * latency_score + +def _adaptive_threshold() -> float: + """Seuil du juge ajusté selon quota Claude : si quota élevé, plus permissif (économise).""" + usage = _state.get("usage", {}) + five_h = (usage.get("five_hour") or {}).get("utilization", 50) + seven_d = (usage.get("seven_day") or {}).get("utilization", 50) + pressure = max(five_h, seven_d) + # > 80% : très permissif (6.0), <30% : exigeant (8.0), milieu : 7.0 + if pressure >= 80: return 6.0 + if pressure >= 60: return 6.5 + if pressure <= 30: return 8.0 + return JUDGE_THRESHOLD_BASE + +def _is_simple_question(text: str) -> bool: + """Heuristique : courte, factuelle, pas de code, pas de demande complexe.""" + t = text.strip() + if len(t) > 150: return False + if re.search(r'```|def |class |function|import |const |let |var |implement|architecture', t, re.I): + return False + if re.search(r'(comment|pourquoi|explique|détaille|analyse|compare|liste|résume)', t, re.I): + return False + # Question factuelle simple : "Quelle...?", "Qui...?", "Combien...?", "Quand...?" + if re.search(r'\b(quel|qui|combien|quand|où|c\'est quoi|how|what|who|when|where)\b', t, re.I): + return True + return False + +_opencode_semaphore = threading.Semaphore(2) # max 2 opencode concurrents + +def _call_opencode(model_key: str, text: str, timeout: int = 60) -> tuple[str, str | None, float]: + """Appelle opencode avec prompt via stdin. Sémaphore global évite la saturation.""" + t0 = time.time() + if not _opencode_semaphore.acquire(timeout=timeout): + return (model_key, None, time.time() - t0) + try: + model_id = OPENCODE_FREE[model_key] + r = subprocess.run( + [OPENCODE_CMD, "run", "--model", model_id, "-"], # "-" = lire stdin + input=text, + capture_output=True, text=True, timeout=timeout, + encoding="utf-8", errors="replace" + ) + lines = [l for l in r.stdout.splitlines() + if l.strip() and not l.startswith(">") and "\x1b" not in l and "build" not in l.lower()] + response = "\n".join(lines).strip() + if not response: + # Fallback : si stdin pas supporté par cette version, retomber sur arg + r2 = subprocess.run( + [OPENCODE_CMD, "run", "--model", model_id, text], + capture_output=True, text=True, timeout=timeout, + encoding="utf-8", errors="replace" + ) + lines = [l for l in r2.stdout.splitlines() + if l.strip() and not l.startswith(">") and "\x1b" not in l and "build" not in l.lower()] + response = "\n".join(lines).strip() + if not response: + return (model_key, None, time.time() - t0) + return (model_key, response, time.time() - t0) + except Exception as e: + print(f"[v2] {model_key} err: {e}", flush=True) + return (model_key, None, time.time() - t0) + finally: + try: _opencode_semaphore.release() + except: pass + +def _call_lm_studio(prompt: str, timeout: int = 60, max_tokens: int = 800) -> str | None: + try: + lms = _state["backends"].get("lm_studio", {}) + if not lms.get("available"): return None + model = select_lmstudio_model( + task_type="eval", + automatic=True, + available_models=lms.get("models", []), + ) + url = f"{LM_STUDIO}/v1/chat/completions" + payload = { + "model": model, + "messages": [ + {"role": "system", "content": "/no_think\nOutput only the final answer. No hidden reasoning. No markdown unless explicitly requested."}, + {"role": "user", "content": "/no_think\n" + prompt}, + ], + "temperature": 0.3, "max_tokens": max_tokens, "stream": False, + } + payload = json.dumps(add_lmstudio_ttl(payload)).encode() + req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + return extract_lmstudio_content(json.loads(r.read().decode())["choices"][0], expect_json=False) + except Exception as e: + print(f"[v2] lm_studio err: {e}", flush=True) + return None + +def _normalize(text: str) -> set: + """Tokenise pour Jaccard similarity.""" + return set(re.findall(r'\w+', text.lower())) if text else set() + +def _jaccard(a: set, b: set) -> float: + if not a or not b: return 0.0 + return len(a & b) / len(a | b) + +def _self_consistency(responses: dict) -> str | None: + """Si plusieurs modèles donnent une réponse similaire, retourne celle-là. + responses: {model_key: response_text}""" + valid = [(k, v) for k, v in responses.items() if v] + if len(valid) < 2: return None + norms = {k: _normalize(v) for k, v in valid} + # Compter pour chaque réponse combien d'autres sont similaires + best_key, best_count = None, 0 + for k1, n1 in norms.items(): + count = sum(1 for k2, n2 in norms.items() if k1 != k2 and _jaccard(n1, n2) >= SELF_CONSISTENCY_K) + if count > best_count: + best_count, best_key = count, k1 + # Consensus si au moins 2 modèles agree (donc count >= 1 pour le best) + if best_count >= 1: + return best_key + return None + +def _extract_scores(raw: str, n_letters: int) -> dict: + """Extrait robustement {letter: score} depuis n'importe quel format de juge LLM. + Cherche d'abord JSON, puis patterns markdown / table / inline.""" + if not raw: return {} + expected = [chr(65 + i) for i in range(n_letters)] + result = {} + + # Tentative 1 : JSON valide + for m in re.finditer(r'\{[^{}]+\}', raw): + try: + d = json.loads(m.group(0)) + for k, v in d.items(): + kk = k.strip().upper()[:1] + if kk in expected: + try: result[kk] = float(v) + except: pass + if result: return result + except: continue + + # Tentative 2 : patterns "A: 8", "A → 8", "A | 8", "A**: **8/10", "A) 7" + for letter in expected: + # Patterns variés + patterns = [ + rf'\*?\*?{letter}\*?\*?\s*[:→\-=)|]+\s*\*?\*?(\d+(?:\.\d+)?)\s*\*?\*?(?:\s*/\s*10)?', + rf'(?:^|\W){letter}\s*[:|]\s*(\d+(?:\.\d+)?)', + rf'Réponse\s+{letter}\s*[:\-]?\s*\*?\*?(\d+(?:\.\d+)?)', + ] + for pat in patterns: + m = re.search(pat, raw, re.MULTILINE | re.IGNORECASE) + if m: + try: + score = float(m.group(1)) + if 0 <= score <= 10: + result[letter] = score + break + except: pass + return result + +def _judge_one(judge_key: str, question: str, shuffled_responses: list, judge_call_fn) -> dict: + """Un juge note les réponses anonymisées. Retourne {letter: score}.""" + items = "\n\n".join(f"### Réponse {chr(65+i)}\n{v}" for i, (_, v) in enumerate(shuffled_responses)) + prompt = ( + f"Tu es un juge. Note chaque réponse de 0 à 10 selon : exactitude factuelle, pertinence, clarté concise. " + f"Ne récompense pas la verbosité.\n\n" + f"## Question\n{question}\n\n" + f"## Réponses anonymes\n{items}\n\n" + f"Format strict (1 ligne par réponse) :\n" + f"A: 8\nB: 6\nC: 9\n..." + ) + try: + raw = judge_call_fn(prompt) + if not raw: return {} + scores = _extract_scores(raw, len(shuffled_responses)) + if not scores: + print(f"[v2] judge {judge_key} no scores parsed, raw: {raw[:200]!r}", flush=True) + return scores + except Exception as e: + print(f"[v2] judge {judge_key} err: {e}", flush=True) + return {} + +def _panel_judge(question: str, responses: dict, include_claude: bool = False) -> dict: + """Conglomérat de juges anonymisé. Tronque candidats à 300 chars, limite à 3 juges rapides + qwen.""" + valid = [(k, v) for k, v in responses.items() if v] + if len(valid) < 2: return {} + + import random as _rnd + # Tronquer chaque candidat à 300 chars pour réduire taille prompt + truncated = [(k, (v[:300] + ("..." if len(v) > 300 else ""))) for k, v in valid] + shuffled = truncated.copy(); _rnd.shuffle(shuffled) + letter_to_key = {chr(65 + i): k for i, (k, _) in enumerate(shuffled)} + + # Panel : qwen externe (si LM Studio actif) + top-2 free models + free_judges = sorted(responses.keys(), key=_model_priority, reverse=True)[:2] + lms_state = _state["backends"].get("lm_studio", {}) + if lms_state.get("available"): + judges = ["qwen_external"] + free_judges + else: + judges = free_judges # skip qwen si LM Studio down — évite timeout + + all_judgments = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=len(judges)) as ex: + futures = {} + for judge in judges: + if judge == "qwen_external": + fn = lambda p: _call_lm_studio(p, max_tokens=150, timeout=30) + else: + fn = lambda p, jk=judge: _call_opencode(jk, p, timeout=30)[1] + futures[ex.submit(_judge_one, judge, question, shuffled, fn)] = judge + for f in concurrent.futures.as_completed(futures, timeout=60): + judge = futures[f] + try: + scores = f.result() + all_judgments[judge] = scores + if scores: + print(f"[v2 panel] {judge} → {scores}", flush=True) + except Exception as e: + all_judgments[judge] = {} + print(f"[v2 panel] {judge} crash: {e}", flush=True) + + # Aggréger anti-auto-favoritisme + aggregated = {k: [] for k, _ in valid} + for judge, scores in all_judgments.items(): + for letter, score in scores.items(): + target_key = letter_to_key.get(letter) + if not target_key or judge == target_key: continue + aggregated[target_key].append(score) + final = {k: sum(v)/len(v) for k, v in aggregated.items() if v} + print(f"[v2 panel] {len(judges)} juges, agrégé: {final}", flush=True) + return final + +# Alias rétro-compatibilité +def _judge_responses(question: str, responses: dict) -> dict: + return _panel_judge(question, responses) + +def _log_v2_round(question: str, responses: dict, latencies: dict, scores: dict, winner: str): + """Log chaque round dans .vault-llm-benchmark-iag.json pour benchmark continu.""" + import datetime as _dt + log_file = Path(r"C:\Users\Smedj\Documents\Obsidian Vault\.vault-llm-benchmark-iag.json") + try: + existing = json.loads(log_file.read_text(encoding="utf-8")) if log_file.exists() else {"rounds": []} + if not isinstance(existing, dict) or "rounds" not in existing: existing = {"rounds": []} + existing["rounds"].append({ + "ts": _dt.datetime.now().isoformat(), + "question": question[:200], + "responses": {k: (v[:300] if v else None) for k, v in responses.items()}, + "latencies": latencies, + "scores": scores, + "winner": winner, + }) + # Garder les 200 derniers rounds + existing["rounds"] = existing["rounds"][-200:] + log_file.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception as e: + print(f"[v2] log err: {e}", flush=True) + +def _record_winner(winner: str, path: str, scores: dict | None = None): + """Met à jour l'état v2 visible dans /status pour l'UI.""" + with _lock: + _state["v2_last_winner"] = winner + _state["v2_last_path"] = path + _state["v2_last_scores"] = scores or {} + _state["v2_last_ts"] = time.time() + +def route_v2(text: str) -> dict: + """Pipeline v2 intelligent : + - Skip cooldowned models + - Question simple → top-priority model only (1 appel, pas de juge) + - Question complexe → parallèle + self-consistency + juge adaptatif + cascade + """ + # Filtrer les modèles en cooldown + available = [k for k in OPENCODE_FREE + if _state["backends"].get(k, {}).get("available") and not _is_in_cooldown(k)] + if not available: + return {"backend": "claude", "inject": True, "text": text, "v2_path": "no_free_available"} + + # Trier par priorité dynamique (winrate + latency) + available.sort(key=_model_priority, reverse=True) + threshold = _adaptive_threshold() + simple = _is_simple_question(text) + + # ─── Question simple : un seul modèle (le top-priority) ───────────────── + if simple: + top = available[0] + print(f"[v2] simple Q → solo {top} (threshold={threshold})", flush=True) + _, resp, lat = _call_opencode(top, text, timeout=30) + if resp: + _record_call(top, success=True, latency=lat, won=True) + _log_v2_round(text, {top: resp}, {top: lat}, {}, top) + _record_winner(top, "simple_solo") + return {"backend": top, "inject": False, "response": resp, + "v2_path": "simple_solo", "model_priority": _model_priority(top)} + _record_call(top, success=False, latency=lat) + # Échec solo → retombe sur parallèle + print(f"[v2] solo {top} failed → fallback parallel", flush=True) + + # ─── Question complexe : parallèle ─────────────────────────────────────── + print(f"[v2] parallel → {available} (threshold={threshold})", flush=True) + responses, latencies = {}, {} + with concurrent.futures.ThreadPoolExecutor(max_workers=len(available)) as ex: + futures = {ex.submit(_call_opencode, k, text): k for k in available} + try: + for f in concurrent.futures.as_completed(futures, timeout=70): + k, resp, lat = f.result() + responses[k] = resp; latencies[k] = lat + _record_call(k, success=resp is not None, latency=lat) + except concurrent.futures.TimeoutError: + print(f"[v2] timeout global, on prend ce qu'on a", flush=True) + + valid = {k: v for k, v in responses.items() if v} + print(f"[v2] {len(valid)}/{len(available)} valides", flush=True) + if not valid: + return {"backend": "claude", "inject": True, "text": text, "v2_path": "all_free_failed"} + + # Self-consistency + consensus = _self_consistency(valid) + if consensus: + print(f"[v2] consensus → {consensus}", flush=True) + _record_call(consensus, True, latencies.get(consensus, 0), won=True) + _log_v2_round(text, responses, latencies, {}, consensus) + _record_winner(consensus, "consensus") + return {"backend": consensus, "inject": False, "response": valid[consensus], + "v2_path": "consensus", "candidates": list(valid.keys())} + + # Juge avec seuil adaptatif + scores = _judge_responses(text, valid) + if scores: + winner_key, winner_score = max(scores.items(), key=lambda x: x[1]) + print(f"[v2] judge scores={scores} winner={winner_key}@{winner_score} threshold={threshold}", flush=True) + if winner_score >= threshold: + _record_call(winner_key, True, latencies.get(winner_key, 0), won=True) + _record_call(winner_key, True, latencies.get(winner_key, 0), won=True) + _log_v2_round(text, responses, latencies, scores, winner_key) + _record_winner(winner_key, "judge_pass", scores) + return {"backend": winner_key, "inject": False, "response": valid[winner_key], + "v2_path": "judge_pass", "score": winner_score, "all_scores": scores, + "threshold": threshold} + + # PAS de cascade Claude : Sam préfère économiser le quota (Claude Code l'utilise déjà). + # On va direct sur lm_studio (qwen) puis fallback best free. + + # Codex fallback + cdx = _state["backends"].get("codex", {}) + if cdx.get("available"): + try: + resp = route_to_codex(text) + _log_v2_round(text, responses, latencies, scores, "codex") + _record_winner("codex", "escalate_codex", scores) + return {"backend": "codex", "inject": False, "response": resp, "v2_path": "escalate_codex"} + except Exception: pass + + # Dernier recours : meilleur free même si score bas + best_free = max(valid.keys(), key=lambda k: scores.get(k, 0)) + _record_call(best_free, True, latencies.get(best_free, 0), won=True) + _log_v2_round(text, responses, latencies, scores, best_free) + _record_winner(best_free, "fallback_best_free", scores) + return {"backend": best_free, "inject": False, "response": valid[best_free], + "v2_path": "fallback_best_free", "warning": "no flagship available"} + + +# ─── HTTP Handler ───────────────────────────────────────────────────────────── +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/v2_state": + # Snapshot d'abord (lock court), priorité calculée après (sans lock pour éviter deadlock) + with _runtime_lock: + snapshot = {k: dict(v) for k, v in _model_runtime.items()} + stats = {} + for k, v in snapshot.items(): + prio = _model_priority(k) # acquiert le lock à nouveau, OK car sorti + stats[k] = {**v, "in_cooldown": v["cooldown_until"] > time.time(), + "priority": prio} + payload = { + "models": stats, + "threshold": _adaptive_threshold(), + "claude_rate_limited": _claude_rate_limited, + "self_consistency_k": SELF_CONSISTENCY_K, + } + data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(data))) + self.end_headers(); self.wfile.write(data) + return + if self.path == "/status": + with _lock: + data = json.dumps({**_state, "models": MODEL_INFO, "claude_rate_limited": _claude_rate_limited}, ensure_ascii=False, indent=2).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + else: + self.send_error(404) + + def do_POST(self): + if self.path == "/route_v2": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) + text = body.get("text", "") + try: + result = route_v2(text) + except Exception as e: + print(f"[v2] err: {e}", flush=True) + result = {"backend": "claude", "inject": True, "text": text, "error": str(e)} + data = json.dumps(result, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers(); self.wfile.write(data) + return + if self.path == "/route": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode("utf-8-sig")) + text = body.get("text", "") + context = body.get("context", "voice") + + with _lock: + backend = _state["active_backend"] + _state["request_count"] += 1 + _state["last_route"] = {"backend": backend, "context": context, "ts": time.time()} + + print(f"[router] → {backend} | {context} | {text[:50]!r}", flush=True) + + if backend == "claude": + result = {"backend": "claude", "inject": True, "text": text} + elif backend in OPENCODE_FREE: + try: + model_id = OPENCODE_FREE[backend] + r = subprocess.run( + [OPENCODE_CMD, "run", "--model", model_id, text], + capture_output=True, text=True, timeout=60, + encoding="utf-8", errors="replace" + ) + lines = [l for l in r.stdout.splitlines() + if l.strip() and not l.startswith(">") and "\x1b" not in l and "build" not in l.lower()] + response = "\n".join(lines).strip() or "Pas de réponse" + result = {"backend": backend, "inject": False, "response": response} + except Exception as e: + result = {"backend": "claude", "inject": True, "text": text, "fallback": True} + elif backend == "codex": + try: + response = route_to_codex(text) + result = {"backend": "codex", "inject": False, "response": response} + except Exception as e: + print(f"[router] ✗ codex: {e} → fallback claude", flush=True) + result = {"backend": "claude", "inject": True, "text": text, "fallback": True} + else: + try: + response = route_to_local(text, backend) + result = {"backend": backend, "inject": False, "response": response} + except Exception as e: + print(f"[router] ✗ {backend}: {e} → fallback claude", flush=True) + result = {"backend": "claude", "inject": True, "text": text, "fallback": True} + + data = json.dumps(result, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + else: + self.send_error(404) + + def log_message(self, fmt, *args): + pass # Silence les logs HTTP + + +# ─── Main ───────────────────────────────────────────────────────────────────── +if __name__ == "__main__": + print("=== LLM Router ===", flush=True) + # Init immédiate des backends sans attendre le thread + with _lock: + _state["backends"] = {**probe_opencode_models(), "codex": probe_codex(), "lm_studio": {}, "ollama": {}} + threading.Thread(target=probe_all, daemon=True).start() + threading.Thread(target=auto_benchmark_loop, daemon=True).start() + + with _lock: + print(f"[router] Backend actif : {_state['active_backend']}", flush=True) + free = [k for k,v in _state['backends'].items() if v.get('available') and v.get('cost')=='free'] + print(f"[router] Modèles gratuits : {free}", flush=True) + + print(f"[router] Écoute sur port {PORT}", flush=True) + server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler) + try: + server.serve_forever() + except KeyboardInterrupt: + server.shutdown() diff --git a/scripts/brain/lmstudio_policy.py b/scripts/brain/lmstudio_policy.py new file mode 100644 index 0000000000..28a7cf6181 --- /dev/null +++ b/scripts/brain/lmstudio_policy.py @@ -0,0 +1,110 @@ +import os + + +FAST_TASKS = { + "json", + "extract", + "classify", + "memory", + "vault", + "consolidate", + "synthesize_short", + "tooltip", + "short", + "eval", + "healthcheck", +} + +DEEP_TASKS = { + "deep_reason", + "long_synthesis", + "manual_chat", + "explicit_deep", + "complex_self_dev", +} + + +def _env_flag(name: str, default: str = "0") -> bool: + return str(os.getenv(name, default)).strip().lower() in {"1", "true", "yes", "on"} + + +def normalize_model_id(model_id): + model_id = (model_id or "").strip() + if ":" in model_id: + base, suffix = model_id.rsplit(":", 1) + if suffix.isdigit(): + return base + return model_id + + +def get_lmstudio_config(): + return { + "base_url": os.getenv("LMSTUDIO_BASE_URL", "http://127.0.0.1:1234").rstrip("/"), + "fast_model": os.getenv("LMSTUDIO_FAST_MODEL", "qwen2.5-7b-instruct"), + "deep_model": os.getenv("LMSTUDIO_DEEP_MODEL", "qwen3.6-35b-a3b"), + "embed_model": os.getenv("LMSTUDIO_EMBED_MODEL", "text-embedding-nomic-embed-text-v1.5"), + "ttl": int(os.getenv("LMSTUDIO_TTL", "300")), + "allow_deep_auto": _env_flag("LMSTUDIO_ALLOW_DEEP_AUTO", "0"), + "use_native_api": _env_flag("LMSTUDIO_USE_NATIVE_API", "0"), + "jit_enabled": _env_flag("LMSTUDIO_JIT_ENABLED", "0"), + } + + +def is_deep_model(model_id): + cfg = get_lmstudio_config() + return normalize_model_id(model_id) == normalize_model_id(cfg["deep_model"]) + + +def is_embedding_model(model_id): + cfg = get_lmstudio_config() + return normalize_model_id(model_id) == normalize_model_id(cfg["embed_model"]) + + +def task_uses_fast(task_type): + return (task_type or "").strip().lower() in FAST_TASKS + + +def task_uses_deep(task_type): + return (task_type or "").strip().lower() in DEEP_TASKS + + +def select_lmstudio_model(task_type=None, requested_model=None, automatic=True, available_models=None): + cfg = get_lmstudio_config() + fast_model = cfg["fast_model"] + deep_model = cfg["deep_model"] + if requested_model: + selected = requested_model + elif task_uses_deep(task_type): + selected = deep_model + else: + selected = fast_model if automatic or task_uses_fast(task_type) else deep_model + + normalized_available = { + normalize_model_id(model_id) + for model_id in (available_models or []) + } + + if automatic and is_deep_model(selected) and not cfg["allow_deep_auto"]: + if normalize_model_id(fast_model) in normalized_available: + return fast_model + raise RuntimeError("local_fast_model_unavailable") + + if normalized_available: + normalized_selected = normalize_model_id(selected) + if normalized_selected in normalized_available: + return selected + if automatic and normalize_model_id(fast_model) in normalized_available: + return fast_model + if automatic: + raise RuntimeError("local_fast_model_unavailable") + raise RuntimeError("requested_local_model_unavailable") + + return selected + + +def add_lmstudio_ttl(payload): + cfg = get_lmstudio_config() + ttl = cfg["ttl"] + if ttl > 0: + payload["ttl"] = ttl + return payload diff --git a/scripts/brain/lmstudio_response.py b/scripts/brain/lmstudio_response.py new file mode 100644 index 0000000000..d0a9d68351 --- /dev/null +++ b/scripts/brain/lmstudio_response.py @@ -0,0 +1,38 @@ +import json + +def extract_lmstudio_content(choice, expect_json=False): + """ + Extracts usable content from LM Studio responses. + + Workaround for Qwen reasoning models: + sometimes structured JSON is returned in message.reasoning_content + while message.content is empty. + """ + msg = choice.get("message", {}) or {} + content = (msg.get("content") or "").strip() + reasoning = (msg.get("reasoning_content") or "").strip() + finish = choice.get("finish_reason") + + if finish == "length": + raise RuntimeError("lmstudio_output_truncated") + + if content: + return content + + if expect_json and finish == "stop" and reasoning: + if reasoning.startswith("{") or reasoning.startswith("["): + try: + json.loads(reasoning) + return reasoning + except Exception as e: + raise RuntimeError(f"reasoning_content_not_valid_json: {e}") + + if reasoning: + raise RuntimeError("empty_content_with_reasoning_output") + + raise RuntimeError("empty_lmstudio_output") + + +def parse_lmstudio_json(choice): + text = extract_lmstudio_content(choice, expect_json=True) + return json.loads(text) diff --git a/scripts/brain/vault_brain.py b/scripts/brain/vault_brain.py new file mode 100644 index 0000000000..7c81d8b894 --- /dev/null +++ b/scripts/brain/vault_brain.py @@ -0,0 +1,637 @@ +""" +vault_brain.py — Hybrid retrieval engine for the Obsidian second brain. + +Architecture (per current SOTA): + - Episodic memory = 07 - Ingested/ (raw events, append-only, bi-temporal) + - Semantic memory = 08 - Semantic/ (extracted facts, Mem0-style) + - Procedural memory = 02 - Operations/ (runbooks, curated) + - Curated memory = 01/03/04/06 (decisions, topology, protocol) + +Retrieval = hybrid BM25 (sparse) + nomic-embed-text v1.5 (dense, 768d, local +via LM Studio) fused with Reciprocal Rank Fusion (RRF, k=60). Index lives in +one SQLite file with FTS5 + a vec(768) BLOB column. + +Citations: + - Tulving (1972) — episodic vs semantic vs procedural memory taxonomy + - Cormack, Clarke, Buettcher (2009) — Reciprocal Rank Fusion (default k=60) + - Lin et al. (2024) — RRF beats BM25 / dense alone on MS MARCO + - Nussbaum et al. (2024) — nomic-embed-text-v1.5, 768d, MTEB SOTA at size + - Chhikara et al. (2025) — Mem0: append-only single-pass extraction (arXiv:2504.19413) + - Packer et al. (2024) — MemGPT/Letta hierarchical memory (arXiv:2310.08560) + - Gutierrez et al. (2024) — HippoRAG: PageRank-style traversal (arXiv:2405.14831) + +Usage: + python vault_brain.py index # full reindex + python vault_brain.py index --incremental # only changed files + python vault_brain.py query "how is Atlas configured" --k 10 + python vault_brain.py query "..." --format json + python vault_brain.py stats # index stats +""" +try: + from lmstudio_response import extract_lmstudio_content +except Exception: + from scripts.brain.lmstudio_response import extract_lmstudio_content +try: + from lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +except Exception: + from scripts.brain.lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model + +import argparse +import hashlib +import json +import math +import os +import re +import sqlite3 +import struct +import sys +import time +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +# Force stdout to UTF-8 on Windows so non-ASCII chars in vault content don't crash printing +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +# ─── Configuration ──────────────────────────────────────────────────────────── +VAULT_PATH = Path(os.environ.get("VAULT_PATH", r"C:\Users\Smedj\Documents\Obsidian Vault")) +INDEX_PATH = VAULT_PATH / ".vault-brain.sqlite" +EMBED_URL = get_lmstudio_config()["base_url"] + "/v1/embeddings" +EMBED_MODEL = os.environ.get("EMBED_MODEL", get_lmstudio_config()["embed_model"]) +EMBED_DIM = 768 # nomic-embed-text-v1.5 + +# Chunking: target ~500 tokens, overlap 50. Tokens approximated as 4 chars. +CHUNK_TARGET_CHARS = 2000 +CHUNK_OVERLAP_CHARS = 200 + +# Reciprocal Rank Fusion constant. k=60 from Cormack et al. 2009 — the canonical default. +RRF_K = 60 + +# Folders to exclude from indexing (binary, daemon state, .obsidian internals) +EXCLUDED_DIRS = {".obsidian", ".trash", ".vault-brain.sqlite"} +EXCLUDED_FILES = {".vault-ingest-state.json"} + + +@dataclass +class Chunk: + file_path: str # vault-relative + chunk_idx: int + text: str + sha: str + folder: str # top-level folder, e.g. "07 - Ingested" + source: str # derived: "episodic" | "semantic" | "procedural" | "curated" + tags: list[str] + event_at: str | None # ISO from frontmatter if present + + +# ─── Embedding client (LM Studio, nomic-embed-text-v1.5) ────────────────────── +def embed_batch(texts: list[str], retries: int = 3) -> list[list[float]]: + """Call LM Studio /v1/embeddings. Single request per call (LM Studio supports batches).""" + payload = json.dumps({"model": EMBED_MODEL, "input": texts}).encode("utf-8") + req = urllib.request.Request(EMBED_URL, data=payload, headers={"Content-Type": "application/json"}) + last_err = None + for attempt in range(retries): + try: + with urllib.request.urlopen(req, timeout=120) as resp: + d = json.loads(resp.read().decode("utf-8")) + return [item["embedding"] for item in d["data"]] + except Exception as e: + last_err = e + time.sleep(0.5 * (attempt + 1)) + raise RuntimeError(f"embed_batch failed after {retries} retries: {last_err}") + + +def cosine(a: list[float], b: list[float]) -> float: + dot = sum(x * y for x, y in zip(a, b)) + na = math.sqrt(sum(x * x for x in a)) + nb = math.sqrt(sum(x * x for x in b)) + return dot / (na * nb) if na and nb else 0.0 + + +def pack_vec(v: list[float]) -> bytes: + return struct.pack(f"{len(v)}f", *v) + + +def unpack_vec(b: bytes) -> list[float]: + n = len(b) // 4 + return list(struct.unpack(f"{n}f", b)) + + +# ─── Frontmatter & chunking ────────────────────────────────────────────────── +FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL) + + +def parse_frontmatter(text: str) -> tuple[dict, str]: + m = FRONTMATTER_RE.match(text) + if not m: + return {}, text + fm_block = m.group(1) + body = text[m.end() :] + fm = {} + for line in fm_block.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if ":" not in line: + continue + k, _, v = line.partition(":") + v = v.strip() + # Crude YAML — handles "key: \"val\"", "key: [a, b, c]", "key: val" + if v.startswith('"') and v.endswith('"'): + v = v[1:-1].replace('\\"', '"') + elif v.startswith("[") and v.endswith("]"): + inner = v[1:-1] + v = [item.strip().strip('"') for item in inner.split(",") if item.strip()] + fm[k.strip()] = v + return fm, body + + +def folder_to_source(folder: str) -> str: + """Map vault top-level folder to memory category.""" + if folder.startswith("07 - Ingested"): + return "episodic" + if folder.startswith("08 - Semantic"): + return "semantic" + if folder.startswith("02 - Operations"): + return "procedural" + return "curated" + + +def chunk_text(text: str) -> list[str]: + """Char-based chunker with overlap. Cheap and robust for markdown.""" + if len(text) <= CHUNK_TARGET_CHARS: + return [text] if text.strip() else [] + chunks = [] + start = 0 + while start < len(text): + end = min(start + CHUNK_TARGET_CHARS, len(text)) + # Prefer to break at paragraph + if end < len(text): + break_at = text.rfind("\n\n", start, end) + if break_at > start + CHUNK_TARGET_CHARS // 2: + end = break_at + chunks.append(text[start:end].strip()) + if end >= len(text): + break + start = end - CHUNK_OVERLAP_CHARS + return [c for c in chunks if c] + + +def iter_vault_chunks() -> Iterable[Chunk]: + """Walk the vault, yield chunks with metadata.""" + for root, dirs, files in os.walk(VAULT_PATH): + # Prune excluded dirs in-place + dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS] + for fname in files: + if not fname.endswith(".md"): + continue + if fname in EXCLUDED_FILES: + continue + fpath = Path(root) / fname + rel = fpath.relative_to(VAULT_PATH).as_posix() + top_folder = rel.split("/", 1)[0] + try: + text = fpath.read_text(encoding="utf-8", errors="replace") + except Exception: + continue + fm, body = parse_frontmatter(text) + tags = fm.get("tags", []) if isinstance(fm.get("tags"), list) else [] + event_at = fm.get("event_at") or fm.get("created") or None + source = folder_to_source(top_folder) + for i, chunk in enumerate(chunk_text(body)): + sha = hashlib.sha256(f"{rel}::{i}::{chunk}".encode("utf-8")).hexdigest()[:16] + yield Chunk(rel, i, chunk, sha, top_folder, source, tags, event_at) + + +# ─── Index lifecycle ────────────────────────────────────────────────────────── +SCHEMA = """ +CREATE TABLE IF NOT EXISTS chunks ( + rowid INTEGER PRIMARY KEY, + file_path TEXT NOT NULL, + chunk_idx INTEGER NOT NULL, + sha TEXT NOT NULL UNIQUE, + folder TEXT NOT NULL, + source TEXT NOT NULL, + tags TEXT, -- JSON array + event_at TEXT, + text TEXT NOT NULL, + embedding BLOB -- 768f packed; NULL if not yet embedded +); +CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(file_path); +CREATE INDEX IF NOT EXISTS idx_chunks_folder ON chunks(folder); +CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source); + +CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( + text, + file_path UNINDEXED, + folder UNINDEXED, + tokenize = 'unicode61 remove_diacritics 2' +); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); +""" + + +def open_index() -> sqlite3.Connection: + conn = sqlite3.connect(INDEX_PATH) + conn.executescript(SCHEMA) + conn.commit() + return conn + + +def cmd_index(args): + full = not args.incremental + conn = open_index() + cur = conn.cursor() + t0 = time.time() + + if full: + cur.execute("DELETE FROM chunks") + cur.execute("DELETE FROM chunks_fts") + conn.commit() + + # Collect all current SHAs + new_chunks: list[Chunk] = [] + seen_shas: set[str] = set() + for ch in iter_vault_chunks(): + seen_shas.add(ch.sha) + new_chunks.append(ch) + + if args.incremental: + existing = {row[0] for row in cur.execute("SELECT sha FROM chunks").fetchall()} + # Delete chunks no longer present + to_delete = existing - seen_shas + if to_delete: + placeholders = ",".join("?" * len(to_delete)) + cur.execute(f"DELETE FROM chunks WHERE sha IN ({placeholders})", list(to_delete)) + cur.execute(f"DELETE FROM chunks_fts WHERE rowid IN (SELECT rowid FROM chunks WHERE sha IN ({placeholders}))", list(to_delete)) + # Filter to only new chunks + new_chunks = [c for c in new_chunks if c.sha not in existing] + + print(f"[index] {len(new_chunks)} new chunk(s) to embed (full={full})") + inserted = 0 + BATCH = 32 # LM Studio handles ~32-64 inputs cleanly + for i in range(0, len(new_chunks), BATCH): + batch = new_chunks[i : i + BATCH] + try: + embeddings = embed_batch([c.text[:8000] for c in batch]) # truncate to 8K chars per chunk for safety + except Exception as e: + print(f"[index] WARN: embed batch failed at {i}: {e}", file=sys.stderr) + embeddings = [None] * len(batch) + for c, emb in zip(batch, embeddings): + blob = pack_vec(emb) if emb else None + try: + cur.execute( + "INSERT OR IGNORE INTO chunks (file_path, chunk_idx, sha, folder, source, tags, event_at, text, embedding) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (c.file_path, c.chunk_idx, c.sha, c.folder, c.source, json.dumps(c.tags), c.event_at, c.text, blob), + ) + rowid = cur.lastrowid + cur.execute( + "INSERT INTO chunks_fts (rowid, text, file_path, folder) VALUES (?, ?, ?, ?)", + (rowid, c.text, c.file_path, c.folder), + ) + inserted += 1 + except Exception as e: + print(f"[index] WARN: insert failed for {c.file_path}#{c.chunk_idx}: {e}", file=sys.stderr) + if i and i % (BATCH * 10) == 0: + conn.commit() + print(f"[index] {inserted}/{len(new_chunks)} indexed (elapsed {time.time()-t0:.1f}s)") + + cur.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_index_at', ?)", (str(int(time.time())),)) + conn.commit() + print(f"[index] done. {inserted} chunk(s) added in {time.time()-t0:.1f}s") + cmd_stats(args) + + +def cmd_stats(args): + conn = open_index() + cur = conn.cursor() + rows = cur.execute("SELECT source, COUNT(*) FROM chunks GROUP BY source").fetchall() + total = cur.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] + embedded = cur.execute("SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL").fetchone()[0] + distinct_files = cur.execute("SELECT COUNT(DISTINCT file_path) FROM chunks").fetchone()[0] + last = cur.execute("SELECT value FROM meta WHERE key='last_index_at'").fetchone() + print(f"\n=== Vault Brain Index ===") + print(f" files indexed: {distinct_files}") + print(f" total chunks: {total}") + print(f" with embeddings: {embedded} ({100*embedded/max(total,1):.1f}%)") + print(f" by source:") + for s, n in rows: + print(f" {s:12} {n}") + if last: + from datetime import datetime + print(f" last index: {datetime.fromtimestamp(int(last[0])).isoformat()}") + + +# ─── Retrieval ──────────────────────────────────────────────────────────────── +def fts_query_escape(query: str) -> str: + """FTS5 MATCH expects safe tokens. Strip control chars, quote each word.""" + words = re.findall(r"\w+", query, flags=re.UNICODE) + if not words: + return '""' + # OR all tokens, prefix-match the last for query continuation + return " OR ".join(f'"{w}"*' for w in words) + + +def search_bm25(conn: sqlite3.Connection, query: str, k: int) -> list[tuple[int, float]]: + """Return list of (rowid, bm25_score) for top-k matches. Lower score = better in bm25().""" + fts = fts_query_escape(query) + rows = conn.execute( + "SELECT rowid, bm25(chunks_fts) AS score FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY score LIMIT ?", + (fts, k), + ).fetchall() + return [(rid, sc) for rid, sc in rows] + + +def search_dense(conn: sqlite3.Connection, query_vec: list[float], k: int, source_filter: str | None = None) -> list[tuple[int, float]]: + """Return list of (rowid, cosine) for top-k by cosine similarity.""" + sql = "SELECT rowid, embedding FROM chunks WHERE embedding IS NOT NULL" + params: list = [] + if source_filter: + sql += " AND source = ?" + params.append(source_filter) + rows = conn.execute(sql, params).fetchall() + scored = [] + for rid, blob in rows: + v = unpack_vec(blob) + scored.append((rid, cosine(query_vec, v))) + scored.sort(key=lambda x: x[1], reverse=True) + return scored[:k] + + +def rrf_fuse(rankings: list[list[tuple[int, float]]], k: int = RRF_K) -> dict[int, float]: + """Reciprocal Rank Fusion. Returns rowid -> fused score. + + Reference: Cormack, Clarke & Buettcher, "Reciprocal Rank Fusion outperforms + Condorcet and individual rank learning methods", SIGIR 2009. + https://plg.uwaterloo.ca/~gvcormack/cormacksigir09-rrf.pdf + """ + fused: dict[int, float] = {} + for ranking in rankings: + for rank, (rowid, _score) in enumerate(ranking, start=1): + fused[rowid] = fused.get(rowid, 0.0) + 1.0 / (k + rank) + return fused + + +# Centrality boost: combine RRF score with PageRank centrality. +# We use a multiplicative boost log(1 + γ·centrality·N) so that high-centrality +# nodes get a small lift but the dominant signal stays the query-specific RRF. +# γ is tuned so a centrality at the 95th percentile contributes ~0.1 to log(1+x). +# Reference (motivation): Gutierrez et al., "HippoRAG: Neurobiologically Inspired +# Long-Term Memory for LLMs", NeurIPS 2024 — uses Personalized PageRank to boost +# graph-aware retrieval. We use static (non-personalized) PageRank which is a +# weaker but cheaper proxy when the graph is small. +import math as _math + +CENTRALITY_GAMMA = 1000.0 # tuned for our pi values which are O(1e-3) max + + +def fuse_with_centrality( + fused_rrf: dict[int, float], + centrality_by_rowid: dict[int, float], + gamma: float = CENTRALITY_GAMMA, +) -> dict[int, float]: + """Multiply RRF score by (1 + log(1 + γ·c)) where c is the file's centrality. + Strictly increasing in c, sub-linear so dominant high-centrality items don't + swamp the query-specific signal.""" + out: dict[int, float] = {} + for rid, score in fused_rrf.items(): + c = centrality_by_rowid.get(rid, 0.0) or 0.0 + boost = 1.0 + _math.log(1.0 + gamma * c) + out[rid] = score * boost + return out + + +def hyde_expand(query: str, llm_url: str | None = None, model: str | None = None) -> str | None: + """HyDE — Hypothetical Document Embeddings (Gao, Ma, Lin, Callan, ACL 2023). + + Reference: Gao, L., Ma, X., Lin, J., Callan, J. (2023). "Precise Zero-Shot + Dense Retrieval without Relevance Labels." Proceedings of ACL 2023. + https://aclanthology.org/2023.acl-long.99/ + arXiv: https://arxiv.org/abs/2212.10496 + + Asks a local LLM to generate a hypothetical answer to the query. We then + embed THAT answer instead of the query — the dense bottleneck filters + out hallucinations and the embedding sits in the answer-space, much closer + to relevant retrievable chunks. + """ + url = llm_url or get_lmstudio_config()["base_url"] + "/v1/chat/completions" + try: + with urllib.request.urlopen(get_lmstudio_config()["base_url"] + "/v1/models", timeout=5) as resp: + available_models = [m["id"] for m in json.loads(resp.read().decode("utf-8")).get("data", [])] + model = select_lmstudio_model( + task_type="vault", + requested_model=model or os.environ.get("HYDE_MODEL", get_lmstudio_config()["fast_model"]), + automatic=True, + available_models=available_models, + ) + except Exception: + return None + prompt = ( + "You are answering an information-retrieval probe. Write a short, factual " + "paragraph (3-5 sentences) that DIRECTLY answers the user's query, as if you " + "had perfect knowledge. Do not refuse, do not hedge, do not add caveats — write " + "the answer that the ideal source document would contain. No greetings, no " + "meta-commentary.\n\nQuery: " + query + ) + payload = { + "model": model, + "messages": [ + {"role": "system", "content": "/no_think\nOutput only the final answer. No hidden reasoning. No markdown unless explicitly requested."}, + {"role": "user", "content": "/no_think\n" + prompt}, + ], + "temperature": 0.4, + "max_tokens": 400, + } + payload = json.dumps(add_lmstudio_ttl(payload)).encode("utf-8") + req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + d = json.loads(resp.read().decode("utf-8")) + text = extract_lmstudio_content(d["choices"][0], expect_json=False) + return text or None + except Exception: + return None + + +def cmd_query(args): + query = args.query + k = args.k + conn = open_index() + use_centrality = not args.no_centrality + + # HyDE: optionally expand the query into a hypothetical answer first + qvec_query = None + qvec_hyde = None + hyde_text = None + try: + qvec_query = embed_batch([query])[0] + except Exception as e: + print(f"[query] dense (query) skipped: {e}", file=sys.stderr) + + if args.hyde: + hyde_text = hyde_expand(query) + if hyde_text: + try: + qvec_hyde = embed_batch([hyde_text])[0] + except Exception as e: + print(f"[query] dense (hyde) skipped: {e}", file=sys.stderr) + + qvec = qvec_query # default + + bm25_top = search_bm25(conn, query, k=k * 3) + # Dense rankings: combine query embedding and HyDE embedding via RRF (best of both) + dense_query_top = search_dense(conn, qvec_query, k=k * 3) if qvec_query is not None else [] + dense_hyde_top = search_dense(conn, qvec_hyde, k=k * 3) if qvec_hyde is not None else [] + if qvec_hyde is not None: + dense_top = dense_query_top # keep dense_top variable for compat + else: + dense_top = dense_query_top + + _rankings = [bm25_top, dense_query_top] + if dense_hyde_top: + _rankings.append(dense_hyde_top) + fused_rrf = rrf_fuse(_rankings) + bm25 = bm25_top # downstream display compat + dense = dense_query_top + if not fused_rrf: + print("(no results)") + return + + # Centrality boost (graph-aware retrieval, HippoRAG-inspired) + centrality_by_rowid = {} + community_by_rowid = {} + if use_centrality: + cols = {r[1] for r in conn.execute("PRAGMA table_info(chunks)").fetchall()} + if "centrality" in cols: + rids = list(fused_rrf.keys()) + placeholders = ",".join("?" * len(rids)) + extra_col = ", community_id" if "community_id" in cols else "" + cent_rows = conn.execute( + f"SELECT rowid, centrality{extra_col} FROM chunks WHERE rowid IN ({placeholders})", + rids, + ).fetchall() + for row in cent_rows: + rid = row[0] + centrality_by_rowid[rid] = row[1] or 0.0 + if extra_col: + community_by_rowid[rid] = row[2] + + # Per-community z-score: how central is this chunk WITHIN its community? + # Avoids the flat-distribution problem when the global graph is dense. + # Reference for community-aware ranking: Edge et al., GraphRAG (2024), + # which uses Leiden communities to scope retrieval. We do the simpler + # community-z-score variant. + if community_by_rowid: + from statistics import mean, pstdev + # Aggregate centrality per community (across the whole index, not just candidates) + global_stats: dict[int, tuple[float, float]] = {} + for cid in set(c for c in community_by_rowid.values() if c is not None): + rows_c = conn.execute( + "SELECT centrality FROM chunks WHERE community_id = ? AND centrality IS NOT NULL", + (cid,) + ).fetchall() + vals = [r[0] for r in rows_c if r[0] is not None] + if len(vals) >= 2: + m = mean(vals); sd = pstdev(vals) or 1e-9 + global_stats[cid] = (m, sd) + # Recompute "effective centrality" as z-score in own community + eff_centrality: dict[int, float] = {} + for rid, c in centrality_by_rowid.items(): + cid = community_by_rowid.get(rid) + if cid in global_stats: + m, sd = global_stats[cid] + # Sigmoid-mapped z-score → bounded boost + z = (c - m) / sd + eff_centrality[rid] = max(0.0, c) * (1.0 + 0.2 * z) + else: + eff_centrality[rid] = c + fused = fuse_with_centrality(fused_rrf, eff_centrality) + else: + fused = fuse_with_centrality(fused_rrf, centrality_by_rowid) + else: + fused = fused_rrf + else: + fused = fused_rrf + + top = sorted(fused.items(), key=lambda x: x[1], reverse=True)[:k] + rowids = [rid for rid, _ in top] + placeholders = ",".join("?" * len(rowids)) + chunks = { + row[0]: row + for row in conn.execute( + f"SELECT rowid, file_path, chunk_idx, source, folder, event_at, text FROM chunks WHERE rowid IN ({placeholders})", + rowids, + ) + } + + results = [] + for rid, score in top: + c = chunks.get(rid) + if not c: + continue + rid2, fp, ci, src, folder, ev, text = c + # Component scores for transparency + bm25_rank = next((i + 1 for i, (rr, _) in enumerate(bm25_top) if rr == rid), None) + dense_rank = next((i + 1 for i, (rr, _) in enumerate(dense_top) if rr == rid), None) + snippet = text[:300].replace("\n", " ") + results.append({ + "score": round(score, 4), + "rrf_only": round(fused_rrf.get(rid, 0.0), 4), + "centrality": round(centrality_by_rowid.get(rid, 0.0), 6), + "bm25_rank": bm25_rank, + "dense_rank": dense_rank, + "source": src, + "folder": folder, + "file": fp, + "chunk": ci, + "event_at": ev, + "snippet": snippet, + }) + + if args.format == "json": + print(json.dumps(results, indent=2, ensure_ascii=False)) + else: + for i, r in enumerate(results, 1): + print(f"\n[{i}] score={r['score']:.4f} rrf={r['rrf_only']:.4f} cent={r['centrality']:.2e} bm25_rank={r['bm25_rank']} dense_rank={r['dense_rank']} source={r['source']}") + print(f" {r['file']} (chunk {r['chunk']})") + print(f" {r['snippet']}...") + + +# ─── Main ──────────────────────────────────────────────────────────────────── +def main(): + parser = argparse.ArgumentParser(prog="vault_brain") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_idx = sub.add_parser("index", help="(Re)build the index") + p_idx.add_argument("--incremental", action="store_true", help="Only embed new/changed chunks") + + p_q = sub.add_parser("query", help="Hybrid search (BM25 + dense + RRF + centrality)") + p_q.add_argument("query", help="Query text") + p_q.add_argument("-k", type=int, default=10, help="Number of results") + p_q.add_argument("--format", choices=["text", "json"], default="text") + p_q.add_argument("--no-centrality", action="store_true", help="Disable PageRank centrality boost") + p_q.add_argument("--hyde", action="store_true", help="Enable HyDE: generate hypothetical answer via local LLM, fuse its embedding into the dense ranking (Gao et al., ACL 2023)") + + sub.add_parser("stats", help="Index statistics") + + args = parser.parse_args() + if args.cmd == "index": + cmd_index(args) + elif args.cmd == "query": + cmd_query(args) + elif args.cmd == "stats": + cmd_stats(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/brain/vault_consolidate.py b/scripts/brain/vault_consolidate.py new file mode 100644 index 0000000000..f887262584 --- /dev/null +++ b/scripts/brain/vault_consolidate.py @@ -0,0 +1,261 @@ +""" +vault_consolidate.py — Mem0-style episodic → semantic extraction. + +Reads recent notes from `07 - Ingested/` and extracts atomic facts into +`08 - Semantic//.md`. Single-pass ADD-only per Chhikara et al. +(Mem0, arXiv:2504.19413, 2025): never UPDATE/DELETE — accumulate, don't overwrite. + +Each extracted fact is: +- atomic (one claim per note) +- traceable (frontmatter `derived_from:` lists source notes) +- bi-temporal (event_at = source event time, captured_at = extraction time) +- typed (`fact_type` = decision | preference | constraint | observation | reference) + +Uses qwen3.6-35b-a3b via LM Studio (local, no quota cost) as the extraction LLM. + +Usage: + python vault_consolidate.py --since 24h --dry-run # preview + python vault_consolidate.py --since 24h --apply # write notes + python vault_consolidate.py --since 7d --max-files 200 +""" +try: + from lmstudio_response import extract_lmstudio_content +except Exception: + from scripts.brain.lmstudio_response import extract_lmstudio_content +try: + from lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +except Exception: + from scripts.brain.lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +import argparse +import datetime as dt +import hashlib +import json +import os +import re +import time +import urllib.request +from pathlib import Path + +VAULT_PATH = Path(os.environ.get("VAULT_PATH", r"C:\Users\Smedj\Documents\Obsidian Vault")) +INGESTED_DIR = VAULT_PATH / "07 - Ingested" +SEMANTIC_DIR = VAULT_PATH / "08 - Semantic" +LM_STUDIO_URL = get_lmstudio_config()["base_url"] + "/v1/chat/completions" +EXTRACT_MODEL = os.environ.get("EXTRACT_MODEL", get_lmstudio_config()["fast_model"]) +STATE_FILE = VAULT_PATH / ".vault-consolidate-state.json" + +EXTRACTION_PROMPT = """You are an information-extraction module for a long-term agent memory. + +Read the input note and produce a JSON array of atomic FACTS that should be remembered for future sessions. Each fact must be: +- ATOMIC: one claim per fact, fully self-contained +- TYPED: fact_type ∈ ["decision","preference","constraint","observation","reference"] +- DURABLE: would still be useful in 3 months, not session-trivia +- SHORT: 1-2 sentences + +Skip the note entirely if it contains nothing durable (small talk, "ok", trivial confirmations, build output, errors that were resolved within the same session). Output `[]` in that case. + +Respond with ONLY a valid JSON array. No prose, no markdown fences. Schema: +[ + { + "fact_type": "decision|preference|constraint|observation|reference", + "topic": "short-slug-for-folder", + "title": "Imperative title under 80 chars", + "claim": "1-2 sentence durable claim", + "tags": ["topic/x","agent/y"] + } +] + +Input note: +--- +""" + + +def call_llm(note_text: str, retries: int = 2) -> list[dict]: + """Call qwen via LM Studio chat/completions. Return parsed JSON list or [].""" + try: + with urllib.request.urlopen(get_lmstudio_config()["base_url"] + "/v1/models", timeout=5) as resp: + available_models = [m["id"] for m in json.loads(resp.read().decode("utf-8")).get("data", [])] + model = select_lmstudio_model( + task_type="consolidate", + requested_model=EXTRACT_MODEL, + automatic=True, + available_models=available_models, + ) + except Exception as e: + print(f"[consolidate] LM Studio model selection failed: {e}") + return [] + payload = { + "model": model, + "messages": [ + {"role": "user", "content": EXTRACTION_PROMPT + note_text[:8000]}, # cap input for safety + ], + "temperature": 0.1, + "max_tokens": 2000, + } + payload = json.dumps(add_lmstudio_ttl(payload)).encode("utf-8") + req = urllib.request.Request(LM_STUDIO_URL, data=payload, headers={"Content-Type": "application/json"}) + last_err = None + for attempt in range(retries): + try: + with urllib.request.urlopen(req, timeout=180) as resp: + d = json.loads(resp.read().decode("utf-8")) + content = extract_lmstudio_content(d["choices"][0], expect_json=True) + # Remove ```json fences if model added them despite instructions + content = re.sub(r"^```(?:json)?\s*", "", content) + content = re.sub(r"\s*```$", "", content) + if not content or content == "[]": + return [] + facts = json.loads(content) + if not isinstance(facts, list): + return [] + return facts + except json.JSONDecodeError as e: + last_err = f"JSON: {e}; raw: {content[:200]}" + except Exception as e: + last_err = str(e) + time.sleep(0.5 * (attempt + 1)) + print(f"[consolidate] LLM call failed: {last_err}", file=sys.stderr if False else None) + return [] + + +def load_state() -> dict: + if STATE_FILE.exists(): + try: + return json.loads(STATE_FILE.read_text(encoding="utf-8")) + except Exception: + pass + return {"processed_files": {}} # path -> last_consolidated_iso + + +def save_state(state: dict): + tmp = STATE_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(state, indent=2), encoding="utf-8") + tmp.replace(STATE_FILE) + + +def parse_since(arg: str) -> dt.datetime: + """Accept '24h', '7d', '30m'. Return UTC cutoff.""" + m = re.match(r"^(\d+)([hdm])$", arg) + if not m: + raise ValueError(f"--since must be like '24h', '7d', '30m', got {arg!r}") + n, unit = int(m.group(1)), m.group(2) + delta = {"h": dt.timedelta(hours=n), "d": dt.timedelta(days=n), "m": dt.timedelta(minutes=n)}[unit] + return dt.datetime.now(dt.timezone.utc) - delta + + +def extract_event_at(text: str) -> dt.datetime | None: + m = re.search(r"^event_at:\s*\"?([^\"\n]+)\"?", text, re.MULTILINE) + if not m: + return None + try: + return dt.datetime.fromisoformat(m.group(1).replace("Z", "+00:00")) + except Exception: + return None + + +def write_fact(fact: dict, source_rel: str, event_at: dt.datetime | None) -> Path: + topic = re.sub(r"[^\w\-]", "-", fact.get("topic", "general")).lower().strip("-")[:40] or "general" + title = fact.get("title", "untitled")[:80] + slug = re.sub(r"[^\w\-]", "-", title).strip("-")[:60] + fact_id = hashlib.sha256(f"{source_rel}::{title}".encode()).hexdigest()[:10] + folder = SEMANTIC_DIR / topic + folder.mkdir(parents=True, exist_ok=True) + path = folder / f"{fact_id}-{slug}.md" + if path.exists(): + return path # idempotent — already extracted + + captured_at = dt.datetime.now(dt.timezone.utc).isoformat() + event_iso = event_at.isoformat() if event_at else captured_at + fact_type = fact.get("fact_type", "observation") + raw_tags = fact.get("tags", []) or [] + tags = [str(t) for t in raw_tags] + [f"fact/{fact_type}", f"topic/{topic}", "ingested-fact"] + + fm_lines = [ + "---", + f'type: "semantic-fact"', + f'fact_type: "{fact_type}"', + f'topic: "{topic}"', + f'event_at: "{event_iso}"', + f'captured_at: "{captured_at}"', + f'derived_from: ["{source_rel}"]', + f'tags: [{", ".join(json.dumps(t) for t in tags)}]', + "---", + "", + f"# {title}", + "", + fact.get("claim", "(no claim)"), + "", + f"---", + f"Derived from [[{source_rel}]] on {captured_at}.", + ] + path.write_text("\n".join(fm_lines), encoding="utf-8") + return path + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--since", default="24h", help="Relative window: 24h, 7d, 30m") + ap.add_argument("--apply", action="store_true", help="Actually write fact notes (default is dry-run)") + ap.add_argument("--max-files", type=int, default=500, help="Cap files processed per run") + ap.add_argument("--source-glob", default="*.md", help="Glob within 07 - Ingested/") + args = ap.parse_args() + + cutoff = parse_since(args.since) + state = load_state() + + candidates = [] + if INGESTED_DIR.exists(): + for f in INGESTED_DIR.rglob(args.source_glob): + if not f.is_file(): + continue + rel = f.relative_to(VAULT_PATH).as_posix() + if rel in state["processed_files"]: + continue + try: + text = f.read_text(encoding="utf-8", errors="replace") + except Exception: + continue + ev = extract_event_at(text) + if ev and ev < cutoff: + continue + candidates.append((f, rel, text, ev)) + + candidates = candidates[: args.max_files] + print(f"[consolidate] {len(candidates)} candidate ingested note(s) since {args.since} (cutoff: {cutoff.isoformat()})") + print(f"[consolidate] mode: {'APPLY' if args.apply else 'DRY-RUN'}") + + total_facts = 0 + extracted_files = 0 + t0 = time.time() + for i, (f, rel, text, ev) in enumerate(candidates, 1): + # Trim frontmatter from the LLM input — we already have the metadata + body = re.sub(r"^---\n.*?\n---\n", "", text, count=1, flags=re.DOTALL) + if len(body.strip()) < 80: + continue + facts = call_llm(body) + if facts: + extracted_files += 1 + for fact in facts: + if not isinstance(fact, dict): + continue + if args.apply: + p = write_fact(fact, rel, ev) + total_facts += 1 + else: + print(f" [{rel}] {fact.get('fact_type','?'):12} {fact.get('topic','?'):20} {fact.get('title','')[:60]}") + total_facts += 1 + if args.apply: + state["processed_files"][rel] = dt.datetime.now(dt.timezone.utc).isoformat() + if i % 20 == 0: + save_state(state) + print(f"[consolidate] {i}/{len(candidates)} processed, {total_facts} facts so far ({time.time()-t0:.0f}s)") + + if args.apply: + save_state(state) + print(f"\n[consolidate] done. {extracted_files} files yielded {total_facts} facts in {time.time()-t0:.0f}s") + if args.apply: + print(f"[consolidate] state: {STATE_FILE}") + + +if __name__ == "__main__": + import sys + main() diff --git a/scripts/brain/vault_eval.py b/scripts/brain/vault_eval.py new file mode 100644 index 0000000000..ca3aabcd83 --- /dev/null +++ b/scripts/brain/vault_eval.py @@ -0,0 +1,268 @@ +""" +vault_eval.py — Evaluation harness for the vault brain. + +Measures: + - Recall@k (k=1, 5, 10, 20) + - MRR (Mean Reciprocal Rank) + - Per-strategy: BM25-only, dense-only, hybrid (RRF) + +Generates synthetic queries from existing curated docs (decisions, runbook, +topology) by extracting representative phrases via a small LLM call. Each +synthetic query is paired with its source file as ground truth — we then check +whether retrieval surfaces that file in the top-K. + +Citations: + - Voorhees (1999) — TREC: Reciprocal Rank metric + - Cormack et al. (2009) — Recall@k methodology for fusion ranking + - Lin et al. (2024) — RRF baseline for MS MARCO + +Usage: + python vault_eval.py generate --out queries.json # build synthetic test set + python vault_eval.py run queries.json # run all 3 strategies +""" +try: + from lmstudio_response import extract_lmstudio_content +except Exception: + from scripts.brain.lmstudio_response import extract_lmstudio_content +try: + from lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +except Exception: + from scripts.brain.lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +import argparse +import json +import os +import random +import re +import sys +import time +import urllib.request +from pathlib import Path + +# Reuse the brain functions +sys.path.insert(0, str(Path(__file__).parent)) +from vault_brain import ( + VAULT_PATH, open_index, embed_batch, search_bm25, search_dense, rrf_fuse, + fuse_with_centrality, EMBED_URL, +) + +GENERATE_PROMPT = """Given the following note from an engineering knowledge base, generate 2 SHORT user queries (each 5-12 words) that a future user would plausibly type to find this exact information. The queries should: +- be in natural language, not just keywords +- be specific enough that THIS note is the right answer +- vary in style (one keyword-heavy, one paraphrased) + +Output EXACTLY two lines, no numbering, no prose. Each line is one query. + +Note (file: {file_path}): +--- +{snippet} +---""" + + +def call_llm(prompt: str, model: str | None = None) -> str: + try: + with urllib.request.urlopen(get_lmstudio_config()["base_url"] + "/v1/models", timeout=5) as resp: + available_models = [m["id"] for m in json.loads(resp.read().decode("utf-8")).get("data", [])] + model = select_lmstudio_model( + task_type="eval", + requested_model=model or os.environ.get("EVAL_MODEL", get_lmstudio_config()["fast_model"]), + automatic=True, + available_models=available_models, + ) + except Exception as e: + return f"__error__ {e}" + payload = { + "model": model, + "messages": [ + {"role": "system", "content": "/no_think\nOutput only the final answer. No hidden reasoning. No markdown unless explicitly requested."}, + {"role": "user", "content": "/no_think\n" + prompt}, + ], + "temperature": 0.4, + "max_tokens": 200, + } + payload = json.dumps(add_lmstudio_ttl(payload)).encode("utf-8") + req = urllib.request.Request( + get_lmstudio_config()["base_url"] + "/v1/chat/completions", + data=payload, headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + d = json.loads(resp.read().decode("utf-8")) + return extract_lmstudio_content(d["choices"][0], expect_json=False) + except Exception as e: + return f"__error__ {e}" + + +def cmd_generate(args): + """Sample N curated files, generate 2 queries each.""" + conn = open_index() + rows = conn.execute( + "SELECT DISTINCT file_path FROM chunks WHERE source IN ('curated','procedural','semantic') AND chunk_idx = 0 LIMIT 500" + ).fetchall() + files = [r[0] for r in rows] + random.seed(42) + sample = random.sample(files, min(args.n, len(files))) + + queries = [] + for i, fp in enumerate(sample, 1): + row = conn.execute( + "SELECT text FROM chunks WHERE file_path=? AND chunk_idx=0", (fp,) + ).fetchone() + if not row: + continue + snippet = row[0][:1500] + prompt = GENERATE_PROMPT.format(file_path=fp, snippet=snippet) + out = call_llm(prompt) + if out.startswith("__error__"): + print(f"[generate] WARN {fp}: {out}", file=sys.stderr) + continue + lines = [l.strip(" -*0123456789.") for l in out.splitlines() if l.strip()] + for q in lines[:2]: + if 4 <= len(q.split()) <= 20: + queries.append({"query": q, "expected_file": fp}) + print(f"[generate] {i}/{len(sample)} {fp[:50]}: {len(lines[:2])} queries") + Path(args.out).write_text(json.dumps(queries, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"\n[generate] wrote {len(queries)} queries to {args.out}") + + +def evaluate(queries: list[dict], strategy: str, k_values: list[int]) -> dict: + """Run a strategy across queries, return metrics.""" + conn = open_index() + metrics = {f"recall@{k}": 0 for k in k_values} + metrics["mrr"] = 0.0 + metrics["n"] = len(queries) + metrics["queries_with_hit"] = 0 + + max_k = max(k_values) + for q in queries: + query, expected = q["query"], q["expected_file"] + needs_dense = strategy in ("dense", "hybrid", "hybrid_centrality") + needs_bm25 = strategy in ("bm25", "hybrid", "hybrid_centrality") + try: + qvec = embed_batch([query])[0] if needs_dense else None + except Exception: + qvec = None + bm25 = search_bm25(conn, query, k=max_k * 3) if needs_bm25 else [] + dense = search_dense(conn, qvec, k=max_k * 3) if (qvec and needs_dense) else [] + + if strategy == "bm25": + ranked = [rid for rid, _ in bm25] + elif strategy == "dense": + ranked = [rid for rid, _ in dense] + elif strategy == "hybrid": + fused = rrf_fuse([bm25, dense]) + ranked = [rid for rid, _ in sorted(fused.items(), key=lambda x: x[1], reverse=True)] + elif strategy == "hybrid_centrality": + fused = rrf_fuse([bm25, dense]) + # Load centrality for the candidate rowids + rids = list(fused.keys()) + if rids: + placeholders = ",".join("?" * len(rids)) + cent = {r[0]: r[1] for r in conn.execute( + f"SELECT rowid, centrality FROM chunks WHERE rowid IN ({placeholders})", rids + ).fetchall()} + fused = fuse_with_centrality(fused, cent) + ranked = [rid for rid, _ in sorted(fused.items(), key=lambda x: x[1], reverse=True)] + else: + raise ValueError(strategy) + + if not ranked: + continue + # Map rowid -> file_path (top-K only) + top_rids = ranked[:max_k] + if not top_rids: + continue + placeholders = ",".join("?" * len(top_rids)) + rows = conn.execute( + f"SELECT rowid, file_path FROM chunks WHERE rowid IN ({placeholders})", top_rids + ).fetchall() + rid_to_file = {rid: fp for rid, fp in rows} + ranked_files = [rid_to_file.get(rid, "") for rid in top_rids] + + # First match position + try: + pos = next(i for i, fp in enumerate(ranked_files, 1) if fp == expected) + metrics["mrr"] += 1.0 / pos + metrics["queries_with_hit"] += 1 + for k in k_values: + if pos <= k: + metrics[f"recall@{k}"] += 1 + except StopIteration: + pass + + n = max(metrics["n"], 1) + metrics["mrr"] = round(metrics["mrr"] / n, 4) + for k in k_values: + metrics[f"recall@{k}"] = round(metrics[f"recall@{k}"] / n, 4) + return metrics + + +def cmd_run(args): + queries = json.loads(Path(args.queries).read_text(encoding="utf-8")) + print(f"[eval] {len(queries)} queries loaded from {args.queries}") + k_values = [1, 5, 10, 20] + results = {} + strategies = args.strategies.split(",") if args.strategies else ["bm25", "dense", "hybrid", "hybrid_centrality"] + for strategy in strategies: + t0 = time.time() + metrics = evaluate(queries, strategy, k_values) + elapsed = time.time() - t0 + print(f"\n=== Strategy: {strategy} ({elapsed:.1f}s) ===") + for k in k_values: + print(f" Recall@{k:>2}: {metrics[f'recall@{k}']:.4f}") + print(f" MRR : {metrics['mrr']:.4f}") + print(f" hits : {metrics['queries_with_hit']}/{metrics['n']}") + results[strategy] = metrics + + # Pretty markdown report saved to vault for transparency + report_path = VAULT_PATH / "06 - Agents" / f"eval-{time.strftime('%Y-%m-%d')}.md" + report_path.parent.mkdir(parents=True, exist_ok=True) + lines = [ + "---", + f'type: "evaluation"', + f'created: "{time.strftime("%Y-%m-%d")}"', + f'tags: ["evaluation","retrieval","brain"]', + "---", + "", + f"# Vault Brain Retrieval Evaluation — {time.strftime('%Y-%m-%d')}", + "", + f"Test set: **{len(queries)} synthetic queries** generated from curated docs (decisions, runbook, topology).", + "", + "| Strategy | Recall@1 | Recall@5 | Recall@10 | Recall@20 | MRR |", + "|---|---|---|---|---|---|", + ] + for s in strategies: + m = results[s] + lines.append(f"| {s} | {m['recall@1']:.4f} | {m['recall@5']:.4f} | {m['recall@10']:.4f} | {m['recall@20']:.4f} | {m['mrr']:.4f} |") + lines += ["", "## Interpretation", "", + "- **BM25** (sparse, lexical) — strong on exact identifiers, weak on paraphrase.", + "- **Dense** (nomic-embed-text v1.5, 768d) — strong on semantic match, weak on rare tokens.", + "- **Hybrid (RRF k=60)** — should beat both; if it doesn't, the index has gaps or the queries are too easy.", + "", + "Citations:", + "- Cormack, Clarke, Buettcher (2009) — *Reciprocal Rank Fusion outperforms Condorcet and individual rank learning methods*", + "- Lin et al. (2024) — RRF baseline reproductions on MS MARCO", + "- Voorhees (1999) — Mean Reciprocal Rank, TREC-8", + ""] + report_path.write_text("\n".join(lines), encoding="utf-8") + print(f"\n[eval] report written to {report_path}") + print(json.dumps(results, indent=2)) + + +def main(): + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + p_g = sub.add_parser("generate") + p_g.add_argument("--out", default=str(VAULT_PATH / ".eval-queries.json")) + p_g.add_argument("-n", type=int, default=30) + p_r = sub.add_parser("run") + p_r.add_argument("queries") + p_r.add_argument("--strategies", default="", help="Comma-list, e.g. bm25,dense,hybrid,hybrid_centrality") + args = ap.parse_args() + if args.cmd == "generate": + cmd_generate(args) + elif args.cmd == "run": + cmd_run(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/brain/vault_synthesizer.py b/scripts/brain/vault_synthesizer.py new file mode 100644 index 0000000000..22d458c9f0 --- /dev/null +++ b/scripts/brain/vault_synthesizer.py @@ -0,0 +1,339 @@ +""" +vault_synthesizer.py — The brain that writes about itself. + +For each Label-Propagation community of size >= MIN_SIZE, generate a +"community summary" note that: + 1. Lists all members of the cluster (wikilinks) + 2. Identifies the dominant theme (top TextRank phrases shared) + 3. Asks the local LLM (qwen3.6 via LM Studio) to write a 5-sentence + synthesis describing what this cluster is about + 4. Cites the most central member as the "anchor" of the cluster + 5. Drops a note in 08 - Semantic/communities/ + +This makes the brain an *active* participant: it discovers structure (via +LPA), then describes that structure in human-readable form. New synthesis +notes themselves become high-degree hubs in the graph (each links to all +their cluster members), which dramatically improves PageRank centrality +and navigability. + +Theoretical foundation (peer-reviewed): + - Park, J. S. et al. (2023). "Generative Agents." UIST 2023. + DOI: 10.1145/3586183.3606763. The "reflection" component of generative + agents writes higher-level abstractions over recent episodic memories. + This synthesizer applies the same pattern to graph communities. + - Raghavan, U. N., Albert, R., Kumara, S. (2007). "Near linear time + algorithm to detect community structures." Physical Review E 76(3), + 036106. DOI: 10.1103/PhysRevE.76.036106. (Provides the communities + we summarize.) + - Mihalcea, R., Tarau, P. (2004). "TextRank: Bringing Order into Texts." + EMNLP 2004. (Provides the dominant phrases per community.) + +Usage: + python vault_synthesizer.py --apply # synthesize all communities >= 5 + python vault_synthesizer.py --apply --min-size 10 # only big clusters + python vault_synthesizer.py --apply --max-clusters 20 # cap LLM calls +""" +try: + from lmstudio_response import extract_lmstudio_content +except Exception: + from scripts.brain.lmstudio_response import extract_lmstudio_content +try: + from lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +except Exception: + from scripts.brain.lmstudio_policy import add_lmstudio_ttl, get_lmstudio_config, select_lmstudio_model +import argparse +import datetime as dt +import json +import os +import re +import sys +import time +import urllib.request +from collections import Counter, defaultdict +from pathlib import Path + +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +VAULT_PATH = Path(os.environ.get("VAULT_PATH", r"C:\Users\Smedj\Documents\Obsidian Vault")) +COMMUNITIES_FILE = VAULT_PATH / ".vault-communities.json" +KEYPHRASES_FILE = VAULT_PATH / ".vault-keyphrases.json" +PAGERANK_FILE = VAULT_PATH / ".vault-pagerank.json" +COMMUNITY_DIR = VAULT_PATH / "08 - Semantic" / "communities" +LM_STUDIO_URL = get_lmstudio_config()["base_url"] + "/v1/chat/completions" +SYNTH_MODEL = os.environ.get("SYNTH_MODEL", get_lmstudio_config()["deep_model"]) + +# Tiered model preference for synthesis. Per the operator's correction (2026-04-29): +# Claude (this session) and GPT-5.5 (codex CLI) are flagships; qwen3.6 is fallback / +# token-saver for when both flagships are tapped out (cf. 04 - Quota & Cost/Strategy). +# +# This module only handles the qwen3.6 path (fastest to plumb, no auth). The claude-direct +# path is invoked by Claude itself writing the synthesis in-session (see existing +# community-XXXX notes written 2026-04-29). The gpt-5.5 path uses codex CLI in --exec mode. +PROMPT_FOR_FLAGSHIP_PATH = """If you want Claude or GPT-5.5 to write this synthesis directly +instead of calling qwen3.6, paste the prompt below into the active session: + +--- +{prompt} +--- + +Then write the resulting markdown into: +{out_path} +""" + + +SYNTH_PROMPT = """You are summarizing a cluster of related notes from an engineering knowledge base. + +The cluster has the following dominant phrases (most-shared TextRank keywords across notes): +{phrases} + +The cluster contains {n} notes. Here are titles of representative members: +{titles} + +Write EXACTLY: +1. A 1-line theme name (under 8 words) that captures what this cluster is about. +2. A 4-5 sentence synthesis describing the common thread, recurring decisions, and any tensions. +3. One concrete TODO that an agent should pick up to either resolve a tension or extend the cluster. + +Format strictly as YAML: +theme: "..." +synthesis: "..." +todo: "..." + +Do not add prose, explanations, or markdown fences. Output ONLY the YAML.""" + + +def call_llm(prompt: str, retries: int = 2, timeout: int = 90) -> str | None: + try: + with urllib.request.urlopen(get_lmstudio_config()["base_url"] + "/v1/models", timeout=5) as resp: + available_models = [m["id"] for m in json.loads(resp.read().decode("utf-8")).get("data", [])] + model = select_lmstudio_model( + task_type="long_synthesis", + requested_model=SYNTH_MODEL, + automatic=True, + available_models=available_models, + ) + except Exception as e: + print(f"[synth] LM Studio model selection failed: {e}", file=sys.stderr) + return None + payload = { + "model": model, + "messages": [ + {"role": "system", "content": "/no_think\nOutput only the final answer. No hidden reasoning. No markdown unless explicitly requested."}, + {"role": "user", "content": "/no_think\n" + prompt}, + ], + "temperature": 0.3, + "max_tokens": 600, + } + payload = json.dumps(add_lmstudio_ttl(payload)).encode("utf-8") + for attempt in range(retries): + try: + req = urllib.request.Request(LM_STUDIO_URL, data=payload, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + d = json.loads(resp.read().decode("utf-8")) + return extract_lmstudio_content(d["choices"][0], expect_json=False) + except Exception as e: + if attempt == retries - 1: + print(f"[synth] LLM call failed: {e}", file=sys.stderr) + time.sleep(1) + return None + + +def parse_synth_yaml(raw: str) -> dict | None: + if not raw: + return None + raw = raw.strip() + # Strip code fences + raw = re.sub(r"^```(?:yaml)?\s*", "", raw) + raw = re.sub(r"\s*```$", "", raw) + out = {} + for line in raw.splitlines(): + m = re.match(r'\s*(theme|synthesis|todo)\s*:\s*"?(.+?)"?\s*$', line) + if m: + out[m.group(1)] = m.group(2).strip().strip('"').strip("'") + if "theme" in out and "synthesis" in out: + return out + return None + + +def build_community_phrases(community_paths: list[str], keyphrases: dict[str, list[str]], + top: int = 8) -> list[tuple[str, int]]: + counter = Counter() + for path in community_paths: + for p in keyphrases.get(path, []): + counter[p] += 1 + return counter.most_common(top) + + +def render_note(community_id: int, paths: list[str], theme: str, synthesis: str, todo: str, + top_phrases: list[tuple[str, int]], anchor: str | None) -> str: + lines = [ + "---", + f'type: "community-synthesis"', + f'community_id: {community_id}', + f'created: "{dt.datetime.now(dt.timezone.utc).isoformat()}"', + f'member_count: {len(paths)}', + f'tags: ["community-synthesis","auto-generated","layer-2"]', + "---", + "", + f"# Community {community_id} — {theme}", + "", + "## Synthesis", + "", + synthesis, + "", + "## TODO", + "", + f"- {todo}", + "", + "## Anchor", + "", + f"- [[{anchor[:-3] if anchor and anchor.endswith('.md') else anchor}]]" if anchor else "*(no anchor)*", + "", + "## Dominant phrases", + "", + ] + for p, c in top_phrases: + lines.append(f"- `{p}` ({c} notes)") + lines += ["", "## Members", ""] + for p in paths[:60]: + target = p[:-3] if p.endswith(".md") else p + lines.append(f"- [[{target.replace(chr(92), '/')}]]") + if len(paths) > 60: + lines.append(f"- *(...and {len(paths) - 60} more — see graph)*") + lines += [ + "", + "---", + "", + "*Auto-synthesized from Label-Propagation community detection ([Raghavan, Albert & Kumara, " + "Phys. Rev. E 76(3), 2007](https://doi.org/10.1103/PhysRevE.76.036106)) over the densified wikilink graph. " + "Synthesis written by `qwen3.6-35b-a3b` (local, no API cost).*", + ] + return "\n".join(lines) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--apply", action="store_true") + ap.add_argument("--min-size", type=int, default=5) + ap.add_argument("--max-clusters", type=int, default=15) + ap.add_argument("--rewrite", action="store_true") + ap.add_argument("--emit-prompts", action="store_true", + help="Don't call any LLM — print prompts for Claude/GPT to fill in interactively. " + "Use this in active sessions where the operator has Claude or GPT-5.5 ready.") + ap.add_argument("--via-codex", action="store_true", + help="Call OpenAI Codex CLI (gpt-5.5) instead of LM Studio (qwen3.6). " + "Faster but uses ChatGPT quota.") + args = ap.parse_args() + + if not COMMUNITIES_FILE.exists(): + print("[synth] missing communities — run vault_communities.py detect") + sys.exit(1) + if not KEYPHRASES_FILE.exists(): + print("[synth] missing keyphrases — run vault_textrank.py extract") + sys.exit(1) + + com = json.loads(COMMUNITIES_FILE.read_text(encoding="utf-8")) + nodes = com["nodes"] + labels = com["labels"] + keyphrases = json.loads(KEYPHRASES_FILE.read_text(encoding="utf-8")) + pr = {} + if PAGERANK_FILE.exists(): + pr = json.loads(PAGERANK_FILE.read_text(encoding="utf-8")).get("pagerank", {}) + + # Group nodes by community + members: dict[int, list[str]] = defaultdict(list) + for i, lbl in enumerate(labels): + members[lbl].append(nodes[i]) + + eligible = sorted( + [(cid, paths) for cid, paths in members.items() if len(paths) >= args.min_size], + key=lambda x: -len(x[1]), + ) + print(f"[synth] {len(eligible)} communities with size >= {args.min_size}") + eligible = eligible[: args.max_clusters] + print(f"[synth] processing top {len(eligible)} (capped by --max-clusters)") + + if args.apply: + COMMUNITY_DIR.mkdir(parents=True, exist_ok=True) + + written = 0 + for cid, paths in eligible: + out_path = COMMUNITY_DIR / f"community-{cid:04d}.md" + if out_path.exists() and not args.rewrite: + print(f"[synth] community {cid}: file exists, skip (use --rewrite to overwrite)") + continue + + top_phrases = build_community_phrases(paths, keyphrases, top=8) + if not top_phrases: + print(f"[synth] community {cid}: no phrases, skipping") + continue + + # Pick anchor = highest-PageRank member + anchor = max(paths, key=lambda p: pr.get(p, 0.0)) if pr else paths[0] + + # Sample titles for prompt (first chunk = title) + titles = [] + for p in paths[:8]: + stem = Path(p).stem.replace("-", " ").replace("_", " ") + stem = re.sub(r"^\d{2}-\d{2}-\d{2}\s*", "", stem) + titles.append(f" - {stem[:80]}") + + prompt = SYNTH_PROMPT.format( + phrases=", ".join(f"`{p}`" for p, _ in top_phrases), + n=len(paths), + titles="\n".join(titles), + ) + + if args.emit_prompts: + # Print the prompt so Claude or GPT-5.5 (in active session) can fill it in. + print("=" * 80) + print(PROMPT_FOR_FLAGSHIP_PATH.format(prompt=prompt, out_path=out_path)) + print("=" * 80) + continue + + if args.via_codex: + print(f"[synth] community {cid:4d}: routing via Codex CLI (gpt-5.5)…") + try: + import subprocess as _sp + t0 = time.time() + res = _sp.run(["codex", "exec", "--skip-git-repo-check", prompt], + capture_output=True, text=True, timeout=120) + raw = res.stdout.strip() + elapsed = time.time() - t0 + except Exception as e: + print(f"[synth] codex failed: {e}; falling back to qwen3.6") + t0 = time.time() + raw = call_llm(prompt) + elapsed = time.time() - t0 + else: + print(f"[synth] community {cid:4d} ({len(paths)} notes): calling qwen3.6 (fallback tier)…") + t0 = time.time() + raw = call_llm(prompt) + elapsed = time.time() - t0 + if not raw: + print(f"[synth] community {cid}: LLM returned nothing ({elapsed:.0f}s)") + continue + parsed = parse_synth_yaml(raw) + if not parsed: + print(f"[synth] community {cid}: parse failed; raw[:120]={raw[:120]!r}") + continue + theme = parsed.get("theme", "(untitled)") + synthesis = parsed.get("synthesis", "") + todo = parsed.get("todo", "") + print(f"[synth] community {cid}: '{theme}' ({elapsed:.0f}s)") + + note = render_note(cid, paths, theme, synthesis, todo, top_phrases, anchor) + if args.apply: + out_path.write_text(note, encoding="utf-8") + written += 1 + + print() + print(f"[synth] wrote {written} synthesis note(s) to {COMMUNITY_DIR}") + print(f"[synth] mode: {'APPLIED' if args.apply else 'DRY-RUN'}") + + +if __name__ == "__main__": + main()