From 02db85c7acc35e4e455f6d82f04148ce4d7ced0b Mon Sep 17 00:00:00 2001 From: HamoudeFakhoury01 Date: Thu, 18 Jun 2026 18:31:20 +0200 Subject: [PATCH 1/5] Windows native port: install.ps1 + patched hooks + wscript scheduled tasks - install.ps1 : idempotent, non-destructive PowerShell installer - hooks patched for Windows (utf-8 encoding, tempfile, strftime, Path.home) - scheduled tasks via wscript launchers (avoids 0xC000013A CTRL_C in autonomous/locked sessions; S4U blocked on Windows Home) - gbq/gbrain .cmd wrappers (bun link shim broken on Windows) - gbrain-nightly.ps1, set-ze-key.ps1, CLAUDE.global.windows.md Co-Authored-By: Claude Opus 4.8 (1M context) --- windows/CLAUDE.global.windows.md | 35 ++++ windows/gbrain-nightly.ps1 | 67 +++++++ windows/hooks/correction-detector.py | 35 ++++ windows/hooks/daily-reflection.py | 70 +++++++ windows/hooks/session-indexer.py | 29 +++ windows/hooks/session-logger.py | 28 +++ windows/hooks/session-recap.py | 282 +++++++++++++++++++++++++++ windows/install.ps1 | 240 +++++++++++++++++++++++ windows/set-ze-key.ps1 | 27 +++ 9 files changed, 813 insertions(+) create mode 100644 windows/CLAUDE.global.windows.md create mode 100644 windows/gbrain-nightly.ps1 create mode 100644 windows/hooks/correction-detector.py create mode 100644 windows/hooks/daily-reflection.py create mode 100644 windows/hooks/session-indexer.py create mode 100644 windows/hooks/session-logger.py create mode 100644 windows/hooks/session-recap.py create mode 100644 windows/install.ps1 create mode 100644 windows/set-ze-key.ps1 diff --git a/windows/CLAUDE.global.windows.md b/windows/CLAUDE.global.windows.md new file mode 100644 index 0000000..32dba31 --- /dev/null +++ b/windows/CLAUDE.global.windows.md @@ -0,0 +1,35 @@ + +## Directives brain-in-a-box + +> Installé par brain-in-a-box (Windows). Bloc ajouté à la fin de ton `~/.claude/CLAUDE.md` (jamais écrasé). + +Boucle directrice : **rassembler le contexte → agir → vérifier → recommencer.** + +### Règle n°1 ABSOLUE — Capturer les corrections (BLOQUANT) + +S'applique dans CHAQUE repo, CHAQUE session. Le hook `~/.claude/hooks/brain/correction-detector.py` (UserPromptSubmit) détecte les signaux de correction (négation, « en fait », « plutôt », « évite », reproche, préférence) et injecte un rappel `⚠️ CORRECTION DETECTED`. + +Quand tu le vois, AVANT toute autre chose : +1. **Ajouter** une ligne à `~/Documents/Brain/Profile/lessons.md` sous l'en-tête du jour (`## AAAA-MM-JJ`). + Format : `- **[contexte court]** → règle : [quoi faire] (quand : [déclencheur])` +2. **Confirmer** visiblement : `✓ noté dans lessons.md` +3. **Ensuite** appliquer la correction et continuer. + +Non négociable. Si tu ne peux pas écrire le fichier, dis-le explicitement. + +### Avant de supposer qu'un outil manque +Lis `~/Documents/Brain/Profile/stack.md` — outils installés, mémoire GBrain (`gbq`), accès. Si c'est listé, utilise-le. + +### Avant de diagnostiquer +Lis `~/Documents/Brain/Profile/memory.md` (contexte récent, décisions actives) et `Projects/.md`. Le contexte est déjà capturé — ne le redemande pas. + +### Directives d'ingénierie +- **Supprimer avant de construire** : avant de refactorer un fichier >300 lignes, retirer le code mort. Commit séparé. +- **Exécution par phases** : jamais un refacto multi-fichiers en une seule réponse. Max ~5 fichiers/phase, vérifier entre chaque. +- **Planifier et construire sont séparés** : « fais un plan » = plan seulement, pas de code avant « go ». +- **Vérification forcée** : avant « fini », lancer type-checker + linter + tests. Si rien n'est configuré, le dire. Jamais « Fini ! » avec des erreurs ouvertes. +- **Sécurité d'abord** : injection, authz, secrets exposés, crypto faible, race conditions. Signaler toute vulnérabilité, même hors scope. Ne jamais committer/logger un secret (.env, *.pem, *.key, tokens). +- **Sécurité des actions destructrices** : ne jamais supprimer un fichier sans vérifier les références. Ne jamais pousser sur un repo partagé sans demande explicite. + +### Mémoire (GBrain) +Pour rappeler des décisions/contexte/personnes : `gbq query ""` (sémantique) au lieu de deviner. Cite le slug. Détails : `Profile/stack.md`. diff --git a/windows/gbrain-nightly.ps1 b/windows/gbrain-nightly.ps1 new file mode 100644 index 0000000..6829989 --- /dev/null +++ b/windows/gbrain-nightly.ps1 @@ -0,0 +1,67 @@ +# brain-in-a-box nightly maintenance (Windows port of gbrain-nightly.sh). +# Run by Task Scheduler at 04:00. +# Steps: self-update gbrain -> commit personal vault -> sync personal +# -> [company vault pull + sync if present] -> dream cycle. +# +# No stale-lock sweep here: the macOS PGLite "process won't exit" bug does NOT +# reproduce on Windows (cli-force-exit.ts exits cleanly, verified with +# sequential + concurrent reads). So no kill/lock dance is needed. + +$ErrorActionPreference = "Continue" + +$Bun = @("$env:USERPROFILE\.bun\bin\bun.exe", "$env:APPDATA\npm\node_modules\bun\bin\bun.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1 +$Cli = "$env:USERPROFILE\DEV\gbrain\src\cli.ts" +$Vault = "$env:USERPROFILE\Documents\Brain" +$VaultCo = "$env:USERPROFILE\Documents\BrainCo" +$GRepo = "$env:USERPROFILE\DEV\gbrain" +$Log = "$env:USERPROFILE\.gbrain\nightly.log" + +# All logging goes through one UTF-8 sink so the log stays readable. PowerShell +# 5.1's `*>>` redirect writes UTF-16 (mixes encodings), so we pipe to Add-Content. +function Log($msg) { Add-Content -Path $Log -Value $msg -Encoding utf8 } +function Run { & $args[0] @($args[1..($args.Count-1)]) 2>&1 | Add-Content -Path $Log -Encoding utf8 } +# bun runs inline; output piped to the UTF-8 log sink. The 0xC000013A (CTRL_C) +# failures in autonomous/locked-session runs are avoided by LAUNCHING this task +# via a wscript launcher (.vbs, no console) + LogonType Interactive. S4U is NOT +# used (Access Denied on Windows Home). No Start-Process gymnastics needed. +function Gbrain { (& $Bun run $Cli @args 2>&1) | Add-Content -Path $Log -Encoding utf8 } + +Log "===== $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') nightly start =====" + +# 0. Self-update gbrain (resilient: a failure must never block the cycle). +if (Test-Path "$GRepo\.git") { + Push-Location $GRepo + $before = (git rev-parse --short HEAD 2>$null) + Run git pull --ff-only + $after = (git rev-parse --short HEAD 2>$null) + if ($before -ne $after) { + Log "[update] gbrain $before -> $after" + Run $Bun install + Gbrain apply-migrations --yes + } + Pop-Location +} + +# 1. Commit the personal vault first (sync is git-diff based -> without a commit, edits are invisible). +if (Test-Path "$Vault\.git") { + Push-Location $Vault + if (git status --porcelain) { + Run git add -A + Run git -c user.email="brain@local" -c user.name="brain" commit -q -m "nightly $(Get-Date -Format 'yyyy-MM-dd')" + Log "[git] personal vault committed" + } + Pop-Location +} +Gbrain sync --repo $Vault --no-pull + +# 2. COMPANY vault (team mode): pull teammates' contributions + sync the 'company' source. +if (Test-Path "$VaultCo\.git") { + Run git -C $VaultCo pull --ff-only + Gbrain sync --source company + Log "[sync] company source" +} + +# 3. Dream cycle (dedup, facts, consolidation, embed, purge). +Gbrain dream + +Log "===== $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') nightly done =====" diff --git a/windows/hooks/correction-detector.py b/windows/hooks/correction-detector.py new file mode 100644 index 0000000..070cbbb --- /dev/null +++ b/windows/hooks/correction-detector.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +import json, sys, re, os +from pathlib import Path + +try: + payload = json.load(sys.stdin) +except Exception: + sys.exit(0) + +prompt = (payload.get("prompt") or payload.get("user_prompt") or "").lower() +if not prompt: + sys.exit(0) + +PATTERNS = [ + r"\b(no|nope|nah|stop|don't|do not|not)\b", + r"\b(actually|instead|rather|i'd prefer|i would prefer|avoid|no need to|it'd be better|it would be better)\b", + r"\b(that's not it|that's wrong|you forgot|you were supposed to|why didn't you|why did you not)\b", + r"\b(i want you to|from now on|going forward|next time|please don't)\b", +] +if not any(re.search(p, prompt) for p in PATTERNS): + sys.exit(0) + +brain = Path(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()) +if not (brain / "Profile" / "lessons.md").exists(): + brain = Path.home() / "Documents" / "Brain" + +reminder = ( + "⚠️ CORRECTION DETECTED — BEFORE doing anything else:\n" + f"1. Append a line to {brain}/Profile/lessons.md under today's header (## YYYY-MM-DD).\n" + " Format: - **[short context]** -> rule: [what to do] (when: [trigger condition])\n" + "2. Confirm visibly: ✓ noted in lessons.md\n" + "3. Only then apply the correction and continue." +) +print(json.dumps({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": reminder}})) +sys.exit(0) diff --git a/windows/hooks/daily-reflection.py b/windows/hooks/daily-reflection.py new file mode 100644 index 0000000..4f97861 --- /dev/null +++ b/windows/hooks/daily-reflection.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +import json, sys, os, time, subprocess, tempfile, shutil +from pathlib import Path + +BRAIN = Path.home() / "Documents" / "Brain" +LOGS = Path.home() / ".claude" / "logs" +DAY = time.strftime("%Y-%m-%d") +slot = "midday" if int(time.strftime("%H")) < 18 else "evening" + +lock = Path(tempfile.gettempdir()) / f"brain-daily-reflection-{DAY}-{slot}.lock" +if lock.exists() and (time.time() - lock.stat().st_mtime) < 3600: + sys.exit(0) +lock.write_text(str(time.time())) + +index_file = LOGS / f"sessions-{DAY}.jsonl" +if not index_file.exists(): + sys.exit(0) + +sessions = [] +for line in index_file.read_text(encoding="utf-8").splitlines(): + try: + sessions.append(json.loads(line)) + except Exception: + pass +if not sessions: + sys.exit(0) + +transcripts = [] +for s in sessions: + tp = s.get("transcript_path") + if tp and Path(tp).exists(): + try: + transcripts.append(Path(tp).read_text(encoding="utf-8", errors="replace")[:50000]) + except Exception: + pass + +if not transcripts: + sys.exit(0) + +big = (chr(10) + "---SESSION---" + chr(10)).join(transcripts)[:200000] +prompt = f"""Read these Claude Code session transcripts from {DAY} ({slot} run). Generate: + +1. A journal summary for Journal/{DAY}.md with sections: + - What I did today + - Key decisions + - Projects I worked on + - To do tomorrow + +2. An update to Profile/memory.md (Recent context section): keep the last 15 days max, add today's salient items. + +Write both files directly. Terse style, no filler. Skip sessions with <3 messages. + +Transcripts: +{big} +""" + +claude_bin = shutil.which("claude") or os.path.expandvars(r"%APPDATA%\npm\claude.cmd") +if not claude_bin or not Path(claude_bin).exists(): + (LOGS / "daily-reflection-errors.log").open("a", encoding="utf-8").write(f"{time.strftime('%Y-%m-%dT%H:%M:%S')} claude introuvable: {claude_bin}" + chr(10)) + sys.exit(0) +try: + subprocess.run( + [claude_bin, "-p", "--permission-mode", "acceptEdits", prompt], + cwd=str(BRAIN), + timeout=600, + check=False, + ) +except Exception as e: + (LOGS / "daily-reflection-errors.log").open("a", encoding="utf-8").write(f"{time.strftime('%Y-%m-%dT%H:%M:%S')} {e}" + chr(10)) +sys.exit(0) diff --git a/windows/hooks/session-indexer.py b/windows/hooks/session-indexer.py new file mode 100644 index 0000000..9223e89 --- /dev/null +++ b/windows/hooks/session-indexer.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import json, sys, os, time, tempfile +from pathlib import Path + +try: + payload = json.load(sys.stdin) +except Exception: + sys.exit(0) + +sid = payload.get("session_id") or payload.get("sessionId") or "unknown" +lock_dir = Path(tempfile.gettempdir()) / "claude-session-locks" +lock_dir.mkdir(parents=True, exist_ok=True) +lock = lock_dir / f"indexer-{sid}.lock" +if lock.exists(): + sys.exit(0) +lock.write_text(str(time.time())) + +log_dir = Path.home() / ".claude" / "logs" +log_dir.mkdir(parents=True, exist_ok=True) +day = time.strftime("%Y-%m-%d") +entry = { + "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "session_id": sid, + "cwd": payload.get("cwd") or os.getcwd(), + "transcript_path": payload.get("transcript_path"), +} +with (log_dir / f"sessions-{day}.jsonl").open("a", encoding="utf-8") as f: + f.write(json.dumps(entry) + chr(10)) +sys.exit(0) diff --git a/windows/hooks/session-logger.py b/windows/hooks/session-logger.py new file mode 100644 index 0000000..4099c92 --- /dev/null +++ b/windows/hooks/session-logger.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +import json, sys, os, time, tempfile +from pathlib import Path + +try: + payload = json.load(sys.stdin) +except Exception: + sys.exit(0) + +sid = payload.get("session_id") or payload.get("sessionId") or "unknown" +lock_dir = Path(tempfile.gettempdir()) / "claude-session-locks" +lock_dir.mkdir(parents=True, exist_ok=True) +lock = lock_dir / f"logger-{sid}.lock" +if lock.exists(): + sys.exit(0) +lock.write_text(str(time.time())) + +log_dir = Path.home() / ".claude" / "logs" +log_dir.mkdir(parents=True, exist_ok=True) +entry = { + "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "session_id": sid, + "cwd": payload.get("cwd") or os.getcwd(), + "transcript_path": payload.get("transcript_path"), +} +with (log_dir / "sessions.log").open("a", encoding="utf-8") as f: + f.write(json.dumps(entry) + chr(10)) +sys.exit(0) diff --git a/windows/hooks/session-recap.py b/windows/hooks/session-recap.py new file mode 100644 index 0000000..058a4ff --- /dev/null +++ b/windows/hooks/session-recap.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +""" +Stop hook: extracts lightweight stats from a Claude session and appends them to +~/Documents/Brain/Journal/YYYY-MM-DD.md under the "📊 Claude Sessions" section. + +Idempotent (skips if session_id already present). No LLM call. +""" +import json, sys, os, re, time, tempfile +from pathlib import Path +from collections import Counter +from datetime import datetime + +BRAIN = Path.home() / "Documents" / "Brain" +JOURNAL_DIR = BRAIN / "Journal" +LOCK_DIR = Path(tempfile.gettempdir()) / "claude-session-locks" + +SIGNAL_PATTERNS = [ + # SSH / infra + ("ssh", re.compile(r"\bssh\s+\S")), + # GitHub + ("gh pr create", re.compile(r"\bgh pr create\b")), + ("gh pr merge", re.compile(r"\bgh pr merge\b")), + ("gh run", re.compile(r"\bgh run\b")), + ("gh workflow", re.compile(r"\bgh workflow\b")), + # Cloud / IaC + ("terraform", re.compile(r"\bterraform\b")), + ("aws cli", re.compile(r"\baws\s+(s3|ec2|ecs|rds|logs|iam|ssm)\b")), + ("kubectl", re.compile(r"\bkubectl\b")), + ("vercel", re.compile(r"\bvercel\b")), + # DB + ("psql", re.compile(r"\bpsql\b|postgresql@1[56]/bin/psql")), + ("mysql", re.compile(r"\bmysql\b")), + ("redis", re.compile(r"\bredis-cli\b")), + ("mongo", re.compile(r"\bmongosh\b|\bmongo\b")), + (".sql", re.compile(r"\.sql\b")), + # Build / runtime + ("npm/yarn build", re.compile(r"\b(npm|yarn|pnpm) (run )?(build|deploy)\b")), + ("bun", re.compile(r"\bbun (run|install|test|dev)\b")), + ("docker", re.compile(r"\bdocker (ps|build|run|compose|exec)\b")), + ("podman", re.compile(r"\bpodman\s+\w+")), + # Pentest / security + ("nmap", re.compile(r"\bnmap\b")), + ("gobuster", re.compile(r"\bgobuster\b")), + ("hashcat", re.compile(r"\bhashcat\b")), + ("sqlmap", re.compile(r"\bsqlmap\b")), + ("hydra", re.compile(r"\bhydra\s+-")), + ("burp", re.compile(r"\bburp(suite)?\b")), + ("gitleaks", re.compile(r"\bgitleaks\b")), + # API calls + ("curl", re.compile(r"\bcurl\s+[^\n]*https?://")), + # Secrets + ("keychain", re.compile(r"security find-generic-password")), +] + + +def parse_transcript(path): + """Returns dict with stats from the JSONL transcript.""" + stats = { + "tools": Counter(), + "files": set(), + "first_prompt": None, + "first_ts": None, + "last_ts": None, + "model": None, + "signals": Counter(), + "bash_commands": [], + } + if not path or not Path(path).exists(): + return stats + + with open(path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + try: + d = json.loads(line) + except Exception: + continue + + ts = d.get("timestamp") or d.get("ts") + if ts: + if stats["first_ts"] is None: + stats["first_ts"] = ts + stats["last_ts"] = ts + + # First user prompt + if d.get("type") == "user" and stats["first_prompt"] is None: + msg = d.get("message", {}) + content = msg.get("content", "") + if isinstance(content, list): + for c in content: + if c.get("type") == "text": + stats["first_prompt"] = c.get("text", "")[:200] + break + elif isinstance(content, str): + stats["first_prompt"] = content[:200] + + # Model name + if d.get("type") == "assistant" and not stats["model"]: + msg = d.get("message", {}) + stats["model"] = msg.get("model", "")[:30] + + # Tool uses + if d.get("type") == "assistant": + msg = d.get("message", {}) + content = msg.get("content", []) + if isinstance(content, list): + for c in content: + if c.get("type") == "tool_use": + name = c.get("name", "?") + stats["tools"][name] += 1 + inp = c.get("input", {}) or {} + # File paths + fp = inp.get("file_path") + if fp: + stats["files"].add(fp) + # Bash command signal detection + if name == "Bash": + cmd = inp.get("command", "") + stats["bash_commands"].append(cmd[:200]) + for label, pat in SIGNAL_PATTERNS: + if pat.search(cmd): + stats["signals"][label] += 1 + return stats + + +def fmt_duration(first_ts, last_ts): + """Returns (display_str, is_resumed_bool). Si > 24h → reprise.""" + if not first_ts or not last_ts: + return ("?min", False) + try: + a = datetime.fromisoformat(first_ts.replace("Z", "+00:00")) + b = datetime.fromisoformat(last_ts.replace("Z", "+00:00")) + secs = int((b - a).total_seconds()) + if secs < 0: + return (f"?", False) + if secs >= 86400: + days = secs // 86400 + hours = (secs % 86400) // 3600 + return (f"reprise/{days}j{hours}h", True) + if secs < 60: + return (f"{secs}s", False) + m = secs // 60 + if m < 60: + return (f"{m}min", False) + return (f"{m//60}h{m%60:02d}", False) + except Exception: + return ("?min", False) + + +def fmt_time(ts): + if not ts: + return "??:??" + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone() + return dt.strftime("%H:%M") + except Exception: + return "??:??" + + +def short_cwd(cwd): + if not cwd: + return "?" + # Normalize: drop trailing slash, expand home + cwd = cwd.rstrip("/").replace(str(Path.home()), "~") + if "/Documents/Brain" in cwd: + return "Brain" + if cwd.startswith("~/DEV/"): + return cwd.replace("~/DEV/", "") + return cwd + + +def short_files(files): + """Shows up to 3 basenames + count of remaining.""" + bases = [Path(f).name for f in sorted(files)] + if len(bases) <= 3: + return ", ".join(bases) if bases else "—" + return ", ".join(bases[:3]) + f" (+{len(bases)-3})" + + +def render_entry(stats, session_id, cwd): + end = fmt_time(stats["last_ts"]) + dur_str, resumed = fmt_duration(stats["first_ts"], stats["last_ts"]) + header_time = (f"~{end}" if resumed else f"{fmt_time(stats['first_ts'])}–{end}") + cwd_s = short_cwd(cwd) + model = (stats["model"] or "").replace("claude-", "") + top_tools = ", ".join(f"{n} {c}" for n, c in stats["tools"].most_common(5)) + files_s = short_files(stats["files"]) + prompt = (stats["first_prompt"] or "").replace("\n", " ").strip() + if len(prompt) > 140: + prompt = prompt[:137] + "..." + signals = ", ".join(f"{s}×{n}" for s, n in stats["signals"].most_common(5)) + + lines = [ + f"- **{header_time}** ({dur_str}) `{cwd_s}` {model} · sid:`{session_id[:8]}`", + f" - tools: {top_tools or '—'}", + ] + if prompt: + lines.append(f" - intent: {prompt}") + if files_s != "—": + lines.append(f" - files: {files_s}") + if signals: + lines.append(f" - infra/cli: {signals}") + return "\n".join(lines) + + +def ensure_journal(day): + f = JOURNAL_DIR / f"{day}.md" + if not f.exists(): + JOURNAL_DIR.mkdir(parents=True, exist_ok=True) + f.write_text(f"# {day} — Daily\n\n## 📊 Claude Sessions\n\n", encoding="utf-8") + elif "## 📊 Claude Sessions" not in f.read_text(encoding="utf-8"): + with f.open("a", encoding="utf-8") as fh: + fh.write("\n## 📊 Claude Sessions\n\n") + return f + + +def upsert_session(journal, session_id, entry): + """Replace existing block for this sid (or append). True if changed.""" + sid_short = session_id[:8] + txt = journal.read_text(encoding="utf-8") + # Match existing block: header line + nested " - ..." continuation lines + pattern = re.compile( + rf"^- \*\*[^\n]+sid:`{re.escape(sid_short)}`[^\n]*\n(?: - [^\n]+\n?)*", + re.MULTILINE, + ) + new_txt = pattern.sub("", txt) + # Ensure section header still present after removal + if "## 📊 Claude Sessions" not in new_txt: + new_txt = new_txt.rstrip() + "\n\n## 📊 Claude Sessions\n\n" + new_txt = new_txt.rstrip("\n") + "\n" + entry + "\n" + if new_txt != txt: + journal.write_text(new_txt, encoding="utf-8") + return True + return False + + +def main(): + try: + payload = json.load(sys.stdin) + except Exception: + sys.exit(0) + + sid = payload.get("session_id") or payload.get("sessionId") or "unknown" + cwd = payload.get("cwd") or os.getcwd() + transcript_path = payload.get("transcript_path") + + # Debounce: skip if the last recap for this sid was < DEBOUNCE_SECS ago + DEBOUNCE_SECS = 30 + LOCK_DIR.mkdir(parents=True, exist_ok=True) + lock = LOCK_DIR / f"recap-{sid}.lock" + if lock.exists(): + age = time.time() - lock.stat().st_mtime + if age < DEBOUNCE_SECS: + sys.exit(0) + lock.write_text(str(time.time())) + + # Skip smoke tests / temp cwd + if cwd.startswith(tempfile.gettempdir()) or cwd.startswith("/tmp") or cwd.startswith("/private/tmp"): + sys.exit(0) + + stats = parse_transcript(transcript_path) + # Skip sessions < 3 tool calls (probablement triviales) + if sum(stats["tools"].values()) < 3: + sys.exit(0) + + day = time.strftime("%Y-%m-%d") + journal = ensure_journal(day) + entry = render_entry(stats, sid, cwd) + upsert_session(journal, sid, entry) + sys.exit(0) + + +if __name__ == "__main__": + try: + main() + except Exception as e: + # Log the error but never break the Stop hook + err_log = Path.home() / ".claude" / "logs" / "session-recap-errors.log" + try: + err_log.open("a", encoding="utf-8").write(f"{time.strftime('%Y-%m-%dT%H:%M:%S')} {type(e).__name__}: {e}\n") + except Exception: + pass + sys.exit(0) diff --git a/windows/install.ps1 b/windows/install.ps1 new file mode 100644 index 0000000..78e2007 --- /dev/null +++ b/windows/install.ps1 @@ -0,0 +1,240 @@ +# brain-in-a-box - installeur NATIF WINDOWS (port de install.sh). +# Idempotent + non-destructif sur tes DONNEES (vault, CLAUDE.md, cle ZE, init). +# NB: les fichiers GERES par l'installeur (hooks .py, gbrain-nightly.ps1, .vbs, +# wrappers .cmd, taches) SONT reecrits a chaque run (c'est voulu : ce sont +# des artefacts d'install, pas tes donnees). +# ASCII pur volontairement : PowerShell 5.1 lit un .ps1 UTF-8 sans BOM en cp1252 +# et casserait sur les accents / caracteres speciaux. +# +# Differences Windows (toutes eprouvees) : +# - PowerShell au lieu de bash ; pas de launchd -> Planificateur de taches. +# - Taches lancees via wscript (hote sans console) -> evite le 0xC000013A (CTRL_C) +# des runs autonomes/verrouilles. S4U non utilise (bloque sur Windows Home). +# - Hooks Python patches (encoding utf-8, tempfile au lieu de /tmp, %Y-%m-%d...). +# - Hooks lances via le chemin python.exe complet (pas le launcher 'py', qui +# peut etre absent -> capture des corrections cassee en silence). +# - Pas de 'bun link' (shim casse sous Windows) -> wrappers .cmd vers la source. +# +# Usage : +# powershell -ExecutionPolicy Bypass -File install.ps1 +# powershell -ExecutionPolicy Bypass -File install.ps1 -Company https://github.com//brain-company +param([string]$Company = "") + +# Continue (pas Stop) : sous PS 5.1, la stderr d'un exe natif (bun/git/winget) +# redirigee via 2>&1 devient une ErrorRecord terminante sous Stop, ce qui tuerait +# le script alors que la commande a reussi. Les vrais prerequis sont gardes par Die(). +$ErrorActionPreference = "Continue" + +function Say($m){ Write-Host "`n> $m" -ForegroundColor Cyan } +function OK($m){ Write-Host " [OK] $m" -ForegroundColor Green } +function Warn($m){ Write-Host " [!] $m" -ForegroundColor Yellow } +function Die($m){ Write-Host "[X] $m" -ForegroundColor Red; exit 1 } + +$WIN = $PSScriptRoot +$REPO = Split-Path -Parent $WIN +$H = $env:USERPROFILE +$BRAIN = "$H\Documents\Brain" +$BRAINCO = "$H\Documents\BrainCo" +$HOOKS = "$H\.claude\hooks\brain" +$BIN = "$H\.local\bin" +$GREPO = "$H\DEV\gbrain" +$GDATA = "$H\.gbrain" +$utf8 = New-Object System.Text.UTF8Encoding $false + +# --- 1. Preflight ------------------------------------------------------------- +Say "Preflight" +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Die "git manquant - installe Git for Windows." } + +$PYEXE = (Get-Command python.exe -ErrorAction SilentlyContinue | Where-Object { $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source +if (-not $PYEXE) { + $cand = Get-ChildItem "$env:LOCALAPPDATA\Programs\Python" -Directory -ErrorAction SilentlyContinue | + Where-Object { Test-Path "$($_.FullName)\python.exe" } | Sort-Object Name -Descending | Select-Object -First 1 + if ($cand) { $PYEXE = Join-Path $cand.FullName "python.exe" } +} +if (-not $PYEXE) { Die "Python 3 manquant - installe-le depuis python.org (pas le stub Microsoft Store)." } + +if (-not (Get-Command claude -ErrorAction SilentlyContinue)) { Warn "claude CLI introuvable - installe Claude Code (la reflection 23h en a besoin)." } + +$BUN = @("$H\.bun\bin\bun.exe", "$env:APPDATA\npm\node_modules\bun\bin\bun.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $BUN) { + Warn "bun manquant -> installation (bun.sh)..." + Invoke-RestMethod https://bun.sh/install.ps1 | Invoke-Expression + if (Test-Path "$H\.bun\bin\bun.exe") { $BUN = "$H\.bun\bin\bun.exe" } +} +if (-not $BUN) { Die "bun indisponible apres install." } +OK "deps : git, python ($PYEXE), bun ($BUN)" + +# --- 2. GBrain (clone + deps ; PAS de bun link) ------------------------------ +Say "GBrain" +New-Item -ItemType Directory -Force -Path "$H\DEV" | Out-Null +if (Test-Path "$GREPO\.git") { OK "deja clone" } +else { git clone https://github.com/garrytan/gbrain.git $GREPO 2>&1 | Out-Null; OK "clone" } +Push-Location $GREPO; & $BUN install 2>&1 | Out-Null; Pop-Location; OK "deps gbrain installees" +$CLI = "$GREPO\src\cli.ts" + +# --- 3. Vault (jamais ecraser) ----------------------------------------------- +Say "Vault ($BRAIN)" +if ((Test-Path $BRAIN) -and (Get-ChildItem $BRAIN -Force -ErrorAction SilentlyContinue)) { + Warn "vault existe deja -> skeleton NON copie (on garde ton contenu)" +} else { + New-Item -ItemType Directory -Force -Path $BRAIN | Out-Null + Copy-Item "$REPO\vault-skeleton\*" $BRAIN -Recurse -Force + OK "skeleton place" +} + +# --- 4. Hooks (copie des .py patches Windows) -------------------------------- +Say "Hooks ($HOOKS)" +New-Item -ItemType Directory -Force -Path $HOOKS | Out-Null +Copy-Item "$WIN\hooks\*.py" $HOOKS -Force +Copy-Item "$WIN\gbrain-nightly.ps1" "$HOOKS\gbrain-nightly.ps1" -Force +OK "hooks + gbrain-nightly.ps1 deployes" + +# --- 5. Commandes gbq / gbrain (wrappers generes avec chemins detectes) ------ +Say "Commandes gbq / gbrain" +New-Item -ItemType Directory -Force -Path $BIN | Out-Null +$wrapper = "@echo off`r`n`"$BUN`" run `"$CLI`" %*`r`n" +[System.IO.File]::WriteAllText("$BIN\gbrain.cmd", $wrapper, $utf8) +[System.IO.File]::WriteAllText("$BIN\gbq.cmd", $wrapper, $utf8) +$userPath = [Environment]::GetEnvironmentVariable('Path','User') +if (($userPath -split ';') -notcontains $BIN) { + [Environment]::SetEnvironmentVariable('Path', ($userPath.TrimEnd(';') + ';' + $BIN), 'User') + OK "gbq/gbrain -> $BIN (ajoute au PATH - effectif dans un nouveau terminal)" +} else { OK "gbq/gbrain -> $BIN (deja sur le PATH)" } +$env:Path = "$env:Path;$BIN" + +# --- 6. GBrain init (PGLite, sans embedding pour l'instant) ------------------ +# NB embedding_dimensions : laisse implicite (defaut gbrain pour zembed-1). On NE +# pin PAS de valeur devinee : init + embed tournent sur la meme version gbrain +# (coherent), et le nightly fait 'apply-migrations' apres chaque self-update. +Say "GBrain init" +if (Test-Path "$GDATA\brain.pglite") { OK "deja initialise" } +else { + & $BUN run $CLI init --pglite --no-embedding --non-interactive 2>&1 | Out-Null + OK "brain PGLite cree ($GDATA\brain.pglite)" +} +& $BUN run $CLI config set search.mode balanced 2>&1 | Out-Null + +# --- 7. Lanceurs wscript + taches planifiees (fix CTRL_C) -------------------- +Say "Taches planifiees (Planificateur, via wscript)" +$vbsNightly = "CreateObject(`"WScript.Shell`").Run `"powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"`"$HOOKS\gbrain-nightly.ps1`"`"`", 0, True`r`n" +$vbsReflect = "CreateObject(`"WScript.Shell`").Run `"`"`"$PYEXE`"`" `"`"$HOOKS\daily-reflection.py`"`"`", 0, True`r`n" +[System.IO.File]::WriteAllText("$HOOKS\run-nightly.vbs", $vbsNightly, $utf8) +[System.IO.File]::WriteAllText("$HOOKS\run-reflection.vbs", $vbsReflect, $utf8) + +$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable +$an = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "`"$HOOKS\run-nightly.vbs`"" +Register-ScheduledTask -TaskName "brain-gbrain-nightly" -Action $an -Trigger (New-ScheduledTaskTrigger -Daily -At 4:00AM) -Principal $principal -Settings $settings -Force | Out-Null +$ar = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "`"$HOOKS\run-reflection.vbs`"" +Register-ScheduledTask -TaskName "brain-reflection" -Action $ar -Trigger @((New-ScheduledTaskTrigger -Daily -At 12:00PM),(New-ScheduledTaskTrigger -Daily -At 11:00PM)) -Principal $principal -Settings $settings -Force | Out-Null +OK "brain-gbrain-nightly (04:00) + brain-reflection (12:00/23:00)" + +# --- 8. settings.json (merge hooks, non-destructif ; commande = python complet) +Say "Enregistrement des hooks (~/.claude/settings.json)" +$sp = "$H\.claude\settings.json" +New-Item -ItemType Directory -Force -Path (Split-Path $sp) | Out-Null +$cfg = if (Test-Path $sp) { Get-Content $sp -Raw | ConvertFrom-Json } else { [pscustomobject]@{} } +if (-not $cfg) { $cfg = [pscustomobject]@{} } +if (-not $cfg.PSObject.Properties['hooks']) { $cfg | Add-Member -NotePropertyName hooks -NotePropertyValue ([pscustomobject]@{}) -Force } +$hooksFwd = ($HOOKS -replace '\\','/') +$pyFwd = ($PYEXE -replace '\\','/') +function Ensure-Hook($evt, $names) { + if (-not $cfg.hooks.PSObject.Properties[$evt]) { $cfg.hooks | Add-Member -NotePropertyName $evt -NotePropertyValue @() -Force } + $arr = @($cfg.hooks.$evt) + $grp = $arr | Where-Object { -not $_.matcher } | Select-Object -First 1 + if (-not $grp) { $grp = [pscustomobject]@{ hooks = @() }; $arr = $arr + $grp } + foreach ($n in $names) { + $present = @($grp.hooks | Where-Object { $_.command -like "*$n*" }).Count -gt 0 + if (-not $present) { + $cmd = "`"$pyFwd`" `"$hooksFwd/$n`"" + $grp.hooks = @($grp.hooks) + ([pscustomobject]@{ type='command'; command=$cmd }) + } + } + $cfg.hooks.$evt = $arr +} +Ensure-Hook "UserPromptSubmit" @("correction-detector.py") +Ensure-Hook "Stop" @("session-logger.py","session-indexer.py","session-recap.py") +[System.IO.File]::WriteAllText($sp, ($cfg | ConvertTo-Json -Depth 20), $utf8) +OK "hooks enregistres (UserPromptSubmit + Stop, via $PYEXE)" + +# --- 9. CLAUDE.md global (append, marqueur, jamais ecraser) ------------------ +Say "CLAUDE.md global" +$gc = "$H\.claude\CLAUDE.md" +$existing = if (Test-Path $gc) { [System.IO.File]::ReadAllText($gc) } else { "" } +if ($existing -match "") { OK "deja present (skip)" } +else { + $block = [System.IO.File]::ReadAllText("$WIN\CLAUDE.global.windows.md") + [System.IO.File]::AppendAllText($gc, "`r`n`r`n" + $block, $utf8) + OK "bloc ajoute (ton contenu preserve)" +} + +# --- 10. git init du vault --------------------------------------------------- +Say "git init vault" +if (Test-Path "$BRAIN\.git") { OK "deja un repo git" } +else { + [System.IO.File]::WriteAllText("$BRAIN\.gitignore", ".obsidian/`n.DS_Store`nThumbs.db`ndesktop.ini`n", $utf8) + Push-Location $BRAIN + git init -q; git add -A + git -c user.email="brain@local" -c user.name="brain" commit -q -m "brain init" + Pop-Location + OK "repo vault cree" +} + +# --- 11. Cle ZeroEntropy + embedding ----------------------------------------- +Say "ZeroEntropy (embeddings - compte gratuit dashboard.zeroentropy.dev)" +$cfgPath = "$GDATA\config.json" +$gconf = if (Test-Path $cfgPath) { Get-Content $cfgPath -Raw | ConvertFrom-Json } else { [pscustomobject]@{} } +if (-not $gconf) { $gconf = [pscustomobject]@{} } +if ($gconf.PSObject.Properties['zeroentropy_api_key'] -and $gconf.zeroentropy_api_key) { + OK "cle deja configuree" +} else { + $sec = Read-Host " Colle ta cle ZeroEntropy (ze_...) ou Entree pour differer" -AsSecureString + $key = [System.Net.NetworkCredential]::new("", $sec).Password + if ($key) { + $gconf | Add-Member -NotePropertyName zeroentropy_api_key -NotePropertyValue $key -Force + $gconf | Add-Member -NotePropertyName embedding_model -NotePropertyValue "zeroentropyai:zembed-1" -Force + if ($gconf.PSObject.Properties['embedding_disabled']) { $gconf.PSObject.Properties.Remove('embedding_disabled') } + [System.IO.File]::WriteAllText($cfgPath, ($gconf | ConvertTo-Json -Depth 20), $utf8) + # Durcir les ACL (equivalent chmod 600) : la cle est en clair dans ce fichier. + icacls $cfgPath /inheritance:r /grant:r "$($env:USERNAME):(R,W)" 2>&1 | Out-Null + OK "cle ecrite (ACL durcies) + embedding active (zembed-1)" + & $BUN run $CLI import $BRAIN --no-embed 2>&1 | Out-Null + & $BUN run $CLI embed --stale 2>&1 | Out-Null + OK "vault importe + embedde" + } else { Warn "cle differee - lance plus tard : windows\set-ze-key.ps1 puis 'gbq embed --stale'" } +} + +# --- 12. Mode equipe (BrainCo) ----------------------------------------------- +if ($Company) { + Say "Mode equipe - BrainCo ($Company)" + if (Test-Path "$BRAINCO\.git") { OK "deja clone" } + else { git clone $Company $BRAINCO 2>&1 | Out-Null; OK "clone -> $BRAINCO" } + $srcList = (& $BUN run $CLI sources list) 2>$null + if ($srcList -match "(?m)^\s*company\s") { OK "source 'company' deja la" } + else { & $BUN run $CLI sources add company --path $BRAINCO 2>&1 | Out-Null; OK "source 'company' ajoutee" } + & $BUN run $CLI sources federate company 2>&1 | Out-Null + & $BUN run $CLI sync --source company --no-embed 2>&1 | Out-Null + OK "BrainCo indexe (federe)" +} + +# --- 13. Obsidian (optionnel) ------------------------------------------------ +Say "Obsidian (pour parcourir le vault)" +if (Test-Path "$env:LOCALAPPDATA\Programs\Obsidian\Obsidian.exe") { OK "deja installe" } +elseif (Get-Command winget -ErrorAction SilentlyContinue) { + winget install --id Obsidian.Obsidian -e --accept-source-agreements --accept-package-agreements --scope user 2>&1 | Out-Null + OK "installe via winget" +} else { Warn "winget absent - telecharge Obsidian sur https://obsidian.md" } + +# --- 14. Smoke-test des hooks (syntaxe) -------------------------------------- +Say "Verification des hooks" +$hookFail = $false +Get-ChildItem "$HOOKS\*.py" | ForEach-Object { + & $PYEXE -m py_compile $_.FullName 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { Warn "hook KO (syntaxe) : $($_.Name)"; $hookFail = $true } +} +if (-not $hookFail) { OK "5 hooks compiles (syntaxe OK)" } + +Write-Host "`n[OK] brain-in-a-box installe." -ForegroundColor Green +Write-Host "Prochaine etape - l'ONBOARDING (15 min) :" -ForegroundColor Cyan +Write-Host " cd $BRAIN ; claude puis colle : 'Lis onboarding/ONBOARDING.md et lance-le pour me personnaliser.'" +Write-Host "Verifs : gbq query `"test`" | Get-ScheduledTask brain-* | ouvre Obsidian sur $BRAIN" diff --git a/windows/set-ze-key.ps1 b/windows/set-ze-key.ps1 new file mode 100644 index 0000000..11d433c --- /dev/null +++ b/windows/set-ze-key.ps1 @@ -0,0 +1,27 @@ +# set-ze-key.ps1 - Configure la cle ZeroEntropy pour gbrain (Windows). +# +# Lance-le dans TON terminal (la cle est saisie ici, masquee, jamais affichee +# ni envoyee a Claude) : +# powershell -ExecutionPolicy Bypass -File set-ze-key.ps1 +# +# Il ecrit la cle dans ~/.gbrain/config.json (champ zeroentropy_api_key), +# active l'embedding et fixe le modele zeroentropyai:zembed-1. + +$ErrorActionPreference = "Stop" +$cfgPath = Join-Path $env:USERPROFILE ".gbrain\config.json" +if (-not (Test-Path $cfgPath)) { Write-Host "config.json introuvable ($cfgPath). gbrain init d'abord." -ForegroundColor Red; exit 1 } + +$sec = Read-Host "Colle ta cle ZeroEntropy (ze_...)" -AsSecureString +$key = [System.Net.NetworkCredential]::new("", $sec).Password +if ([string]::IsNullOrWhiteSpace($key)) { Write-Host "Cle vide -> abandon." -ForegroundColor Red; exit 1 } + +$cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json +$cfg | Add-Member -NotePropertyName zeroentropy_api_key -NotePropertyValue $key -Force +$cfg | Add-Member -NotePropertyName embedding_model -NotePropertyValue "zeroentropyai:zembed-1" -Force +if ($cfg.PSObject.Properties.Name -contains 'embedding_disabled') { $cfg.PSObject.Properties.Remove('embedding_disabled') } + +$json = $cfg | ConvertTo-Json -Depth 20 +[System.IO.File]::WriteAllText($cfgPath, $json, [System.Text.UTF8Encoding]::new($false)) + +Write-Host "OK: cle enregistree, embedding active (zeroentropyai:zembed-1)." -ForegroundColor Green +Write-Host "La cle n'a pas ete affichee. Reviens dans Claude et dis 'cle ok'." From 6abe51fbcb20b8e5b1c2b8c547c274d61056c2f9 Mon Sep 17 00:00:00 2001 From: HamoudeFakhoury01 Date: Thu, 25 Jun 2026 18:42:37 +0200 Subject: [PATCH 2/5] windows(twenty): connecteur CRM Twenty -> BrainCo (vue Commerciale) - twenty-extract-commercial.mjs : extrait les clients in-scope (hors a-contacter/perdu), dedup par societe - twenty-import.mjs : import companies/people (create-only, dry-run par defaut) - set-twenty-key / inspect / setup-pipeline : config + lecture du schema CRM La cle est lue depuis ~/.gbrain/twenty/config.json (hors repo, jamais commit). Co-Authored-By: Claude Opus 4.8 (1M context) --- windows/twenty/set-twenty-key.ps1 | 36 +++++ windows/twenty/twenty-extract-commercial.mjs | 64 ++++++++ windows/twenty/twenty-import.mjs | 162 +++++++++++++++++++ windows/twenty/twenty-inspect.mjs | 35 ++++ windows/twenty/twenty-setup-pipeline.mjs | 55 +++++++ 5 files changed, 352 insertions(+) create mode 100644 windows/twenty/set-twenty-key.ps1 create mode 100644 windows/twenty/twenty-extract-commercial.mjs create mode 100644 windows/twenty/twenty-import.mjs create mode 100644 windows/twenty/twenty-inspect.mjs create mode 100644 windows/twenty/twenty-setup-pipeline.mjs diff --git a/windows/twenty/set-twenty-key.ps1 b/windows/twenty/set-twenty-key.ps1 new file mode 100644 index 0000000..1efb13c --- /dev/null +++ b/windows/twenty/set-twenty-key.ps1 @@ -0,0 +1,36 @@ +# set-twenty-key.ps1 - Enregistre l'acces API Twenty (CRM) pour le connecteur brain. +# +# Lance-le dans TON terminal (la cle est saisie ici, masquee, jamais affichee +# ni envoyee a Claude) : +# powershell -ExecutionPolicy Bypass -File set-twenty-key.ps1 +# +# Ecrit dans ~/.gbrain/twenty/config.json : { url, api_key } + +$ErrorActionPreference = "Stop" + +$dir = Join-Path $env:USERPROFILE ".gbrain\twenty" +if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } +$cfgPath = Join-Path $dir "config.json" + +$url = Read-Host "URL de ton instance Twenty (defaut: https://crm.citymood.io)" +if ([string]::IsNullOrWhiteSpace($url)) { $url = "https://crm.citymood.io" } +$url = $url.TrimEnd('/') -replace '/rest$','' # on garde l'URL de BASE (le /rest est ajoute par le code) + +$sec = Read-Host "Colle ta cle API Twenty (le token COMPLET, type eyJ...)" -AsSecureString +$key = ([System.Net.NetworkCredential]::new("", $sec).Password).Trim() +if ([string]::IsNullOrWhiteSpace($key)) { Write-Host "Cle vide -> abandon." -ForegroundColor Red; exit 1 } +if ($key -match '[^\x20-\x7E]') { + Write-Host "Cle invalide (caracteres non-ASCII / bullets •). Tu as sans doute copie une version MASQUEE." -ForegroundColor Red + Write-Host "Recopie la cle COMPLETE telle qu'affichee a la creation (longue, commence souvent par 'eyJ')." -ForegroundColor Yellow + exit 1 +} + +$cfg = [ordered]@{ url = $url; api_key = $key } +$json = ($cfg | ConvertTo-Json -Depth 5) +[System.IO.File]::WriteAllText($cfgPath, $json, [System.Text.UTF8Encoding]::new($false)) + +# ACL : lisible par toi seul. +icacls $cfgPath /inheritance:r /grant:r "$($env:USERNAME):(R,W)" | Out-Null + +Write-Host "OK: acces Twenty enregistre dans $cfgPath (ACL durcie)." -ForegroundColor Green +Write-Host "La cle n'a pas ete affichee. Reviens dans Claude et dis 'cle twenty ok'." diff --git a/windows/twenty/twenty-extract-commercial.mjs b/windows/twenty/twenty-extract-commercial.mjs new file mode 100644 index 0000000..9c43ce8 --- /dev/null +++ b/windows/twenty/twenty-extract-commercial.mjs @@ -0,0 +1,64 @@ +// twenty-extract-commercial.mjs — ETAPE 1 de la synchro CRM -> BrainCo. +// Extrait les opportunites de la "vue Commerciale" = TOUTES sauf stage A_CONTACTER et PERDU. +// Dedup par societe -> liste de clients. Lecture seule (n'ecrit AUCUN fichier). +// Sortie : JSON sur stdout. +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const cfg = JSON.parse(readFileSync(join(homedir(), ".gbrain", "twenty", "config.json"), "utf8")); +const root = cfg.url.replace(/\/rest\/?$/, ""); +const H = { Authorization: "Bearer " + cfg.api_key, "Content-Type": "application/json" }; +const EXCLUDE = new Set(["A_CONTACTER", "PERDU"]); + +async function fetchAll(obj) { + let all = [], cursor = null; + for (let i = 0; i < 12; i++) { + const r = await (await fetch(root + "/rest/" + obj + "?limit=60" + (cursor ? "&starting_after=" + cursor : ""), { headers: H })).json(); + const recs = r.data?.[obj] || []; + all = all.concat(recs); + cursor = r.pageInfo?.endCursor; + if (!r.pageInfo?.hasNextPage || !recs.length) break; + } + return all; +} +const pname = (p) => p ? (((p.name?.firstName || "") + " " + (p.name?.lastName || "")).trim() || "") : ""; +const acct = (pl) => pl === "B2G" ? "collectivite" : pl === "B2B" ? "entreprise" : "à confirmer"; +const stageLabel = (s) => (s && typeof s === "object") ? s.label : s; +const slugify = (s) => (s || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "") + .replace(/^(ville de la |ville de l'|ville de |ville du |ville d'|mairie de la |mairie de |mairie du |mairie d'|commune de |commune du )/, "") + .replace(/['']/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + +const [opps, companies, people] = await Promise.all([fetchAll("opportunities"), fetchAll("companies"), fetchAll("people")]); +const coById = Object.fromEntries(companies.map((c) => [c.id, c])); +const pById = Object.fromEntries(people.map((p) => [p.id, p])); + +const inScope = opps.filter((o) => !EXCLUDE.has(o.stage)); +const clients = {}; +const oppsWithoutCompany = []; +for (const o of inScope) { + const stg = stageLabel(o.stage); + const co = o.companyId ? coById[o.companyId] : null; + if (!co) { oppsWithoutCompany.push({ opportunity: o.name || "(sans nom)", stage: stg }); continue; } + if (!clients[co.id]) clients[co.id] = { + companyName: co.name, companyId: co.id, naiveSlug: slugify(co.name), + domain: co.domainName?.primaryLinkUrl || "", accountType: acct(o.pipeline), + stages: [], opportunities: [], contacts: [], + }; + const cl = clients[co.id]; + if (stg && !cl.stages.includes(stg)) cl.stages.push(stg); + if (o.name) cl.opportunities.push(o.name); + const poc = o.pointOfContactId ? pById[o.pointOfContactId] : null; + if (poc) { const nm = pname(poc); if (nm && !cl.contacts.find((x) => x.name === nm)) cl.contacts.push({ name: nm, email: poc.emails?.primaryEmail || "", jobTitle: poc.jobTitle || "", decideur: !!poc.decideur }); } +} +// Enrichir : tous les people rattaches a ces societes (pas seulement le pointOfContact) +for (const p of people) { + if (p.companyId && clients[p.companyId]) { + const nm = pname(p); const cl = clients[p.companyId]; + if (nm && !cl.contacts.find((x) => x.name === nm)) cl.contacts.push({ name: nm, email: p.emails?.primaryEmail || "", jobTitle: p.jobTitle || "", decideur: !!p.decideur }); + } +} +process.stdout.write(JSON.stringify({ + inScopeCount: inScope.length, clientCount: Object.keys(clients).length, + clients: Object.values(clients), oppsWithoutCompany, +}, null, 2)); diff --git a/windows/twenty/twenty-import.mjs b/windows/twenty/twenty-import.mjs new file mode 100644 index 0000000..67b27d8 --- /dev/null +++ b/windows/twenty/twenty-import.mjs @@ -0,0 +1,162 @@ +// twenty-import.mjs — Importe le CRM Twenty dans BrainCo. +// Companies -> clients/.md People -> people/.md +// +// Principes : +// - CREATE-ONLY : n'ecrase JAMAIS un fichier existant (preserve les fiches ecrites a la main). +// - DRY-RUN par defaut (rapport seul). Ajoute --write pour ecrire reellement. +// - Ne commit/push rien : c'est une etape separee et explicite. +// - SRP : acces reseau / slug / mapping markdown / IO fichier sont des fonctions distinctes. +import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const WRITE = process.argv.includes("--write"); +const BRAINCO = join(homedir(), "Documents", "BrainCo"); +const TODAY = new Date().toISOString().slice(0, 10); + +// --- config / acces API (SRP : reseau isole) --- +function loadConfig() { + return JSON.parse(readFileSync(join(homedir(), ".gbrain", "twenty", "config.json"), "utf8")); +} +async function fetchAll(root, H, obj) { + let all = [], cursor = null; + for (let i = 0; i < 12; i++) { + const url = root + "/rest/" + obj + "?limit=60" + (cursor ? "&starting_after=" + cursor : ""); + const r = await (await fetch(url, { headers: H })).json(); + const recs = r.data?.[obj] || []; + all = all.concat(recs); + cursor = r.pageInfo?.endCursor; + if (!r.pageInfo?.hasNextPage || !recs.length) break; + } + return all; +} + +// --- slugs (SRP : nom -> slug gbrain, minuscules ascii) --- +function stripPrefix(s) { + return s.replace(/^(ville de la |ville de l'|ville de |ville du |ville d'|mairie de la |mairie de |mairie du |mairie d'|commune de |commune du )/, ""); +} +function slugify(name) { + const base = stripPrefix((name || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")); + return base.replace(/['']/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} +function personName(p) { + const n = p.name || {}; + return ((n.firstName || "") + " " + (n.lastName || "")).trim() || (typeof p.name === "string" ? p.name : "") || ""; +} +function personSlug(p) { return slugify(personName(p)); } + +// --- mapping CRM -> markdown (SRP : pur, aucune IO) --- +function pipelineToAccountType(pl) { + return pl === "B2G" ? "collectivite" : pl === "B2B" ? "entreprise" : "à confirmer"; +} +function clientSheet(company, accountType) { + const fm = [ + "---", + "type: client", + `account-type: ${accountType}`, + "crm: twenty", + `crm-id: ${company.id}`, + company.domainName?.primaryLinkUrl ? `domain: ${company.domainName.primaryLinkUrl}` : null, + `updated: ${TODAY}`, + "---", + ].filter(Boolean).join("\n"); + const body = [ + `\n# ${company.name}`, + "\n## Status\n_Importé du CRM Twenty — à enrichir._", + "\n## Contacts\n", + "\n## Meetings\n", + ].join("\n"); + return fm + "\n" + body + "\n"; +} +function personSheet(p, clientSlug) { + const email = p.emails?.primaryEmail || ""; + const fm = [ + "---", + "type: people", + "crm: twenty", + `crm-id: ${p.id}`, + clientSlug ? `client: ${clientSlug}` : null, + p.jobTitle ? `role: "${String(p.jobTitle).replace(/"/g, "'")}"` : null, + email ? `email: ${email}` : null, + `updated: ${TODAY}`, + "---", + ].filter(Boolean).join("\n"); + const body = [ + `\n# ${personName(p)}`, + clientSlug ? `- Société : [[clients/${clientSlug}]]` : null, + p.jobTitle ? `- Rôle : ${p.jobTitle}` : null, + email ? `- Email : ${email}` : null, + p.decideur != null ? `- Décideur : ${p.decideur ? "oui" : "non"}` : null, + ].filter(Boolean).join("\n"); + return fm + "\n" + body + "\n"; +} + +// --- IO fichier (SRP : create-only) --- +function existingSlugs(dir) { + const d = join(BRAINCO, dir); + if (!existsSync(d)) return new Set(); + return new Set(readdirSync(d).filter((f) => f.endsWith(".md")).map((f) => f.slice(0, -3))); +} +function writeSheet(dir, slug, content) { + const d = join(BRAINCO, dir); + if (!existsSync(d)) mkdirSync(d, { recursive: true }); + writeFileSync(join(d, slug + ".md"), content, "utf8"); +} + +// --- orchestration --- +(async () => { + if (!existsSync(BRAINCO)) { console.log("BrainCo introuvable:", BRAINCO); process.exit(1); } + const cfg = loadConfig(); + const root = cfg.url.replace(/\/rest\/?$/, ""); + const H = { Authorization: "Bearer " + cfg.api_key, "Content-Type": "application/json" }; + + const companies = await fetchAll(root, H, "companies"); + const people = await fetchAll(root, H, "people"); + const opps = await fetchAll(root, H, "opportunities"); + + // account-type d'un client = pipeline de sa 1re opportunite (B2G/B2B) + const acctByCompany = {}; + for (const o of opps) { + if (o.companyId && !acctByCompany[o.companyId] && o.pipeline) acctByCompany[o.companyId] = pipelineToAccountType(o.pipeline); + } + // slug client par companyId (pour lier les people) + const slugByCompany = {}; + for (const c of companies) slugByCompany[c.id] = slugify(c.name); + + const existClients = existingSlugs("clients"); + const existPeople = existingSlugs("people"); + + // --- plan clients --- + let cNew = 0, cSkip = 0; const cNewList = []; const bySlug = {}; + for (const c of companies) { + const slug = slugify(c.name); + if (!slug) continue; + (bySlug[slug] ||= []).push(c.name); + if (existClients.has(slug)) { cSkip++; continue; } + cNew++; cNewList.push(slug); + if (WRITE) writeSheet("clients", slug, clientSheet(c, acctByCompany[c.id] || "à confirmer")); + } + // --- plan people --- + let pNew = 0, pSkip = 0, pNoClient = 0; + for (const p of people) { + const slug = personSlug(p); + if (!slug) continue; + if (existPeople.has(slug)) { pSkip++; continue; } + pNew++; + const cslug = p.companyId ? slugByCompany[p.companyId] : null; + if (!cslug) pNoClient++; + if (WRITE) writeSheet("people", slug, personSheet(p, cslug)); + } + + // --- rapport --- + console.log((WRITE ? "=== ECRITURE dans BrainCo ===" : "=== DRY-RUN (rien ecrit) ===") + "\n"); + console.log(`Companies ${companies.length} -> clients a creer: ${cNew} | deja existants (skip): ${cSkip}`); + console.log(`People ${people.length} -> people a creer: ${pNew} | deja existants (skip): ${pSkip} | sans client lie: ${pNoClient}`); + const dups = Object.entries(bySlug).filter(([, arr]) => arr.length > 1); + if (dups.length) { + console.log(`\n⚠️ Collisions de slug (${dups.length}) — plusieurs societes -> meme fichier (1 seul gagne) :`); + dups.slice(0, 20).forEach(([s, arr]) => console.log(` ${s}.md <- ${arr.join(" / ")}`)); + } + console.log(`\nExemples clients a creer: ${cNewList.slice(0, 15).join(", ")}`); + if (!WRITE) console.log("\n-> relance avec --write pour ecrire (rien ne sera commit/push automatiquement)."); +})(); diff --git a/windows/twenty/twenty-inspect.mjs b/windows/twenty/twenty-inspect.mjs new file mode 100644 index 0000000..003b36d --- /dev/null +++ b/windows/twenty/twenty-inspect.mjs @@ -0,0 +1,35 @@ +// twenty-inspect.mjs - Teste l'API REST Twenty + liste les objets/champs. +// Lecture seule. La cle est lue depuis ~/.gbrain/twenty/config.json, jamais affichee. +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const cfg = JSON.parse(readFileSync(join(homedir(), ".gbrain", "twenty", "config.json"), "utf8")); +const base = cfg.url.replace(/\/rest\/?$/, "") + "/rest"; // URL de base + /rest (evite le doublon) +const headers = { Authorization: `Bearer ${cfg.api_key}` }; + +async function get(path) { + const res = await fetch(base + path, { headers }); + const text = await res.text(); + let json = null; + try { json = JSON.parse(text); } catch { /* non-json */ } + return { status: res.status, json, text }; +} + +// Twenty REST renvoie typiquement { data: { : [...] } }. +function records(json, key) { + return json?.data?.[key] || json?.data || []; +} + +console.log("URL:", cfg.url, "\n"); +for (const obj of ["companies", "people", "opportunities", "notes", "tasks"]) { + const r = await get(`/${obj}?limit=2`); + const recs = records(r.json, obj); + const n = Array.isArray(recs) ? recs.length : "?"; + console.log(`/${obj.padEnd(13)} -> HTTP ${r.status} | ${n} record(s)`); + if (Array.isArray(recs) && recs[0]) { + console.log(" champs:", Object.keys(recs[0]).join(", ")); + } else if (r.status !== 200) { + console.log(" ", (r.text || "").slice(0, 120)); + } +} diff --git a/windows/twenty/twenty-setup-pipeline.mjs b/windows/twenty/twenty-setup-pipeline.mjs new file mode 100644 index 0000000..5cd6dec --- /dev/null +++ b/windows/twenty/twenty-setup-pipeline.mjs @@ -0,0 +1,55 @@ +// twenty-setup-pipeline.mjs - Ajoute (de façon ADDITIVE, idempotente) : +// - pipeline : option "Prospection" +// - stage : options "À contacter" + "Contacté" +// Préserve toutes les options existantes. Lit la clé depuis ~/.gbrain/twenty/config.json. +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const cfg = JSON.parse(readFileSync(join(homedir(), ".gbrain", "twenty", "config.json"), "utf8")); +const root = cfg.url.replace(/\/rest\/?$/, ""); +const H = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" }; + +async function getField(name) { + const j = await (await fetch(root + "/rest/metadata/objects?limit=80", { headers: H })).json(); + const opp = (j.data?.objects || j.objects || []).find((o) => (o.nameSingular || o.name) === "opportunity"); + return (opp?.fields || []).find((f) => f.name === name); +} + +async function ensureOptions(name, additions) { + const f = await getField(name); + if (!f) { console.log(`${name}: champ introuvable`); return; } + const have = new Set((f.options || []).map((o) => o.value)); + const toAdd = additions.filter((a) => !have.has(a.value)); + if (!toAdd.length) { console.log(`${name}: deja a jour (${[...have].join(", ")})`); return; } + const options = [...(f.options || []), ...toAdd]; // existant preserve + nouveaux + const r = await fetch(root + "/rest/metadata/fields/" + f.id, { + method: "PATCH", headers: H, body: JSON.stringify({ options }), + }); + const t = await r.text(); + console.log(`${name}: PATCH HTTP ${r.status}` + (r.status >= 300 ? " ERR " + t.slice(0, 240) : "")); +} + +// 0. token valide ? +try { + const p = JSON.parse(Buffer.from(String(cfg.api_key).split(".")[1], "base64url").toString("utf8")); + console.log("Token expire le:", p.exp ? new Date(p.exp * 1000).toISOString() : "?"); +} catch { /* ignore */ } +const test = await fetch(root + "/rest/companies?limit=1", { headers: H }); +console.log("Test lecture API -> HTTP", test.status, "\n"); +if (test.status !== 200) { console.log("Token invalide -> stop."); process.exit(1); } + +// 1. pipeline + Prospection +await ensureOptions("pipeline", [{ value: "PROSPECTION", label: "Prospection", color: "blue", position: 2 }]); +// 2. stage + À contacter / Contacté +await ensureOptions("stage", [ + { value: "A_CONTACTER", label: "À contacter", color: "gray", position: 10 }, + { value: "CONTACTE", label: "Contacté", color: "orange", position: 11 }, +]); + +// 3. verif finale +console.log("\n=== etat final ==="); +for (const n of ["pipeline", "stage"]) { + const f = await getField(n); + console.log(`${n}: ` + (f.options || []).map((o) => `${o.value}("${o.label}")`).join(", ")); +} From 29ba1c5c8b248534468af694ee87df437469f304 Mon Sep 17 00:00:00 2001 From: HamoudeFakhoury01 Date: Thu, 25 Jun 2026 18:44:05 +0200 Subject: [PATCH 3/5] chore(gitignore): exclure .mcp.json et config.json (secrets locaux) .mcp.json contient le token Twenty -> ne doit jamais etre commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index dc30ec6..13e38f0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ _test-home/ __pycache__/ *.pyc + +# secrets locaux — ne jamais committer +.mcp.json +**/config.json From ff13b79d59a169e068beb8b5353d39c48d7ecf3b Mon Sep 17 00:00:00 2001 From: HamoudeFakhoury01 Date: Fri, 26 Jun 2026 12:30:02 +0200 Subject: [PATCH 4/5] windows: connecteur mail Gmail->brain + skill mail-affiliator + nightly - email-collector/ : collecte Gmail (metadata + corps complet) -> JSON local -> notes mails/ affiliees - mail-candidates.mjs : pre-filtre + dedup des candidats clients (retire bruit/calendrier/interne) - skills/mail-affiliator : classifie 1 mail -> 1 client (fiche + wikilink) - gbrain-nightly.ps1 : section mail (collecte+enrich) + pin --source/--repo (sync federe correct, plus de corruption de source) Cles/data hors repo (~/.gbrain), jamais commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- windows/email-collector/README.md | 37 +++ windows/email-collector/email-collector.mjs | 314 +++++++++++++++++++ windows/email-collector/email-enrich.py | 172 ++++++++++ windows/email-collector/mail-candidates.mjs | 61 ++++ windows/email-collector/set-google-oauth.ps1 | 51 +++ windows/gbrain-nightly.ps1 | 32 +- windows/skills/mail-affiliator/SKILL.md | 44 +++ 7 files changed, 708 insertions(+), 3 deletions(-) create mode 100644 windows/email-collector/README.md create mode 100644 windows/email-collector/email-collector.mjs create mode 100644 windows/email-collector/email-enrich.py create mode 100644 windows/email-collector/mail-candidates.mjs create mode 100644 windows/email-collector/set-google-oauth.ps1 create mode 100644 windows/skills/mail-affiliator/SKILL.md diff --git a/windows/email-collector/README.md b/windows/email-collector/README.md new file mode 100644 index 0000000..02985e9 --- /dev/null +++ b/windows/email-collector/README.md @@ -0,0 +1,37 @@ +# Email collector (Windows) — recette gbrain email-to-brain + +Collecteur Gmail déterministe : pull les mails (lecture seule, **métadonnées only**, +jamais le corps), filtre le bruit, génère les liens Gmail, déduplique. Sort un +digest markdown. L'enrichissement (écrire `[[clients/]]` dans BrainCo) est +une 2e étape (agent). + +## Confidentialité +- Mails bruts → **local** (`~/.gbrain/email-collector/data/`), **jamais** dans BrainCo (partagé). +- Dans BrainCo ne vont que des **lignes timeline** (expéditeur, objet, lien) — pas le corps. +- Scope OAuth = `gmail.readonly`. On ne fait que lire. + +## Installation (Option B — Google OAuth direct) +1. Crée les creds OAuth (Desktop app) sur https://console.cloud.google.com + en étant connecté avec **le compte mail à connecter** (ex. mohamedfakhoury@citymood.io), + API Gmail activée, scope `gmail.readonly`. +2. Enregistre-les (saisie masquée) : + ``` + powershell -ExecutionPolicy Bypass -File set-google-oauth.ps1 + ``` +3. Première autorisation (ouvre le navigateur) : + ``` + bun email-collector.mjs auth + ``` +4. Collecte + digest : + ``` + bun email-collector.mjs run + ``` + +## Fichiers +- `set-google-oauth.ps1` — enregistre client_id/secret dans `~/.gbrain/email-collector/config.json`. +- `email-collector.mjs` — collecteur (commandes : `auth` | `collect` | `digest` | `run`). +- Données : `~/.gbrain/email-collector/data/{messages,digests}/` + `state.json`. + +## Cron (à brancher) +Task Scheduler + lanceur wscript (comme les autres tâches brain), toutes les 30 min : +`bun email-collector.mjs run`. L'enrichissement BrainCo tourne ensuite (agent). diff --git a/windows/email-collector/email-collector.mjs b/windows/email-collector/email-collector.mjs new file mode 100644 index 0000000..21ee671 --- /dev/null +++ b/windows/email-collector/email-collector.mjs @@ -0,0 +1,314 @@ +// email-collector.mjs - Collecteur Gmail -> digest, pour brain-in-a-box (Windows). +// +// Couche DETERMINISTE de la recette gbrain email-to-brain : du code pull les +// mails (lecture seule, METADONNEES uniquement -- jamais le corps), filtre le +// bruit, genere les liens Gmail, deduplique. L'enrichissement (ecrire les liens +// [[clients/]] dans BrainCo) est une 2e etape faite par l'agent. +// +// Runtime : bun (ou node 18+). Zero dependance npm (fetch + node:http natifs). +// +// Commandes : +// bun email-collector.mjs auth -> flux OAuth navigateur (1re fois), stocke le refresh_token +// bun email-collector.mjs collect -> pull les mails depuis le dernier run +// bun email-collector.mjs digest -> ecrit le digest markdown du jour +// bun email-collector.mjs run -> collect + digest +// +// Config : ~/.gbrain/email-collector/config.json (cree par set-google-oauth.ps1) +// Donnees : ~/.gbrain/email-collector/data/ (LOCAL, jamais dans BrainCo) + +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; + +const SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; +const BASE = join(homedir(), ".gbrain", "email-collector"); +const CFG_PATH = join(BASE, "config.json"); +const DATA = join(BASE, "data"); +const MSG_DIR = join(DATA, "messages"); +const DIGEST_DIR = join(DATA, "digests"); +const STATE_PATH = join(DATA, "state.json"); + +// --- bruit / signatures (deterministe, identique a la recette) --- +const NOISE_SENDERS = [ + "noreply", "no-reply", "notifications@", "calendar-notification", + "mailer-daemon", "postmaster", "donotreply", +]; +const SIGNATURE_PATTERNS = [ + /docusign/i, /dropbox sign/i, /hellosign/i, /pandadoc/i, + /please sign/i, /signature needed/i, /ready for your signature/i, + /everyone has signed/i, /you just signed/i, +]; + +function isNoise(from) { + const f = (from || "").toLowerCase(); + return NOISE_SENDERS.some((p) => f.includes(p)); +} +function isSignature(from, subject) { + return SIGNATURE_PATTERNS.some((p) => p.test(subject || "") || p.test(from || "")); +} + +// --- helpers fichiers --- +function ensureDirs() { + for (const d of [BASE, DATA, MSG_DIR, DIGEST_DIR]) mkdirSync(d, { recursive: true }); +} +function readJson(path, fallback) { + if (!existsSync(path)) return fallback; + return JSON.parse(readFileSync(path, "utf8")); +} +function writeJson(path, obj) { + writeFileSync(path, JSON.stringify(obj, null, 2), "utf8"); +} +function loadConfig() { + if (!existsSync(CFG_PATH)) { + throw new Error(`Config absente: ${CFG_PATH}. Lance d'abord set-google-oauth.ps1.`); + } + const cfg = readJson(CFG_PATH, null); + if (!cfg?.client_id || !cfg?.client_secret) { + throw new Error("client_id/client_secret manquants dans config.json."); + } + return cfg; +} +function saveConfig(cfg) { + writeJson(CFG_PATH, cfg); +} +function today() { + // Date locale YYYY-MM-DD sans dependance. + const d = new Date(); + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} + +// --- OAuth --- +async function exchangeToken(params) { + const res = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(params), + }); + const text = await res.text(); + if (!res.ok) throw new Error(`OAuth token ${res.status}: ${text}`); + return JSON.parse(text); +} + +// Flux navigateur (1re fois) : recupere et stocke le refresh_token. +async function runAuthFlow(cfg) { + return new Promise((resolve, reject) => { + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://127.0.0.1"); + const code = url.searchParams.get("code"); + const err = url.searchParams.get("error"); + if (err) { res.end(`Erreur OAuth: ${err}`); server.close(); return reject(new Error(err)); } + if (!code) { res.end("En attente du code..."); return; } + const port = server.address().port; + const tok = await exchangeToken({ + code, + client_id: cfg.client_id, + client_secret: cfg.client_secret, + redirect_uri: `http://127.0.0.1:${port}`, + grant_type: "authorization_code", + }); + if (!tok.refresh_token) { + res.end("Pas de refresh_token recu (deja autorise ? revoque l'acces et reessaie)."); + server.close(); + return reject(new Error("refresh_token absent -- ajoute prompt=consent / revoque l'ancien acces.")); + } + cfg.refresh_token = tok.refresh_token; + saveConfig(cfg); + res.end("OK ! Refresh token enregistre. Tu peux fermer cet onglet et revenir au terminal."); + server.close(); + resolve(); + } catch (e) { reject(e); } + }); + + server.listen(0, "127.0.0.1", () => { + const port = server.address().port; + const authUrl = "https://accounts.google.com/o/oauth2/v2/auth?" + new URLSearchParams({ + client_id: cfg.client_id, + redirect_uri: `http://127.0.0.1:${port}`, + response_type: "code", + scope: SCOPE, + access_type: "offline", + prompt: "consent", + login_hint: cfg.account || "", + }); + console.log(`\nOuverture du navigateur pour autoriser ${cfg.account}...`); + console.log(`Si rien ne s'ouvre, colle cette URL :\n${authUrl}\n`); + // Windows : ouvrir l'URL via rundll32 (passe l'URL TELLE QUELLE au + // navigateur par defaut). PAS `cmd /c start` : cmd traite les `&` de + // l'URL comme des separateurs et tronque tout apres client_id. + spawn("rundll32", ["url.dll,FileProtocolHandler", authUrl], { detached: true, stdio: "ignore" }); + }); + }); +} + +async function getAccessToken(cfg) { + if (!cfg.refresh_token) throw new Error("Pas de refresh_token. Lance: bun email-collector.mjs auth"); + const tok = await exchangeToken({ + client_id: cfg.client_id, + client_secret: cfg.client_secret, + refresh_token: cfg.refresh_token, + grant_type: "refresh_token", + }); + return tok.access_token; +} + +// --- Gmail (REST, metadonnees uniquement) --- +async function gmail(accessToken, path, params) { + const url = new URL(`https://gmail.googleapis.com/gmail/v1/users/me/${path}`); + for (const [k, v] of Object.entries(params || {})) { + if (Array.isArray(v)) v.forEach((x) => url.searchParams.append(k, x)); + else url.searchParams.set(k, v); + } + const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }); + const text = await res.text(); + if (!res.ok) throw new Error(`Gmail ${path} ${res.status}: ${text}`); + return JSON.parse(text); +} + +function header(msg, name) { + const h = msg.payload?.headers?.find((x) => x.name.toLowerCase() === name.toLowerCase()); + return h?.value || ""; +} +function gmailLink(messageId, account) { + // authuser pointe sur LE bon compte (sinon ouvre le compte par defaut). + return `https://mail.google.com/mail/u/?authuser=${encodeURIComponent(account)}#inbox/${messageId}`; +} + +// Decode un corps base64url Gmail en texte. +function decodeB64(data) { + return data ? Buffer.from(data, "base64url").toString("utf8") : ""; +} + +// Extrait le corps d'un message pour un mime donne (text/plain ou text/html). +// Recursif : gere les multipart imbriques. +function extractBody(payload, mime) { + if (!payload) return ""; + if (payload.mimeType === mime && payload.body?.data) return decodeB64(payload.body.data); + for (const part of payload.parts || []) { + const found = extractBody(part, mime); + if (found) return found; + } + return ""; +} + +function stripHtml(html) { + return html.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/[ \t]{2,}/g, " ").trim(); +} + +// Construit un enregistrement complet a partir d'un id de message. +// format=full => on recupere le CORPS COMPLET (texte), pas seulement le snippet. +async function buildRecord(accessToken, id, account, direction) { + const msg = await gmail(accessToken, `messages/${id}`, { format: "full" }); + const from = header(msg, "From"); + const subject = header(msg, "Subject"); + const plain = extractBody(msg.payload, "text/plain"); + const body = (plain || stripHtml(extractBody(msg.payload, "text/html")) || msg.snippet || "").slice(0, 50000); + return { + id, + account, + direction, // "inbound" (recu) | "outbound" (envoye) + from, + to: header(msg, "To"), + subject, + date: header(msg, "Date"), + snippet: msg.snippet || "", + body, // corps complet (texte), plafonne a 50k + link: gmailLink(id, account), + noise: isNoise(from), + signature: isSignature(from, subject), + collectedAt: today(), + }; +} + +async function collect() { + ensureDirs(); + const cfg = loadConfig(); + const account = cfg.account || "me"; + const accessToken = await getAccessToken(cfg); + const state = readJson(STATE_PATH, { lastCollect: null, knownIds: {} }); + + // Fenetre : depuis le dernier run (heures), sinon 7 derniers jours au 1er run. + let q = "newer_than:7d"; + if (state.lastCollect) { + const hours = Math.max(1, Math.ceil((Date.now() - state.lastCollect) / 3.6e6)); + q = `newer_than:${hours}h`; + } + + // Entrant (inbox) ET sortant (sent) : memes enregistrements complets, direction + // differente. Les sortants donnent l'historique des relances/envois. + // in:inbox / in:sent : sinon une requete `newer_than` nue ramasse AUSSI les + // mails envoyes et les etiquette a tort "inbound" (-> relances perdues). + const sources = [ + { q: `in:inbox ${q}`, max: 50, direction: "inbound" }, + { q: `in:sent ${q}`, max: 30, direction: "outbound" }, + ]; + + const collected = []; + for (const src of sources) { + const list = await gmail(accessToken, "messages", { q: src.q, maxResults: src.max }); + for (const ref of list.messages || []) { + if (state.knownIds[ref.id]) continue; + const rec = await buildRecord(accessToken, ref.id, account, src.direction); + collected.push(rec); + state.knownIds[ref.id] = { direction: rec.direction, from: rec.from, subject: rec.subject, collectedAt: rec.collectedAt }; + } + } + + state.lastCollect = Date.now(); + writeJson(STATE_PATH, state); + + const out = join(MSG_DIR, `${today()}.json`); + const existing = readJson(out, []); + writeJson(out, existing.concat(collected)); + console.log(`collect: ${collected.length} nouveau(x) mail(s) -> ${out}`); + return collected; +} + +function digest() { + ensureDirs(); + const day = today(); + const msgs = readJson(join(MSG_DIR, `${day}.json`), []); + const signatures = msgs.filter((m) => m.signature); + const triage = msgs.filter((m) => !m.signature && !m.noise); + const noise = msgs.filter((m) => m.noise); + + const line = (m) => `- **${m.from}** — ${m.subject || "(sans objet)"} \n ${m.snippet} \n [Ouvrir dans Gmail](${m.link})`; + const section = (title, arr) => `## ${title} (${arr.length})\n\n${arr.map(line).join("\n\n") || "_(rien)_"}\n`; + + const md = [ + `# Digest mail — ${day}`, + "", + section("Signatures en attente", signatures), + section("A traiter (vrais mails)", triage), + section("Bruit (filtre)", noise), + ].join("\n"); + + const out = join(DIGEST_DIR, `${day}.md`); + writeFileSync(out, md, "utf8"); + console.log(`digest: ${triage.length} a traiter, ${signatures.length} signature(s), ${noise.length} bruit -> ${out}`); +} + +// --- entree --- +const cmd = process.argv[2] || "run"; +try { + if (cmd === "auth") { + await runAuthFlow(loadConfig()); + console.log("auth: refresh_token enregistre."); + } else if (cmd === "collect") { + await collect(); + } else if (cmd === "digest") { + digest(); + } else if (cmd === "run") { + await collect(); + digest(); + } else { + console.error(`Commande inconnue: ${cmd} (auth|collect|digest|run)`); + process.exit(2); + } +} catch (e) { + console.error(`ERREUR: ${e.message}`); + process.exit(1); +} diff --git a/windows/email-collector/email-enrich.py b/windows/email-collector/email-enrich.py new file mode 100644 index 0000000..b6db087 --- /dev/null +++ b/windows/email-collector/email-enrich.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# email-enrich.py - Affiliation LLM des REPONSES clients -> 1 note par mail dans BrainCo. +# +# Couche LATENTE de la recette email-to-brain : le code filtre (deterministe) +# les reponses entrantes de clients, puis un agent (claude -p = login Claude +# Code, PAS de cle API) cree une note par mail dans BrainCo/mails/ et l'affilie +# au bon client via [[clients/]] (-> croise avec meetings dans le graphe). +# +# Lance par le nightly APRES le collecteur. Idempotent : ne recree pas une note +# dont l'id mail est deja present dans mails/. +# +# SRP : une fonction = une responsabilite ; main() orchestre. + +import json, sys, time, os, subprocess, shutil +from pathlib import Path + +BRAINCO = Path.home() / "Documents" / "BrainCo" +DATA = Path.home() / ".gbrain" / "email-collector" / "data" +LOGS = Path.home() / ".claude" / "logs" +MAILS_DIR = BRAINCO / "mails" +SKILL = Path.home() / ".claude" / "skills" / "mail-affiliator" / "SKILL.md" +DAY = time.strftime("%Y-%m-%d") + + +def log_err(msg): + LOGS.mkdir(parents=True, exist_ok=True) + (LOGS / "email-enrich-errors.log").open("a", encoding="utf-8").write( + f"{time.strftime('%Y-%m-%dT%H:%M:%S')} {msg}" + chr(10)) + + +def read(path): + # Lecture UTF-8 robuste (le cp1252 par defaut de Windows casse les accents). + return path.read_text(encoding="utf-8", errors="replace") + + +def no_mcp_config(): + """Fichier de config MCP VIDE pour les `claude -p` d'automatisation. + Sans ca, claude -p charge toute la flotte MCP (Playwright, n8n, Gmail...) + -> serveurs qui pendent en headless (le nightly se bloque) et fuient en + zombies (constate 2026-06-22). Avec --strict-mcp-config + ce fichier : 0 MCP.""" + p = Path.home() / ".gbrain" / "no-mcp-config.json" + if not p.exists(): + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text('{"mcpServers":{}}', encoding="utf-8") + return str(p) + + +def load_messages(): + """Charge les mails collectes du jour. None si le fichier n'existe pas.""" + f = DATA / "messages" / f"{DAY}.json" + if not f.exists(): + return None + try: + return json.loads(read(f)) + except (OSError, ValueError) as e: # ValueError couvre JSONDecodeError + log_err(f"lecture messages: {e}") + sys.exit(1) + + +def has_external_recipient(to): + """True si au moins un destinataire est hors @citymood.io (mail vers un externe).""" + return any("@" in part and "@citymood.io" not in part + for part in (to or "").lower().split(",")) + + +def is_client_mail(m): + """Mail d'une conversation client, dans LES DEUX sens : + - sortant : notre envoi/relance vers un destinataire externe ; + - entrant : reponse d'un externe (sujet Re:/Tr:). + Exclut le bruit et l'interne citymood<->citymood.""" + if m.get("noise"): + return False + if m.get("direction") == "outbound": + return has_external_recipient(m.get("to")) + # entrant : reponse d'un externe + if "@citymood.io" in (m.get("from") or "").lower(): + return False + subj = (m.get("subject") or "").strip().lower() + return subj.startswith("re:") or subj.startswith("re ") or subj.startswith("tr:") + + +def drop_already_noted(replies): + """Idempotence : retire les mails dont l'id figure deja dans une note mails/.""" + if not MAILS_DIR.exists(): + return replies + seen = set() + for f in MAILS_DIR.glob("*.md"): + txt = read(f) + seen.update(m["id"] for m in replies if m["id"] in txt) + return [m for m in replies if m["id"] not in seen] + + +def load_clients(): + """Liste 'slug : titre' des fiches clients, pour guider l'affiliation par le LLM.""" + cdir = BRAINCO / "clients" + if not cdir.exists(): + return [] + out = [] + for f in sorted(cdir.glob("*.md")): + if f.name.startswith("_") or f.name.lower() == "readme.md": + continue + title = f.stem + for line in read(f).splitlines(): + if line.startswith("# "): + title = line[2:].strip() + break + out.append(f"{f.stem} : {title}") + return out + + +def load_skill(): + """Charge les instructions de classement (le skill mail-affiliator). Fail loud si absent.""" + if not SKILL.exists(): + log_err(f"skill mail-affiliator introuvable: {SKILL}") + sys.exit(1) + txt = read(SKILL) + # retire le frontmatter YAML (--- ... ---) -> ne garder que le corps des instructions + if txt.startswith("---"): + end = txt.find(chr(10) + "---", 3) + if end != -1: + txt = txt[end + 4:] + return txt.strip() + + +def build_prompt(mails, clients): + """Assemble le prompt : instructions du skill (mail-affiliator) + donnees (clients + mails).""" + client_list = chr(10).join(clients) + mails_block = chr(10).join( + f'- id={m["id"]} | {m.get("direction", "?")} | date={m["date"]} | from={m["from"]} | to={m.get("to", "")} | subject={m["subject"]} | gmail={m["link"]}' + + chr(10) + f' contenu: {(m.get("body") or m.get("snippet") or "")[:3000]}' + for m in mails + ) + data = (f"CLIENTS EXISTANTS (slug : nom) :{chr(10)}{client_list}{chr(10)}{chr(10)}" + f"MAILS A RANGER (inbound=recu, outbound=envoye) :{chr(10)}{mails_block}") + return load_skill() + chr(10) + chr(10) + data + + +def run_agent(prompt): + """Appelle claude -p (cwd=BrainCo, acceptEdits) pour creer les notes. Exit 1 si echec reel.""" + claude_bin = (shutil.which("claude.cmd") or shutil.which("claude") + or os.path.expandvars(r"%APPDATA%\npm\claude.cmd")) + if not claude_bin or not Path(claude_bin).exists(): + log_err(f"claude introuvable: {claude_bin}") + sys.exit(1) + MAILS_DIR.mkdir(exist_ok=True) + try: + # Windows: prompt via STDIN (argv plafonne a ~32k), cmd /c pour le .cmd. + # --strict-mcp-config + config vide => AUCUN serveur MCP (pas de hang/zombie). + subprocess.run( + ["cmd", "/c", claude_bin, "-p", + "--strict-mcp-config", "--mcp-config", no_mcp_config(), + "--permission-mode", "acceptEdits"], + input=prompt, text=True, encoding="utf-8", + cwd=str(BRAINCO), timeout=600, check=False, + ) + except (OSError, subprocess.SubprocessError) as e: # TimeoutExpired inclus + log_err(str(e)) + sys.exit(1) + + +def main(): + msgs = load_messages() + if not msgs: + return + mails = drop_already_noted([m for m in msgs if is_client_mail(m)]) + if not mails: + return + run_agent(build_prompt(mails, load_clients())) + + +if __name__ == "__main__": + main() diff --git a/windows/email-collector/mail-candidates.mjs b/windows/email-collector/mail-candidates.mjs new file mode 100644 index 0000000..35765a2 --- /dev/null +++ b/windows/email-collector/mail-candidates.mjs @@ -0,0 +1,61 @@ +// mail-candidates.mjs — dedup + pre-filtre les mails collectes -> candidats "clients". +// Retire le bruit evident (flag noise du collecteur, invitations calendrier, notifs, +// domaines non-clients). Garde les mails susceptibles de concerner un client. +// Ecrit ~/.gbrain/email-collector/data/candidates.json et imprime un resume. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const MSG_DIR = path.join(os.homedir(), ".gbrain", "email-collector", "data", "messages"); +const OUT = path.join(os.homedir(), ".gbrain", "email-collector", "data", "candidates.json"); + +// domaines/expediteurs qui ne sont jamais un client +const HARD_NOISE = /e\.read\.ai|notifications\.lightfield|accounts\.google\.com|devinci\.fr|vivatech\.com|openclassrooms\.com|gptdao\.ai|ccsend|openai\.com|no-reply@|noreply@|registration@vivatech/i; +// sujets calendrier / notifs (pas de vrais echanges) +const CAL_SUBJECT = /^\s*(Invitation|Accept[eé]|Refus[eé]|Decline|Tentative|Peut-[eê]tre|Maybe)\b|🗓|Read Mee|^Prepare for your|Identity verification|Alerte de s[eé]curit[eé]/i; + +const emailsOf = (s) => (String(s || "").match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g) || []).map((e) => e.toLowerCase()); +const isExternal = (e) => !e.endsWith("@citymood.io") && !HARD_NOISE.test(e); + +let all = []; +for (const f of fs.readdirSync(MSG_DIR).filter((f) => f.endsWith(".json"))) { + const j = JSON.parse(fs.readFileSync(path.join(MSG_DIR, f), "utf8")); + const arr = Array.isArray(j) ? j : (j.messages || Object.values(j).find(Array.isArray) || []); + all.push(...arr); +} +// dedup par id (garde le record le plus complet = body le plus long) +const byId = new Map(); +for (const m of all) { + const id = m.id || (m.from + "|" + m.subject + "|" + m.date); + const prev = byId.get(id); + if (!prev || (m.body || "").length > (prev.body || "").length) byId.set(id, m); +} +const uniq = [...byId.values()]; + +let dropped = { noise: 0, calendar: 0, hardNoise: 0, internalOnly: 0 }; +const candidates = []; +for (const m of uniq) { + if (m.noise === true) { dropped.noise++; continue; } + if (CAL_SUBJECT.test(m.subject || "")) { dropped.calendar++; continue; } + if (HARD_NOISE.test(m.from || "")) { dropped.hardNoise++; continue; } + const parties = [...emailsOf(m.from), ...emailsOf(m.to)]; + const ext = parties.filter(isExternal); + const fromCity = emailsOf(m.from).some((e) => e.endsWith("@citymood.io")); + // garde si un tiers externe est implique, OU si outbound depuis citymood (l'agent jugera via le corps) + if (ext.length === 0 && !fromCity) { dropped.internalOnly++; continue; } + candidates.push({ + id: m.id, date: m.date, direction: m.direction, from: m.from, to: m.to, + subject: m.subject, link: m.link, body: String(m.body || m.snippet || "").slice(0, 1500), + }); +} + +fs.writeFileSync(OUT, JSON.stringify(candidates, null, 2), "utf8"); +console.log("Mails uniques:", uniq.length, "| candidats retenus:", candidates.length); +console.log("Ecartes:", JSON.stringify(dropped)); +console.log("\nCandidats (date | dir | from -> to | subject):"); +for (const c of candidates) { + const from = (c.from || "").replace(/.*<|>.*/g, "").trim().slice(0, 34); + const to = (c.to || "").replace(/.*<|>.*/g, "").trim().slice(0, 34); + console.log(" " + (c.date || "").slice(5, 16) + " [" + (c.direction || "?").slice(0, 3) + "] " + from + " -> " + to + " | " + (c.subject || "").slice(0, 42)); +} +console.log("\n-> ecrit:", OUT); diff --git a/windows/email-collector/set-google-oauth.ps1 b/windows/email-collector/set-google-oauth.ps1 new file mode 100644 index 0000000..0e4df93 --- /dev/null +++ b/windows/email-collector/set-google-oauth.ps1 @@ -0,0 +1,51 @@ +# set-google-oauth.ps1 - Enregistre les creds OAuth Google (Gmail readonly) du collecteur. +# +# Lance-le dans TON terminal (creds saisis ici, masques, jamais affiches ni +# envoyes a Claude) : +# powershell -ExecutionPolicy Bypass -File set-google-oauth.ps1 +# +# Ecrit dans ~/.gbrain/email-collector/config.json : +# { "client_id": "...", "client_secret": "...", "account": "mohamedfakhoury@citymood.io" } +# Le refresh token sera ajoute par le collecteur au 1er run (flux OAuth navigateur). + +$ErrorActionPreference = "Stop" + +$dir = Join-Path $env:USERPROFILE ".gbrain\email-collector" +if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } +$cfgPath = Join-Path $dir "config.json" + +$account = Read-Host "Ton adresse mail a connecter (defaut: mohamedfakhoury@citymood.io)" +if ([string]::IsNullOrWhiteSpace($account)) { $account = "mohamedfakhoury@citymood.io" } + +$idSec = Read-Host "Colle le Client ID (xxxx.apps.googleusercontent.com)" -AsSecureString +$clientId = [System.Net.NetworkCredential]::new("", $idSec).Password +if ([string]::IsNullOrWhiteSpace($clientId)) { Write-Host "Client ID vide -> abandon." -ForegroundColor Red; exit 1 } + +$secSec = Read-Host "Colle le Client Secret" -AsSecureString +$clientSecret = [System.Net.NetworkCredential]::new("", $secSec).Password +if ([string]::IsNullOrWhiteSpace($clientSecret)) { Write-Host "Client Secret vide -> abandon." -ForegroundColor Red; exit 1 } + +# Preserve un eventuel refresh_token deja present. +$existing = @{} +if (Test-Path $cfgPath) { + try { $existing = Get-Content $cfgPath -Raw | ConvertFrom-Json } catch { $existing = $null } + if ($null -eq $existing) { $existing = @{} } +} + +$cfg = [ordered]@{ + account = $account + client_id = $clientId + client_secret = $clientSecret +} +if ($existing.PSObject -and ($existing.PSObject.Properties.Name -contains 'refresh_token')) { + $cfg.refresh_token = $existing.refresh_token +} + +$json = ($cfg | ConvertTo-Json -Depth 10) +[System.IO.File]::WriteAllText($cfgPath, $json, [System.Text.UTF8Encoding]::new($false)) + +# ACL : lisible par toi seul. +icacls $cfgPath /inheritance:r /grant:r "$($env:USERNAME):(R,W)" | Out-Null + +Write-Host "OK: creds enregistres dans $cfgPath (ACL durcie)." -ForegroundColor Green +Write-Host "Les creds n'ont pas ete affiches. Reviens dans Claude et dis 'creds ok'." diff --git a/windows/gbrain-nightly.ps1 b/windows/gbrain-nightly.ps1 index 6829989..cc4583d 100644 --- a/windows/gbrain-nightly.ps1 +++ b/windows/gbrain-nightly.ps1 @@ -15,6 +15,12 @@ $Vault = "$env:USERPROFILE\Documents\Brain" $VaultCo = "$env:USERPROFILE\Documents\BrainCo" $GRepo = "$env:USERPROFILE\DEV\gbrain" $Log = "$env:USERPROFILE\.gbrain\nightly.log" +# Mail connector (collecteur metadata + enrichissement LLM -> notes mails/). +$EmailDir = "$env:USERPROFILE\brain-in-a-box\windows\email-collector" +$EmailCli = "$EmailDir\email-collector.mjs" +$EmailEnrich = "$EmailDir\email-enrich.py" +$PyExe = (Get-Command python.exe -ErrorAction SilentlyContinue | Where-Object { $_.Source -notmatch 'WindowsApps' } | Select-Object -First 1).Source +if (-not $PyExe) { $PyExe = "$env:LOCALAPPDATA\Programs\Python\Launcher\py.exe" } # All logging goes through one UTF-8 sink so the log stays readable. PowerShell # 5.1's `*>>` redirect writes UTF-16 (mixes encodings), so we pipe to Add-Content. @@ -52,12 +58,32 @@ if (Test-Path "$Vault\.git") { } Pop-Location } -Gbrain sync --repo $Vault --no-pull +# IMPORTANT: pin BOTH --source AND --repo. Syncing with a bare --repo (no +# --source) makes gbrain write that path onto the wrong source row -> it +# repointed 'company' at the personal vault (corruption seen 2026-06-22). +# Pinning each source to its repo is self-healing: a wrong path is re-corrected +# every night. +Gbrain sync --source default --repo $Vault --no-pull -# 2. COMPANY vault (team mode): pull teammates' contributions + sync the 'company' source. +# 2. COMPANY vault (team mode): pull + collecte/enrichissement mail + sync. if (Test-Path "$VaultCo\.git") { Run git -C $VaultCo pull --ff-only - Gbrain sync --source company + + # 2a. Mail : collecte (metadata, lecture seule) puis enrichissement LLM + # (reponses clients -> 1 note par mail dans BrainCo/mails/, affiliees au client). + if (Test-Path $EmailCli) { Run $Bun $EmailCli run } + if ((Test-Path $EmailEnrich) -and $PyExe) { Run $PyExe $EmailEnrich } + + # 2b. Commit LOCAL des notes mail (le push reste manuel/volontaire). + Push-Location $VaultCo + if (git status --porcelain) { + Run git add -A + Run git -c user.email="brain@local" -c user.name="brain" commit -q -m "nightly mails $(Get-Date -Format 'yyyy-MM-dd')" + Log "[git] company mail notes committed (local)" + } + Pop-Location + + Gbrain sync --source company --repo $VaultCo --no-pull Log "[sync] company source" } diff --git a/windows/skills/mail-affiliator/SKILL.md b/windows/skills/mail-affiliator/SKILL.md new file mode 100644 index 0000000..f1d9e92 --- /dev/null +++ b/windows/skills/mail-affiliator/SKILL.md @@ -0,0 +1,44 @@ +--- +name: mail-affiliator +description: Classeur de mails client (entrants ET sortants). Recoit des mails + la liste des clients, et cree 1 note par mail dans BrainCo/mails/ affiliee au bon client via [[clients/]]. Utilise par email-enrich.py (nightly) et invocable a la main. +--- + +# Mail Affiliator — classer les mails client dans le brain + +Tu ranges des MAILS faisant partie d'une conversation client dans le brain d'equipe CityMood (dossier courant = BrainCo). Chaque mail a une **direction** : +- `inbound` = recu (un client nous repond) ; +- `outbound` = envoye (notre relance / notre prise de contact vers un client). + +Le but : garder l'**historique du fil** par client (relances incluses), pas seulement les reponses. + +Pour CHAQUE mail fourni plus bas, cree UN fichier `mails/-.md` (date du mail ; slug = correspondant + sujet, en minuscule-tirets) avec EXACTEMENT ce format : + +``` +--- +type: mail +direction: # reprends la direction fournie pour ce mail +date: "" +from: "" +to: "" +subject: "" +account: clients/.md +gmail: "" +mail-id: "" +--- + +# () + +<2-3 phrases factuelles: ce que dit/demande le mail, d'apres son CONTENU complet (decision, demande, date, montant...). Reste factuel, n'invente rien.> + +## Client +- [[clients/]] +``` + +## Regles strictes +- **Affilie au BON client** de la liste (indice fort = **domaine email** du correspondant externe / **nom de ville** / **contexte du sujet**). Pour un `outbound`, le correspondant = le destinataire `to` ; pour un `inbound`, c'est l'expediteur `from`. Lien TOUJOURS en minuscule : `[[clients/]]`. +- Si AUCUN client ne correspond clairement : `account: clients/_a-trier.md` et lien `[[clients/_a-trier]]`. +- Le titre nomme le **correspondant externe** (pas nous), et precise le client. +- N'invente rien (aucun fait absent du snippet). UNE note par mail. N'ecris QUE dans le dossier `mails/`. +- Inclus toujours le champ `mail-id` (sert a l'idempotence — un mail deja note n'est pas recree). + +Les **CLIENTS EXISTANTS** (slug : nom) et les **MAILS A RANGER** (avec leur direction) sont fournis ci-dessous. From 9b15240ce628cd5cf7ecfe8a7125421e63e9e652 Mon Sep 17 00:00:00 2001 From: HamoudeFakhoury01 Date: Fri, 26 Jun 2026 12:30:02 +0200 Subject: [PATCH 5/5] windows: hook session-start (auto-contexte) + fix daily-reflection + install - hooks/session-start-context.py : injecte soul/memory/lessons/code-rules a chaque session (ferme la boucle d'apprentissage) - hooks/daily-reflection.py : fix WinError 206 (prompt via stdin) + --strict-mcp-config (anti-zombies) - set-anthropic-key.ps1, INSTALL.md Co-Authored-By: Claude Opus 4.8 (1M context) --- windows/INSTALL.md | 99 ++++++++++++++++++++++++++ windows/hooks/daily-reflection.py | 20 +++++- windows/hooks/session-start-context.py | 65 +++++++++++++++++ windows/set-anthropic-key.ps1 | 30 ++++++++ 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 windows/INSTALL.md create mode 100644 windows/hooks/session-start-context.py create mode 100644 windows/set-anthropic-key.ps1 diff --git a/windows/INSTALL.md b/windows/INSTALL.md new file mode 100644 index 0000000..f98c3a7 --- /dev/null +++ b/windows/INSTALL.md @@ -0,0 +1,99 @@ +# brain-in-a-box — installation (Windows) + +Cerveau d'équipe : un assistant Claude Code qui a déjà toute la mémoire chargée +(projets, décisions, style) + accès au **BrainCo** partagé + des automatisations +qui tournent chaque nuit. Un seul script fait tout : `install.ps1`. + +L'installeur est **idempotent** (relançable sans risque) et **non-destructif** +sur tes données (vault, clés, init). Tu peux le rejouer quand tu veux. + +--- + +## 1. Prérequis (à installer AVANT) + +| Outil | Pourquoi | Où | +|-------|----------|-----| +| **Git for Windows** | clone gbrain + BrainCo | https://git-scm.com/download/win | +| **Python 3** | hooks (capture des corrections, reflection) | https://www.python.org/downloads/ — **PAS** le stub Microsoft Store | +| **Claude Code** (CLI `claude`) | l'assistant + la reflection de 23h | https://claude.com/claude-code | +| **bun** | runtime gbrain | auto-installé par le script si absent | +| **Clé ZeroEntropy** (gratuite) | embeddings (recherche sémantique) | https://dashboard.zeroentropy.dev — peut être différée | + +> ⚠️ Python : si tu tapes `python` et que ça ouvre le Microsoft Store, c'est le +> stub. Installe la vraie version depuis python.org, coche **"Add to PATH"**. + +> 🔑 Accès BrainCo : le repo `brain-company` est **privé**. Avant de lancer, +> assure-toi que ton compte GitHub a accès au repo et que `git` est authentifié +> (token ou SSH). Sinon le clone échoue et ton BrainCo sera vide. + +--- + +## 2. Installation + +```powershell +# 1. Cloner ce repo +git clone https://github.com/BrainInBox/brain-in-a-box.git +cd brain-in-a-box\windows + +# 2. Lancer l'installeur EN MODE ÉQUIPE (récupère le BrainCo partagé) +powershell -ExecutionPolicy Bypass -File install.ps1 -Company https://github.com/BrainInBox/brain-company.git +``` + +> Remplace l'URL `-Company` par celle du BrainCo réel (demande-la à Mohamed si tu +> ne l'as pas). Sans `-Company`, tu n'auras que ton cerveau perso, **pas** le +> partagé. + +Pendant l'install, on te demandera ta **clé ZeroEntropy** (`ze_...`). Colle-la, +ou appuie sur Entrée pour la mettre plus tard (voir §5). + +--- + +## 3. Ce que l'installeur met en place + +- **gbrain** cloné + dépendances (`~\DEV\gbrain`), commandes `gbq` / `gbrain` sur le PATH. +- **Vault perso** (`~\Documents\Brain`) — jamais écrasé si déjà présent. +- **BrainCo** (`~\Documents\BrainCo`) cloné, ajouté comme source gbrain et **fédéré** → interrogeable via `gbq query`. +- **Hooks Claude Code** (capture des corrections, logs/index/recap de session) enregistrés dans `~\.claude\settings.json`. +- **2 tâches planifiées** (Planificateur Windows), lancées via `wscript` (pas de fenêtre, survit à l'écran verrouillé) : + +| Tâche | Quand | Quoi | +|-------|-------|------| +| `brain-gbrain-nightly` | **04:00** | self-update gbrain, migrations, ré-index + embed du vault | +| `brain-reflection` | **12:00 et 23:00** | synthèse / reflection quotidienne | + +--- + +## 4. Vérifier que ça marche + +```powershell +gbq query "test" # la recherche répond +Get-ScheduledTask brain-* # les 2 tâches sont là (State = Ready) +``` + +Puis l'**onboarding** (15 min, personnalise l'assistant) : + +```powershell +cd $HOME\Documents\Brain +claude +# colle : "Lis onboarding/ONBOARDING.md et lance-le pour me personnaliser." +``` + +--- + +## 5. Si tu as différé la clé ZeroEntropy + +```powershell +cd brain-in-a-box\windows +powershell -ExecutionPolicy Bypass -File set-ze-key.ps1 +gbq embed --stale +``` + +--- + +## 6. Dépannage + +- **`gbq` non reconnu** → ouvre un **nouveau** terminal (le PATH n'est mis à jour que pour les nouveaux shells). +- **BrainCo vide après l'install** → ton `git` n'avait pas accès au repo privé. Authentifie-toi, puis relance `install.ps1 -Company ` (idempotent). +- **`claude` introuvable** (warning à l'install) → installe Claude Code, puis relance l'installeur. +- **Les tâches ne se lancent pas** → `Get-ScheduledTask brain-* | Get-ScheduledTaskInfo` pour voir le dernier résultat. +- Pour tout relancer proprement : ré-exécute `install.ps1` — il ne touche pas à tes données. diff --git a/windows/hooks/daily-reflection.py b/windows/hooks/daily-reflection.py index 4f97861..2934fb4 100644 --- a/windows/hooks/daily-reflection.py +++ b/windows/hooks/daily-reflection.py @@ -54,17 +54,31 @@ {big} """ -claude_bin = shutil.which("claude") or os.path.expandvars(r"%APPDATA%\npm\claude.cmd") +claude_bin = shutil.which("claude.cmd") or shutil.which("claude") or os.path.expandvars(r"%APPDATA%\npm\claude.cmd") if not claude_bin or not Path(claude_bin).exists(): (LOGS / "daily-reflection-errors.log").open("a", encoding="utf-8").write(f"{time.strftime('%Y-%m-%dT%H:%M:%S')} claude introuvable: {claude_bin}" + chr(10)) sys.exit(0) +# claude -p en automatisation : config MCP VIDE (--strict-mcp-config) sinon il +# lance toute la flotte MCP (Playwright, n8n...) -> hang en headless + zombies (2026-06-22). +no_mcp = Path.home() / ".gbrain" / "no-mcp-config.json" +if not no_mcp.exists(): + no_mcp.parent.mkdir(parents=True, exist_ok=True) + no_mcp.write_text('{"mcpServers":{}}', encoding="utf-8") try: + # Windows: le prompt (jusqu'a 200k chars de transcripts) passe par STDIN, + # pas par argv -- la ligne de commande Windows plafonne a ~32k chars + # (WinError 206). cmd /c pour executer le .cmd de facon fiable. subprocess.run( - [claude_bin, "-p", "--permission-mode", "acceptEdits", prompt], + ["cmd", "/c", claude_bin, "-p", + "--strict-mcp-config", "--mcp-config", str(no_mcp), + "--permission-mode", "acceptEdits"], + input=prompt, + text=True, + encoding="utf-8", cwd=str(BRAIN), timeout=600, check=False, ) -except Exception as e: +except (OSError, subprocess.SubprocessError) as e: (LOGS / "daily-reflection-errors.log").open("a", encoding="utf-8").write(f"{time.strftime('%Y-%m-%dT%H:%M:%S')} {e}" + chr(10)) sys.exit(0) diff --git a/windows/hooks/session-start-context.py b/windows/hooks/session-start-context.py new file mode 100644 index 0000000..f1e3c83 --- /dev/null +++ b/windows/hooks/session-start-context.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# SessionStart hook - charge le Brain PERSO dans CHAQUE session Claude Code, +# quel que soit le dossier de travail. +# +# Ferme la boucle "le brain ecrit mais ne se relit jamais" : l'original +# brain-in-a-box comptait sur une INSTRUCTION dans Brain/CLAUDE.md (chargee +# seulement si on lance claude depuis le vault). Ce hook, lui, s'execute partout +# (settings utilisateur global) et injecte le contexte via additionalContext. +# +# Charge : soul (identite/style) + memory (contexte recent) + lessons +# (corrections) + regles de code. PAS les journaux (trop gros, cherchables via gbq). + +import json, sys +from pathlib import Path + +PROFILE = Path.home() / "Documents" / "Brain" / "Profile" + + +def read(path): + try: + return path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + + +def file_section(title, filename, tail=None): + """Un bloc de contexte depuis Profile/. tail=N -> N dernieres lignes.""" + txt = read(PROFILE / filename) + if not txt: + return None + if tail: + txt = chr(10).join(txt.splitlines()[-tail:]) + return f"### {title}" + chr(10) + txt + + +def code_rules_section(): + """code-quality.md condense : titres `##` + lignes **Regle** (sans le detail).""" + txt = read(PROFILE / "code-quality.md") + if not txt: + return None + keep = [s for s in (l.strip() for l in txt.splitlines()) + if s.startswith("## ") or s.startswith("**Regle**") or s.startswith("**Règle**")] + if not keep: + return None + return "### Regles de qualite de code (detail: Profile/code-quality.md)" + chr(10) + chr(10).join(keep) + + +def build_context(): + blocks = [ + file_section("Qui je suis / valeurs / style - Profile/soul.md", "soul.md"), + file_section("Memoire : decisions actives + contexte recent - Profile/memory.md", "memory.md", tail=40), + file_section("Lecons passees (corrections a NE JAMAIS repeter) - Profile/lessons.md", "lessons.md", tail=40), + code_rules_section(), + ] + blocks = [b for b in blocks if b] + if not blocks: + return "" + head = "BRAIN - contexte perso auto-charge au demarrage. A prendre en compte AVANT d'agir." + return head + chr(10) + chr(10) + (chr(10) + chr(10)).join(blocks) + + +ctx = build_context() +if ctx: + print(json.dumps({"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": ctx}})) +sys.exit(0) diff --git a/windows/set-anthropic-key.ps1 b/windows/set-anthropic-key.ps1 new file mode 100644 index 0000000..7127cc3 --- /dev/null +++ b/windows/set-anthropic-key.ps1 @@ -0,0 +1,30 @@ +# set-anthropic-key.ps1 - Configure la cle Anthropic pour la synthese gbrain (Windows). +# +# Sert UNIQUEMENT a `gbq query` (reponses redigees a partir de ta memoire). +# Le journal et la recherche n'en ont PAS besoin. +# +# Lance-le dans TON terminal (la cle est saisie ici, masquee, jamais affichee +# ni envoyee a Claude) : +# powershell -ExecutionPolicy Bypass -File set-anthropic-key.ps1 +# +# Il ecrit la cle dans ~/.gbrain/config.json (champ anthropic_api_key). + +$ErrorActionPreference = "Stop" +$cfgPath = Join-Path $env:USERPROFILE ".gbrain\config.json" +if (-not (Test-Path $cfgPath)) { Write-Host "config.json introuvable ($cfgPath). gbrain init d'abord." -ForegroundColor Red; exit 1 } + +$sec = Read-Host "Colle ta cle Anthropic (sk-ant-...)" -AsSecureString +$key = [System.Net.NetworkCredential]::new("", $sec).Password +if ([string]::IsNullOrWhiteSpace($key)) { Write-Host "Cle vide -> abandon." -ForegroundColor Red; exit 1 } + +$cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json +$cfg | Add-Member -NotePropertyName anthropic_api_key -NotePropertyValue $key -Force + +$json = $cfg | ConvertTo-Json -Depth 20 +[System.IO.File]::WriteAllText($cfgPath, $json, [System.Text.UTF8Encoding]::new($false)) + +# Durcissement ACL : lisible par toi seul. +icacls $cfgPath /inheritance:r /grant:r "$($env:USERNAME):(R,W)" | Out-Null + +Write-Host "OK: cle Anthropic enregistree (synthese gbq query activee)." -ForegroundColor Green +Write-Host "La cle n'a pas ete affichee. Reviens dans Claude et dis 'cle ok'."