From d5f91adbe826d4107990ce7dccf56d1840617890 Mon Sep 17 00:00:00 2001 From: Chad El Kurdi <162061136+Chad-Mufasax@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:18:21 +0200 Subject: [PATCH 1/3] docs(proposal): Windows port plan (solo-first, PGLite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approach-before-code proposal per CONTRIBUTING. Maps the macOS-bound layers (launchd, brew, zsh gbq, bash installer, /tmp hook paths), flags the gating unknown (gbrain+PGLite on Windows = Phase 0 spike), and phases the work: cross-platform hooks → scheduler abstraction (Task Scheduler) → cross-platform gbq → install.ps1 → CI → docs. Solo+PGLite only for v1; native PowerShell, not WSL. --- docs/proposals/windows-port.md | 107 +++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/proposals/windows-port.md diff --git a/docs/proposals/windows-port.md b/docs/proposals/windows-port.md new file mode 100644 index 0000000..75059da --- /dev/null +++ b/docs/proposals/windows-port.md @@ -0,0 +1,107 @@ +# Proposal — Windows port (solo first) + +Status: **proposal** (per CONTRIBUTING, approach before code). Scope: make +brain-in-a-box installable and runnable for a **solo user on Windows**. Team mode +(`setup-company.sh`) is out of scope for v1. + +## Why it doesn't run on Windows today + +Everything macOS-specific, by layer: + +| Layer | macOS today | Windows needs | +|---|---|---| +| Installer | `install.sh` (bash); `uname = Darwin \|\| die`; `brew` | `install.ps1` (PowerShell); OS detect; `winget` | +| Scheduler | `launchd` plists (nightly 04:00 + reflection 12:00/23:00) | **Task Scheduler** (`Register-ScheduledTask`) | +| Safe wrapper | `gbq` (zsh) | cross-platform wrapper (Node or Python) | +| Nightly | `gbrain-nightly.sh` (bash) | PowerShell, or rewrite logic in Python | +| Hooks (Python) | hardcoded `/tmp/...` locks, `__HOME__/.local/bin/claude` | `tempfile.gettempdir()`, resolve `claude` on PATH | +| Obsidian | `brew install --cask obsidian` | `winget install Obsidian.Obsidian` | + +## The gating dependency — gbrain on Windows (researched 2026-06) + +brain-in-a-box sits on **gbrain** (separate project, bun-based). The *foundation* +is Windows-ready: **bun's Windows support went stable in bun 1.2** (Jan 2026; ARM64 +in 1.3.10), and **PGLite is WASM** so it runs wherever bun/node runs. So the +building blocks are fine. + +**gbrain itself is the blocker.** It is not CI-tested on Windows (CI = macOS + +Ubuntu only), and its issue tracker has open/known Windows bugs — crucially **on +the solo/PGLite path we'd target**: + +- **#1549 — PGLite on Windows 10: the `pgvector` extension is missing from the WASM + binary** → `search`/`think` don't work. This is the dealbreaker: no semantic + search = no brain. +- **#1605 — Supabase-pooler migration `getaddrinfo ENOTFOUND`** on Windows (team + path, less relevant to solo). +- **#1554 — cross-platform node shim** (PR): the POSIX-shell postinstall didn't run + on Windows. +- **#1665 — "critical fix wave"** merged Windows migration-spawn fixes. + +So fixes are actively landing upstream, but as of this research **a solo Windows +user cannot get working semantic search** until #1549 (PGLite pgvector on Windows) +is resolved. **Our port is gated on that.** Building `install.ps1` before gbrain's +PGLite works on Windows would ship a broken brain. + +## Plan + +**Phase 0 — Spike (gating).** On a real Windows box: install bun (≥1.3.10), +`gbrain init --pglite`, embed, `gbrain sync`, and crucially **`gbrain query`** — +this is the check for issue #1549 (PGLite pgvector on Windows). If `query` returns +ranked results → green, proceed. If pgvector is still missing → **stop**; the port +is blocked upstream. Track gbrain #1549 / #1554 / #1665. *Decision point.* + +**Phase 1 — Cross-platform hooks (also benefits macOS).** Replace `/tmp` with +`tempfile.gettempdir()`; resolve the Claude binary via `shutil.which("claude")` +with the `__HOME__` path as fallback; audit for any other POSIX assumptions. Pure +Python, low risk. Keep `test-hooks.sh` green; add a PowerShell sibling. + +**Phase 2 — Scheduler abstraction.** A thin installer step that registers the two +jobs with the OS scheduler: launchd on darwin (today), **Task Scheduler** on +Windows (nightly 04:00 + reflection 12:00/23:00, running `python daily-reflection.py` +and the nightly). + +**Phase 3 — `gbq` cross-platform.** Port the zsh wrapper (force-kill on PGLite +read hangs, clean wait on writes, stale-lock sweep) to a small **Node or Python** +script that runs everywhere. Single source of truth, drop the zsh version or keep +it as a thin shim. + +**Phase 4 — `install.ps1`.** PowerShell mirror of `install.sh`: copy vault +skeleton, install hooks (`__HOME__` → `$HOME` replace), install/clone gbrain, +Obsidian via `winget`, register scheduled tasks, merge global `CLAUDE.md`. +Non-destructive, same as the bash installer. Replace the `uname` guard with OS +detection that routes to the right scheduler. + +**Phase 5 — Tests + CI.** A cross-platform `test-hooks` (PowerShell or a Python +runner) and a `windows-latest` entry in the CI matrix so it doesn't regress. + +**Phase 6 — Docs.** README + CONTRIBUTING: drop "macOS only", add Windows setup. + +## Scope decisions + +- **Solo + PGLite only** for v1. Defer team mode and the Supabase engine (that's + where the known Windows bug lives). +- **WSL is not the target** — native PowerShell, so a non-technical Windows user + isn't asked to install a Linux subsystem. (WSL would "work" trivially but isn't + a real Windows port.) + +## Risks + +- **gbrain-on-Windows** (gating, Phase 0). +- **CRLF line endings** breaking the Python/JSON hooks — enforce LF via + `.gitattributes`. +- **Task Scheduler** quirks (working dir, env, user session) vs launchd's model. +- Path separators / `%USERPROFILE%` vs `~` — handled by `pathlib`/`os.path` if we + remove the remaining hardcoded POSIX paths (Phase 1). + +## Recommendation + +**Don't build the Windows port yet** — it's gated on gbrain fixing PGLite/pgvector +on Windows (#1549). The right move now: + +1. **Phase 1 (cross-platform hooks) regardless** — pure Python cleanup, makes the + hooks better on macOS too, and is the only part not blocked by gbrain. +2. **Watch gbrain #1549** (semantic search on Windows PGLite). The instant it's + fixed, run the Phase 0 spike to confirm, then do Phases 2–6. +3. Until then, point Windows users at the standalone **git-only journal** tool + (`devjournal`) for the part that doesn't need gbrain — they get the journal, + just not the searchable brain. From 59319690989c9c44d03b76fb825c54387548f892 Mon Sep 17 00:00:00 2001 From: Chad El Kurdi <162061136+Chad-Mufasax@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:42:33 +0200 Subject: [PATCH 2/3] engine: cross-platform hooks (Windows-ready, Phase 1) - /tmp -> tempfile.gettempdir() (session-logger/indexer/recap, daily-reflection) - claude binary via CLAUDE_BIN > ~/.local/bin/claude > shutil.which (no __HOME__ bake-in) - BRAIN/home resolved at runtime with Path.home() - .gitattributes: enforce LF so CRLF can't break hooks on Windows - test-hooks.sh: isolate TMPDIR in the throwaway HOME (locks no longer leak) - windows-port.md: mark Phase 1 done + note server-side-search sidesteps gbrain gating Pure Python, benefits macOS too, not gated by gbrain (#1549). 15/15 test-hooks pass. --- .gitattributes | 10 ++++++++++ docs/proposals/windows-port.md | 11 +++++++++++ engine/hooks/daily-reflection.py | 12 +++++++++--- engine/hooks/session-indexer.py | 4 ++-- engine/hooks/session-logger.py | 4 ++-- engine/hooks/session-recap.py | 10 +++++----- test-hooks.sh | 5 +++-- 7 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..be1ad0e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Keep LF in the repo on every platform — Python/JSON hooks and shell scripts +# break if checked out with CRLF on Windows. +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.pdf binary +*.zip binary diff --git a/docs/proposals/windows-port.md b/docs/proposals/windows-port.md index 75059da..f5def22 100644 --- a/docs/proposals/windows-port.md +++ b/docs/proposals/windows-port.md @@ -4,6 +4,17 @@ Status: **proposal** (per CONTRIBUTING, approach before code). Scope: make brain-in-a-box installable and runnable for a **solo user on Windows**. Team mode (`setup-company.sh`) is out of scope for v1. +> **Update 2026-06 — Phase 1 done.** The engine hooks are now cross-platform +> (pure Python): `/tmp` → `tempfile.gettempdir()`, `claude` resolved via +> `shutil.which()`, home resolved at runtime with `Path.home()` (no `__HOME__` +> bake-in), and `.gitattributes` enforces LF so CRLF can't break the hooks on +> Windows. This benefits macOS too and is **not gated by gbrain**. Remaining: +> `install.ps1`, the scheduler step, and the search path (Phases 2–6 below). +> **Field note:** a downstream deployment ran fully native on Windows by putting +> **semantic search server-side** (a small search API the client hits over HTTP) +> instead of embedded gbrain — which sidesteps the #1549 PGLite/pgvector gating +> entirely. Worth considering as the Windows search path here. + ## Why it doesn't run on Windows today Everything macOS-specific, by layer: diff --git a/engine/hooks/daily-reflection.py b/engine/hooks/daily-reflection.py index e35bc1c..3df269e 100755 --- a/engine/hooks/daily-reflection.py +++ b/engine/hooks/daily-reflection.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import json, sys, os, time, subprocess +import json, sys, os, time, subprocess, tempfile, shutil from pathlib import Path BRAIN = Path.home() / "Documents" / "Brain" @@ -7,7 +7,7 @@ DAY = time.strftime("%Y-%m-%d") slot = "midday" if int(time.strftime("%H")) < 18 else "evening" -lock = Path(f"/tmp/brain-daily-reflection-{DAY}-{slot}.lock") +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())) @@ -55,7 +55,13 @@ """ try: - claude_bin = "__HOME__/.local/bin/claude" + home_claude = Path.home() / ".local" / "bin" / "claude" + claude_bin = ( + os.environ.get("CLAUDE_BIN") + or (str(home_claude) if home_claude.exists() else None) + or shutil.which("claude") + or str(home_claude) + ) subprocess.run( [claude_bin, "-p", "--permission-mode", "acceptEdits", prompt], cwd=str(BRAIN), diff --git a/engine/hooks/session-indexer.py b/engine/hooks/session-indexer.py index f928e90..86bb753 100755 --- a/engine/hooks/session-indexer.py +++ b/engine/hooks/session-indexer.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import json, sys, os, time +import json, sys, os, time, tempfile from pathlib import Path try: @@ -8,7 +8,7 @@ sys.exit(0) sid = payload.get("session_id") or payload.get("sessionId") or "unknown" -lock_dir = Path("/tmp/claude-session-locks") +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(): diff --git a/engine/hooks/session-logger.py b/engine/hooks/session-logger.py index e8d0f03..a37e333 100755 --- a/engine/hooks/session-logger.py +++ b/engine/hooks/session-logger.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import json, sys, os, time +import json, sys, os, time, tempfile from pathlib import Path try: @@ -8,7 +8,7 @@ sys.exit(0) sid = payload.get("session_id") or payload.get("sessionId") or "unknown" -lock_dir = Path("/tmp/claude-session-locks") +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(): diff --git a/engine/hooks/session-recap.py b/engine/hooks/session-recap.py index 9415b2c..1c9773c 100755 --- a/engine/hooks/session-recap.py +++ b/engine/hooks/session-recap.py @@ -5,14 +5,14 @@ Idempotent (skips if session_id already present). No LLM call. """ -import json, sys, os, re, time +import json, sys, os, re, time, tempfile from pathlib import Path from collections import Counter from datetime import datetime -BRAIN = Path("__HOME__/Documents/Brain") +BRAIN = Path.home() / "Documents" / "Brain" JOURNAL_DIR = BRAIN / "Journal" -LOCK_DIR = Path("/tmp/claude-session-locks") +LOCK_DIR = Path(tempfile.gettempdir()) / "claude-session-locks" SIGNAL_PATTERNS = [ # SSH / infra @@ -253,8 +253,8 @@ def main(): sys.exit(0) lock.write_text(str(time.time())) - # Skip smoke tests / /tmp cwd - if cwd.startswith("/tmp") or cwd.startswith("/private/tmp"): + # Skip smoke tests / temp cwd (cross-platform) + if cwd.startswith(tempfile.gettempdir()) or cwd.startswith("/tmp") or cwd.startswith("/private/tmp"): sys.exit(0) stats = parse_transcript(transcript_path) diff --git a/test-hooks.sh b/test-hooks.sh index a5f0401..de0b687 100755 --- a/test-hooks.sh +++ b/test-hooks.sh @@ -7,10 +7,11 @@ set -u REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" H="$(mktemp -d -t biab-hooktest)" +export TMPDIR="$H/tmp" # isolate hook locks (tempfile.gettempdir()) in the throwaway HOME DAY=$(date +%Y-%m-%d) SID="biabtest$(date +%s)" # unique per run → no stale-lock collisions HB="$H/.claude/hooks/brain" -mkdir -p "$HB" "$H/.claude/logs" "$H/Documents/Brain/Journal" "$H/Documents/Brain/Profile" "$H/.local/bin" +mkdir -p "$HB" "$H/.claude/logs" "$H/Documents/Brain/Journal" "$H/Documents/Brain/Profile" "$H/.local/bin" "$H/tmp" # Install the repo's hooks into the temp HOME exactly like install.sh does. for f in "$REPO"/engine/hooks/*.py; do @@ -57,7 +58,7 @@ printf '%s' "$*" > "$HOME/.claude/logs/claude-stub-prompt.txt" exit 0 STUB chmod +x "$H/.local/bin/claude" -rm -f /tmp/brain-daily-reflection-$DAY-*.lock 2>/dev/null # clear debounce lock from prior runs +rm -f "$TMPDIR"/brain-daily-reflection-$DAY-*.lock 2>/dev/null # clear debounce lock from prior runs HOME="$H" python3 "$HB/daily-reflection.py" 2>/dev/null [ -f "$H/.claude/logs/claude-stub-called.txt" ] && ok "cron flow ran (read logs → built prompt → invoked claude)" || no "cron flow did not run" grep -q "journal summary" "$H/.claude/logs/claude-stub-prompt.txt" 2>/dev/null && ok "prompt is correct" || no "prompt wrong" From af555eae94bf942f109aaeaa4c750433c201ef9e Mon Sep 17 00:00:00 2001 From: Chad El Kurdi <162061136+Chad-Mufasax@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:58:05 +0200 Subject: [PATCH 3/3] =?UTF-8?q?windows:=20native=20install=20(Phase=204)?= =?UTF-8?q?=20+=20local=20BM25=20search=20=E2=80=94=20un-gates=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine/search/brain_search.py: pure-Python BM25 local search (index/query/health), no bun/pgvector/model download. Cross-platform, offline. Replaces gbrain search on Windows where PGLite/pgvector is broken (#1549). Tested on a real vault (1922 chunks). - install.ps1: Windows-native installer mirroring install.sh — vault skeleton, hooks, brain_search index, gbq.cmd shim (so 'gbq query' works unchanged), Task Scheduler (reindex 04:00 + reflection 12:00/23:00), CLAUDE.md merge, git init. ASCII-only (PS 5.1). - windows-port.md: documents the un-gated Windows path. Note: install.ps1 needs a real-Windows smoke test; BM25 = keyword (embeddings optional). --- docs/proposals/windows-port.md | 19 ++-- engine/search/brain_search.py | 146 ++++++++++++++++++++++++++++++ install.ps1 | 157 +++++++++++++++++++++++++++++++++ 3 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 engine/search/brain_search.py create mode 100644 install.ps1 diff --git a/docs/proposals/windows-port.md b/docs/proposals/windows-port.md index f5def22..ff8f132 100644 --- a/docs/proposals/windows-port.md +++ b/docs/proposals/windows-port.md @@ -8,12 +8,19 @@ brain-in-a-box installable and runnable for a **solo user on Windows**. Team mod > (pure Python): `/tmp` → `tempfile.gettempdir()`, `claude` resolved via > `shutil.which()`, home resolved at runtime with `Path.home()` (no `__HOME__` > bake-in), and `.gitattributes` enforces LF so CRLF can't break the hooks on -> Windows. This benefits macOS too and is **not gated by gbrain**. Remaining: -> `install.ps1`, the scheduler step, and the search path (Phases 2–6 below). -> **Field note:** a downstream deployment ran fully native on Windows by putting -> **semantic search server-side** (a small search API the client hits over HTTP) -> instead of embedded gbrain — which sidesteps the #1549 PGLite/pgvector gating -> entirely. Worth considering as the Windows search path here. +> Windows. This benefits macOS too and is **not gated by gbrain**. +> +> **Update 2026-06 — Windows path now implemented (search un-gated).** Rather +> than wait on gbrain #1549, the search is replaced on Windows by +> `engine/search/brain_search.py` — a **local BM25 search in pure Python** (no +> bun, no pgvector, no model download, offline). `install.ps1` wires it up: copies +> the vault skeleton, installs the (now cross-platform) hooks, builds the index, +> drops a `gbq.cmd` shim so the existing `gbq query "..."` interface still works, +> registers Task Scheduler jobs (reindex 04:00 + reflection 12:00/23:00), and +> merges the global `CLAUDE.md`. Tested: `brain_search` indexes + queries a real +> vault on macOS; `test-hooks.sh` 15/15. `install.ps1` still needs a real-Windows +> smoke test. (BM25 = keyword ranking; embeddings remain an optional upgrade for +> machines that can `pip install sentence-transformers`.) ## Why it doesn't run on Windows today diff --git a/engine/search/brain_search.py b/engine/search/brain_search.py new file mode 100644 index 0000000..ecd21ca --- /dev/null +++ b/engine/search/brain_search.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""brain_search — recherche locale dans le vault Brain, cross-platform et sans +dépendance lourde (BM25 pur Python). Alternative à gbrain sur Windows, où la +recherche embarquée (PGLite/pgvector) est cassée (#1549). Marche aussi sur +macOS/Linux. Hors-ligne, instantané, zéro modèle à télécharger. + +Commandes : + brain_search.py index [--brain DIR] (re)construit l'index + brain_search.py query "ma question" [--k 5] [--json] + brain_search.py health + +Config (env) : + BRAIN_DIR vault (def: ~/Documents/Brain) + BRAIN_SEARCH_INDEX fichier d'index (def: ~/.brain-search/index.json) +""" + +import argparse +import json +import math +import os +import re +import sys +import unicodedata +from pathlib import Path + +BRAIN = Path(os.environ.get("BRAIN_DIR") or (Path.home() / "Documents" / "Brain")) +INDEX = Path(os.environ.get("BRAIN_SEARCH_INDEX") or (Path.home() / ".brain-search" / "index.json")) +K1, B = 1.5, 0.75 +_TOKEN = re.compile(r"[a-z0-9]+") +SKIP_DIRS = {".git", ".obsidian", ".trash", ".logs", "node_modules", "__pycache__"} + + +def norm(text: str) -> str: + """minuscule + sans accents (déploiement ~ deploiement).""" + text = unicodedata.normalize("NFKD", text.lower()) + return "".join(c for c in text if not unicodedata.combining(c)) + + +def tokenize(text: str): + return _TOKEN.findall(norm(text)) + + +def chunk_markdown(text: str): + """Découpe par titres ## / ### ; fallback : tout le fichier.""" + parts, cur = [], [] + for line in text.splitlines(): + if re.match(r"^#{2,3}\s", line) and cur: + parts.append("\n".join(cur).strip()) + cur = [line] + else: + cur.append(line) + if cur: + parts.append("\n".join(cur).strip()) + return [p for p in parts if p] or ([text.strip()] if text.strip() else []) + + +def cmd_index(args): + brain = Path(args.brain) if args.brain else BRAIN + if not brain.is_dir(): + print(f"[brain_search] vault introuvable : {brain}", file=sys.stderr) + sys.exit(1) + docs, df = [], {} + for md in sorted(brain.rglob("*.md")): + if any(part in SKIP_DIRS for part in md.relative_to(brain).parts): + continue + try: + text = md.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + rel = md.relative_to(brain).as_posix() + for ci, chunk in enumerate(chunk_markdown(text)): + toks = tokenize(chunk) + if not toks: + continue + tf = {} + for t in toks: + tf[t] = tf.get(t, 0) + 1 + for t in tf: + df[t] = df.get(t, 0) + 1 + snippet = re.sub(r"\s+", " ", chunk).strip()[:240] + docs.append({"path": rel, "chunk": ci, "len": len(toks), "tf": tf, "snippet": snippet}) + avgdl = (sum(d["len"] for d in docs) / len(docs)) if docs else 0.0 + INDEX.parent.mkdir(parents=True, exist_ok=True) + INDEX.write_text(json.dumps({"avgdl": avgdl, "N": len(docs), "df": df, "docs": docs}), + encoding="utf-8") + print(f"[brain_search] indexé {len(docs)} chunks depuis {brain} -> {INDEX}") + + +def _load(): + if not INDEX.exists(): + print(f"[brain_search] pas d'index ({INDEX}). Lance d'abord : brain_search.py index", + file=sys.stderr) + sys.exit(2) + return json.loads(INDEX.read_text(encoding="utf-8")) + + +def cmd_query(args): + idx = _load() + N, avgdl, df, docs = idx["N"], idx["avgdl"], idx["df"], idx["docs"] + qterms = set(tokenize(args.q)) + if not qterms or not docs: + print(json.dumps({"query": args.q, "hits": []}) if args.json else " 0 résultat") + return + idf = {t: math.log(1 + (N - df.get(t, 0) + 0.5) / (df.get(t, 0) + 0.5)) for t in qterms} + scored = [] + for d in docs: + s = 0.0 + for t in qterms: + tf = d["tf"].get(t) + if not tf: + continue + denom = tf + K1 * (1 - B + B * d["len"] / (avgdl or 1)) + s += idf[t] * (tf * (K1 + 1)) / denom + if s > 0: + scored.append((s, d)) + scored.sort(key=lambda x: x[0], reverse=True) + hits = [{"path": d["path"], "chunk": d["chunk"], "score": round(s, 3), "snippet": d["snippet"]} + for s, d in scored[: args.k]] + if args.json: + print(json.dumps({"query": args.q, "hits": hits}, ensure_ascii=False)) + return + print(f"\n '{args.q}' — {len(hits)} hits\n") + for i, h in enumerate(hits, 1): + print(f" {i}. [{h['score']}] {h['path']}#chunk{h['chunk']}") + print(f" {h['snippet']}\n") + + +def cmd_health(args): + ok = INDEX.exists() + n = _load()["N"] if ok else 0 + print(json.dumps({"status": "ok" if ok else "no-index", "count": n, "index": str(INDEX)})) + + +def main(): + ap = argparse.ArgumentParser(prog="brain_search") + sub = ap.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("index"); p.add_argument("--brain"); p.set_defaults(func=cmd_index) + p = sub.add_parser("query"); p.add_argument("q"); p.add_argument("--k", type=int, default=5) + p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_query) + p = sub.add_parser("health"); p.set_defaults(func=cmd_health) + args = ap.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..8a54502 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,157 @@ +<# +.SYNOPSIS + brain-in-a-box - installeur Windows natif (second cerveau perso). + Equivalent de install.sh, sans gbrain : la recherche tourne en local via + brain_search (BM25 pur Python) -> marche sur Windows sans pgvector (#1549). + Idempotent et non-destructif (ne remplace jamais un vault/CLAUDE.md existant). + +.NOTES + Scheduler = Task Scheduler (vs launchd). Recherche = engine/search/brain_search.py. + Le shim gbq.cmd mappe `gbq query` -> brain_search, donc le CLAUDE.md reste inchange. + Pre-requis : Windows 10/11, git, python 3, Claude Code. + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File install.ps1 +#> + +[CmdletBinding()] +param([string]$BrainDir = "$env:USERPROFILE\Documents\Brain") + +$ErrorActionPreference = "Stop" +$REPO = Split-Path -Parent $MyInvocation.MyCommand.Path +$H = $env:USERPROFILE +$HOOKS = "$H\.claude\hooks\brain" +$BIN = "$H\.local\bin" +$BSDIR = "$H\.brain-search" + +function Say($t) { Write-Host ""; Write-Host "=== $t ===" -ForegroundColor Cyan } +function Ok($t) { Write-Host " [OK] $t" -ForegroundColor Green } +function Warn($t) { Write-Host " [!] $t" -ForegroundColor Yellow } +function Have($c) { return [bool](Get-Command $c -ErrorAction SilentlyContinue) } +function Refresh-Path { + $env:Path = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Environment]::GetEnvironmentVariable("Path","User") +} + +# 1. Preflight +Say "Preflight" +if (-not (Have git)) { if (Have winget) { winget install --id Git.Git -e --silent --accept-package-agreements --accept-source-agreements | Out-Null; Refresh-Path } } +if (-not (Have git)) { Warn "git introuvable - installe-le puis relance"; exit 1 } else { Ok "git" } +if (-not (Have python)) { if (Have winget) { winget install --id Python.Python.3.12 -e --silent --accept-package-agreements --accept-source-agreements | Out-Null; Refresh-Path } } +$Py = (Get-Command python -ErrorAction SilentlyContinue).Source +if (-not $Py) { Warn "python introuvable - rouvre un terminal apres install et relance"; exit 1 } else { Ok "python ($Py)" } +if (Have claude) { Ok "claude (Claude Code)" } else { Warn "Claude Code absent - l'app desktop suffit ; la reflection nocturne (claude -p) le requiert" } + +# 2. Vault skeleton (jamais ecrase) +Say "Brain vault ($BrainDir)" +if (Test-Path $BrainDir) { + Warn "vault deja present -> skeleton NON copie (on garde ton contenu)" +} else { + New-Item -ItemType Directory -Path $BrainDir -Force | Out-Null + Copy-Item "$REPO\vault-skeleton\*" $BrainDir -Recurse -Force + Ok "skeleton place" +} + +# 3. Hooks (cross-platform, simple copie) +Say "Brain hooks ($HOOKS)" +New-Item -ItemType Directory -Path $HOOKS -Force | Out-Null +Copy-Item "$REPO\engine\hooks\*.py" $HOOKS -Force +Ok "hooks copies" + +# 4. brain_search (recherche locale) + index + shim gbq +Say "brain_search (recherche locale, sans gbrain)" +New-Item -ItemType Directory -Path $BSDIR -Force | Out-Null +New-Item -ItemType Directory -Path $BIN -Force | Out-Null +Copy-Item "$REPO\engine\search\brain_search.py" "$BSDIR\brain_search.py" -Force +$env:BRAIN_DIR = $BrainDir +& $Py "$BSDIR\brain_search.py" index --brain $BrainDir +# shim gbq.cmd : `gbq query "..."` -> brain_search +$gbq = "@echo off`r`nset BRAIN_DIR=$BrainDir`r`n`"$Py`" `"%USERPROFILE%\.brain-search\brain_search.py`" %*`r`n" +Set-Content -Path "$BIN\gbq.cmd" -Value $gbq -Encoding ascii +$userPath = [Environment]::GetEnvironmentVariable("PATH","User") +if ($userPath -notlike "*$BIN*") { [Environment]::SetEnvironmentVariable("PATH","$userPath;$BIN","User") } +[Environment]::SetEnvironmentVariable("BRAIN_DIR",$BrainDir,"User") +Ok "gbq installe ($BIN\gbq.cmd) + index construit" + +# 5. settings.json (merge non-destructif des hooks) +Say "Hooks Claude (settings.json)" +$settings = "$H\.claude\settings.json" +$merge = @' +import json, os, sys +settings, pyexe, hookdir = sys.argv[1], sys.argv[2], sys.argv[3] +os.makedirs(os.path.dirname(settings), exist_ok=True) +d = {} +if os.path.exists(settings): + try: d = json.load(open(settings, encoding="utf-8")) + except Exception: d = {} +hooks = d.setdefault("hooks", {}) +def cmd(name): return {"type": "command", "command": '"%s" "%s"' % (pyexe, os.path.join(hookdir, name))} +def ensure(evt, names): + arr = hooks.setdefault(evt, []) + grp = next((g for g in arr if g.get("matcher", "") == ""), None) + if grp is None: + grp = {"matcher": "", "hooks": []}; arr.append(grp) + have = {h.get("command") for h in grp.setdefault("hooks", [])} + for n in names: + c = cmd(n) + if c["command"] not in have: grp["hooks"].append(c) +ensure("UserPromptSubmit", ["correction-detector.py"]) +ensure("Stop", ["session-logger.py", "session-indexer.py", "session-recap.py"]) +json.dump(d, open(settings, "w", encoding="utf-8"), indent=2, ensure_ascii=False) +print("ok settings.json") +'@ +$merge | & $Py - $settings $Py $HOOKS +Ok "hooks UserPromptSubmit/Stop cables" + +# 6. Global CLAUDE.md (append, jamais ecrase) +Say "CLAUDE.md global" +$GC = "$H\.claude\CLAUDE.md" +$mark = "" +$cur = if (Test-Path $GC) { Get-Content $GC -Raw } else { "" } +if ($cur -match [regex]::Escape($mark)) { + Ok "deja present (skip)" +} else { + $tpl = Get-Content "$REPO\engine\CLAUDE.global.template.md" -Raw -Encoding utf8 + Add-Content -Path $GC -Value "`r`n$mark`r`n$tpl" -Encoding utf8 + Ok "ajoute (ton contenu existant preserve)" +} + +# 7. Task Scheduler (reindex nuit + reflection 12h/23h) +Say "Task Scheduler (reindex 04:00 + reflection 12:00/23:00)" +function Register-BrainTask($name, $argument, $triggers) { + $action = New-ScheduledTaskAction -Execute $Py -Argument $argument + Register-ScheduledTask -TaskName $name -Action $action -Trigger $triggers -Force -User $env:USERNAME | Out-Null + Ok "tache: $name" +} +try { + Register-BrainTask "BrainSearchReindex" "`"$BSDIR\brain_search.py`" index" (New-ScheduledTaskTrigger -Daily -At "04:00") + $t12 = New-ScheduledTaskTrigger -Daily -At "12:00" + $t23 = New-ScheduledTaskTrigger -Daily -At "23:00" + Register-BrainTask "BrainReflection" "`"$HOOKS\daily-reflection.py`"" @($t12, $t23) +} catch { + Warn "Task Scheduler : $($_.Exception.Message) - recree les taches a la main si besoin" +} + +# 8. git init vault +Say "git init vault" +if (-not (Test-Path "$BrainDir\.git")) { + Push-Location $BrainDir + ".obsidian/`n.DS_Store`n" | Set-Content .gitignore -Encoding ascii + 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 git cree" +} else { Ok "deja un repo git" } + +# 9. Verifier la recherche +Say "Verification" +& $Py "$BSDIR\brain_search.py" health + +Write-Host "" +Write-Host "brain-in-a-box installe (Windows natif)." -ForegroundColor Green +Write-Host "" +Write-Host "Ouvre un NOUVEAU terminal puis teste :" -ForegroundColor Cyan +Write-Host " gbq query `"test`" # la memoire repond (recherche locale)" +Write-Host " cd `"$BrainDir`" ; claude # session dans le brain" +Write-Host "" +Write-Host "Reindex auto chaque nuit (Task Scheduler). Reflection 12h/23h si claude est connecte." -ForegroundColor Cyan