brainlayer 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. brainlayer/__init__.py +3 -0
  2. brainlayer/cli/__init__.py +1545 -0
  3. brainlayer/cli/wizard.py +132 -0
  4. brainlayer/cli_new.py +151 -0
  5. brainlayer/client.py +164 -0
  6. brainlayer/clustering.py +736 -0
  7. brainlayer/daemon.py +1105 -0
  8. brainlayer/dashboard/README.md +129 -0
  9. brainlayer/dashboard/__init__.py +5 -0
  10. brainlayer/dashboard/app.py +151 -0
  11. brainlayer/dashboard/search.py +229 -0
  12. brainlayer/dashboard/views.py +230 -0
  13. brainlayer/embeddings.py +131 -0
  14. brainlayer/engine.py +550 -0
  15. brainlayer/index_new.py +87 -0
  16. brainlayer/mcp/__init__.py +1558 -0
  17. brainlayer/migrate.py +205 -0
  18. brainlayer/paths.py +43 -0
  19. brainlayer/pipeline/__init__.py +47 -0
  20. brainlayer/pipeline/analyze_communication.py +508 -0
  21. brainlayer/pipeline/brain_graph.py +567 -0
  22. brainlayer/pipeline/chat_tags.py +63 -0
  23. brainlayer/pipeline/chunk.py +422 -0
  24. brainlayer/pipeline/classify.py +472 -0
  25. brainlayer/pipeline/cluster_sampling.py +73 -0
  26. brainlayer/pipeline/enrichment.py +810 -0
  27. brainlayer/pipeline/extract.py +66 -0
  28. brainlayer/pipeline/extract_claude_desktop.py +149 -0
  29. brainlayer/pipeline/extract_corrections.py +231 -0
  30. brainlayer/pipeline/extract_markdown.py +195 -0
  31. brainlayer/pipeline/extract_whatsapp.py +227 -0
  32. brainlayer/pipeline/git_overlay.py +301 -0
  33. brainlayer/pipeline/longitudinal_analyzer.py +568 -0
  34. brainlayer/pipeline/obsidian_export.py +455 -0
  35. brainlayer/pipeline/operation_grouping.py +486 -0
  36. brainlayer/pipeline/plan_linking.py +313 -0
  37. brainlayer/pipeline/sanitize.py +549 -0
  38. brainlayer/pipeline/semantic_style.py +574 -0
  39. brainlayer/pipeline/session_enrichment.py +472 -0
  40. brainlayer/pipeline/style_embed.py +67 -0
  41. brainlayer/pipeline/style_index.py +139 -0
  42. brainlayer/pipeline/temporal_chains.py +203 -0
  43. brainlayer/pipeline/time_batcher.py +248 -0
  44. brainlayer/pipeline/unified_timeline.py +569 -0
  45. brainlayer/storage.py +66 -0
  46. brainlayer/store.py +155 -0
  47. brainlayer/taxonomy.json +80 -0
  48. brainlayer/vector_store.py +1891 -0
  49. brainlayer-1.0.0.dist-info/METADATA +313 -0
  50. brainlayer-1.0.0.dist-info/RECORD +53 -0
  51. brainlayer-1.0.0.dist-info/WHEEL +4 -0
  52. brainlayer-1.0.0.dist-info/entry_points.txt +4 -0
  53. brainlayer-1.0.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,132 @@
1
+ """Interactive setup wizard for BrainLayer.
2
+
3
+ Detects the user's environment, recommends configuration,
4
+ and guides through first-time setup.
5
+ """
6
+
7
+ import platform
8
+ import shutil
9
+ import subprocess
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+
15
+ @dataclass
16
+ class WizardConfig:
17
+ """Configuration generated by the wizard."""
18
+
19
+ enrich_backend: str = "none"
20
+ extras: list[str] = field(default_factory=list)
21
+ claude_projects_dir: Optional[Path] = None
22
+ db_path: Optional[Path] = None
23
+
24
+
25
+ def detect_environment() -> dict:
26
+ """Detect available tools and data sources."""
27
+ env = {}
28
+
29
+ # Check Ollama
30
+ env["ollama_available"] = shutil.which("ollama") is not None
31
+ if env["ollama_available"]:
32
+ try:
33
+ result = subprocess.run(["ollama", "list"], capture_output=True, text=True, timeout=5)
34
+ env["ollama_models"] = result.stdout.strip().split("\n") if result.returncode == 0 else []
35
+ except (subprocess.TimeoutExpired, FileNotFoundError):
36
+ env["ollama_available"] = False
37
+ env["ollama_models"] = []
38
+ else:
39
+ env["ollama_models"] = []
40
+
41
+ # Check Apple Silicon (for MLX)
42
+ env["is_apple_silicon"] = platform.system() == "Darwin" and platform.machine() == "arm64"
43
+
44
+ # Check Claude Code conversations
45
+ claude_dir = Path.home() / ".claude" / "projects"
46
+ env["claude_projects_dir"] = claude_dir if claude_dir.exists() else None
47
+ env["conversation_count"] = len(list(claude_dir.rglob("*.jsonl"))) if claude_dir.exists() else 0
48
+
49
+ # Check for WhatsApp exports
50
+ env["whatsapp_available"] = False
51
+
52
+ # Check for existing DB
53
+ default_db = Path.home() / ".local" / "share" / "brainlayer" / "brainlayer.db"
54
+ env["existing_db"] = default_db.exists()
55
+
56
+ return env
57
+
58
+
59
+ def run_wizard() -> WizardConfig:
60
+ """Run the interactive setup wizard. Returns configuration."""
61
+ from rich.console import Console
62
+ from rich.panel import Panel
63
+ from rich.prompt import Confirm, Prompt
64
+
65
+ console = Console()
66
+ config = WizardConfig()
67
+
68
+ console.print(
69
+ Panel.fit(
70
+ "[bold]BrainLayer Setup Wizard[/bold]\nLike git for your AI conversations.",
71
+ border_style="blue",
72
+ )
73
+ )
74
+
75
+ console.print("\n[dim]Detecting your environment...[/dim]")
76
+ env = detect_environment()
77
+
78
+ console.print(f"\n Claude Code conversations: [green]{env['conversation_count']}[/green] JSONL files")
79
+ console.print(
80
+ f" Ollama: {'[green]available[/green]' if env['ollama_available'] else '[yellow]not found[/yellow]'}"
81
+ )
82
+ console.print(f" Apple Silicon (MLX): {'[green]yes[/green]' if env['is_apple_silicon'] else '[dim]no[/dim]'}")
83
+ console.print(f" Existing DB: {'[green]found[/green]' if env['existing_db'] else '[dim]none[/dim]'}")
84
+
85
+ if env["is_apple_silicon"] and env["ollama_available"]:
86
+ backend = Prompt.ask(
87
+ "\nEnrichment backend",
88
+ choices=["ollama", "mlx", "none"],
89
+ default="ollama",
90
+ )
91
+ elif env["ollama_available"]:
92
+ backend = Prompt.ask(
93
+ "\nEnrichment backend",
94
+ choices=["ollama", "none"],
95
+ default="ollama",
96
+ )
97
+ else:
98
+ console.print("\n[yellow]No local LLM found. Enrichment disabled.[/yellow]")
99
+ console.print("[dim]Install Ollama (ollama.ai) for local enrichment.[/dim]")
100
+ backend = "none"
101
+ config.enrich_backend = backend
102
+
103
+ extras = []
104
+ if Confirm.ask("\nInstall style analysis (communication patterns)?", default=False):
105
+ extras.append("style")
106
+ if Confirm.ask("Install YouTube transcript indexing?", default=False):
107
+ extras.append("youtube")
108
+ if Confirm.ask("Install Obsidian export?", default=False):
109
+ extras.append("obsidian")
110
+ config.extras = extras
111
+
112
+ if env["claude_projects_dir"]:
113
+ config.claude_projects_dir = env["claude_projects_dir"]
114
+ else:
115
+ custom = Prompt.ask(
116
+ "Path to Claude Code projects directory",
117
+ default=str(Path.home() / ".claude" / "projects"),
118
+ )
119
+ config.claude_projects_dir = Path(custom)
120
+
121
+ console.print(
122
+ Panel.fit(
123
+ f"[bold green]Setup complete![/bold green]\n\n"
124
+ f" Backend: {config.enrich_backend}\n"
125
+ f" Extras: {', '.join(config.extras) or 'none'}\n"
126
+ f" Source: {config.claude_projects_dir}\n\n"
127
+ f"Run [bold]brainlayer index[/bold] to index your conversations.",
128
+ border_style="green",
129
+ )
130
+ )
131
+
132
+ return config
brainlayer/cli_new.py ADDED
@@ -0,0 +1,151 @@
1
+ """Updated CLI commands using daemon client."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich import print as rprint
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from .client import get_client
11
+
12
+ console = Console()
13
+
14
+
15
+ def search_command(
16
+ query: str = typer.Argument(..., help="Search query"),
17
+ n: int = typer.Option(5, "--num", "-n", help="Number of results", min=1, max=100),
18
+ project: str = typer.Option(None, "--project", "-p", help="Filter by project"),
19
+ content_type: str = typer.Option(None, "--type", "-t", help="Filter by content type"),
20
+ text: bool = typer.Option(False, "--text", help="Use text-based search instead of semantic search"),
21
+ hybrid: bool = True,
22
+ ) -> None:
23
+ """Search the knowledge base using fast daemon."""
24
+ try:
25
+ # Auto-detect domain-like queries and use text search
26
+ if not text and ("." in query or query.startswith("http") or "/" in query):
27
+ text = True
28
+ rprint("[dim]Auto-detected domain/URL query, using text search[/]")
29
+
30
+ search_type = "text" if text else ("hybrid" if hybrid else "semantic")
31
+ rprint(f"[bold blue]זיכרון[/] - Searching ({search_type}): [italic]{query}[/]")
32
+
33
+ # Search using daemon
34
+ client = get_client()
35
+
36
+ with console.status("[bold green]Searching..."):
37
+ results = client.search(
38
+ query=query,
39
+ n_results=n,
40
+ project_filter=project,
41
+ content_type_filter=content_type,
42
+ use_semantic=not text,
43
+ hybrid=hybrid and not text,
44
+ )
45
+
46
+ # Display results
47
+ if not results["documents"]:
48
+ rprint("[yellow]No results found[/]")
49
+ return
50
+
51
+ search_time = results["total_time_ms"]
52
+ rprint(f"[dim]Found {len(results['documents'])} results in {search_time:.1f}ms[/]\n")
53
+
54
+ result_ids = results.get("ids", [])
55
+ for i, (doc, meta, dist) in enumerate(zip(results["documents"], results["metadatas"], results["distances"])):
56
+ score = 1 - dist if dist is not None else None
57
+ score_str = f"[dim](score: {score:.3f})[/]" if score is not None else "[dim](text match)[/]"
58
+ proj = _clean_project_name(meta.get("project", "unknown"))
59
+ # Show contact name for WhatsApp/messaging sources
60
+ if proj == "unknown" and meta.get("contact_name"):
61
+ proj = meta["contact_name"]
62
+ chunk_id = result_ids[i] if i < len(result_ids) else None
63
+
64
+ # Truncate long content
65
+ content = doc[:500] + "..." if len(doc) > 500 else doc
66
+
67
+ rprint(f"[bold cyan]{i + 1}.[/] {score_str} [dim]({proj})[/]")
68
+ rprint(f"[white]{content}[/]")
69
+ if chunk_id:
70
+ rprint(f"[dim]ID: {chunk_id}[/]")
71
+ rprint()
72
+
73
+ except Exception as e:
74
+ rprint(f"[bold red]Error:[/] {e}")
75
+ raise typer.Exit(1)
76
+
77
+
78
+ def stats_command() -> None:
79
+ """Show knowledge base statistics."""
80
+ try:
81
+ client = get_client()
82
+
83
+ with console.status("[bold green]Getting stats..."):
84
+ stats = client.get_stats()
85
+
86
+ rprint("[bold blue]זיכרון[/] - Knowledge Base Statistics\n")
87
+
88
+ table = Table(show_header=True, header_style="bold magenta")
89
+ table.add_column("Metric", style="cyan")
90
+ table.add_column("Value", style="white")
91
+
92
+ table.add_row("Total Chunks", f"{stats['total_chunks']:,}")
93
+ table.add_row("Projects", str(len(stats["projects"])))
94
+ table.add_row("Content Types", str(len(stats["content_types"])))
95
+
96
+ console.print(table)
97
+
98
+ if stats["projects"]:
99
+ rprint(f"\n[bold]Projects:[/] {', '.join(stats['projects'])}")
100
+
101
+ if stats["content_types"]:
102
+ rprint(f"[bold]Content Types:[/] {', '.join(stats['content_types'])}")
103
+
104
+ except Exception as e:
105
+ rprint(f"[bold red]Error:[/] {e}")
106
+ raise typer.Exit(1)
107
+
108
+
109
+ def migrate_command() -> None:
110
+ """Migrate from ChromaDB to sqlite-vec."""
111
+ try:
112
+ from .migrate import migrate_from_chromadb
113
+
114
+ rprint("[bold blue]זיכרון[/] - Migration Tool\n")
115
+
116
+ sqlite_path = Path.home() / ".local" / "share" / "brainlayer" / "brainlayer.db"
117
+
118
+ if sqlite_path.exists():
119
+ response = typer.confirm("sqlite-vec database already exists. Overwrite?")
120
+ if not response:
121
+ rprint("Migration cancelled")
122
+ return
123
+ sqlite_path.unlink()
124
+
125
+ with console.status("[bold green]Migrating data..."):
126
+ success = migrate_from_chromadb()
127
+
128
+ if success:
129
+ rprint("[bold green]✓[/] Migration completed successfully!")
130
+ rprint("You can now use the fast daemon service.")
131
+ else:
132
+ rprint("[bold red]✗[/] Migration failed or skipped")
133
+ raise typer.Exit(1)
134
+
135
+ except Exception as e:
136
+ rprint(f"[bold red]Error:[/] {e}")
137
+ raise typer.Exit(1)
138
+
139
+
140
+ def _clean_project_name(project: str) -> str:
141
+ """Clean project name for display."""
142
+ if not project or project == "unknown":
143
+ return "unknown"
144
+
145
+ # Remove common prefixes
146
+ if project.startswith("/Users/"):
147
+ parts = project.split("/")
148
+ if len(parts) > 4:
149
+ return "/".join(parts[-2:]) # Last two parts
150
+
151
+ return project
brainlayer/client.py ADDED
@@ -0,0 +1,164 @@
1
+ """Client for communicating with brainlayer daemon."""
2
+
3
+ import logging
4
+ import subprocess
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional
8
+
9
+ import httpx
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ SOCKET_PATH = Path("/tmp/brainlayer.sock")
14
+ DAEMON_STARTUP_TIMEOUT = 30 # seconds
15
+
16
+
17
+ class DaemonClient:
18
+ """Client for brainlayer daemon."""
19
+
20
+ def __init__(self):
21
+ self.base_url = "http://localhost"
22
+ self._client: Optional[httpx.Client] = None
23
+
24
+ def _get_client(self) -> httpx.Client:
25
+ """Get HTTP client with Unix socket transport."""
26
+ if self._client is None:
27
+ transport = httpx.HTTPTransport(uds=str(SOCKET_PATH))
28
+ self._client = httpx.Client(base_url=self.base_url, transport=transport, timeout=30.0)
29
+ return self._client
30
+
31
+ def _is_daemon_running(self) -> bool:
32
+ """Check if daemon is running."""
33
+ if not SOCKET_PATH.exists():
34
+ return False
35
+
36
+ try:
37
+ client = self._get_client()
38
+ response = client.get("/health")
39
+ return response.status_code == 200
40
+ except Exception:
41
+ return False
42
+
43
+ def _start_daemon(self) -> bool:
44
+ """Start daemon process."""
45
+ try:
46
+ # Start daemon in background
47
+ subprocess.Popen(
48
+ ["brainlayer-daemon"],
49
+ stdout=subprocess.DEVNULL,
50
+ stderr=subprocess.DEVNULL,
51
+ start_new_session=True,
52
+ )
53
+
54
+ # Wait for daemon to start
55
+ for _ in range(DAEMON_STARTUP_TIMEOUT):
56
+ if self._is_daemon_running():
57
+ return True
58
+ time.sleep(1)
59
+
60
+ return False
61
+
62
+ except Exception as e:
63
+ logger.warning("Failed to start daemon: %s", e)
64
+ return False
65
+
66
+ def _ensure_daemon(self) -> bool:
67
+ """Ensure daemon is running."""
68
+ if self._is_daemon_running():
69
+ return True
70
+
71
+ logger.info("Starting brainlayer daemon...")
72
+ return self._start_daemon()
73
+
74
+ def search(
75
+ self,
76
+ query: str,
77
+ n_results: int = 10,
78
+ project_filter: Optional[str] = None,
79
+ content_type_filter: Optional[str] = None,
80
+ source_filter: Optional[str] = None,
81
+ use_semantic: bool = True,
82
+ hybrid: bool = True,
83
+ ) -> Dict[str, Any]:
84
+ """Search the knowledge base."""
85
+ if not self._ensure_daemon():
86
+ raise RuntimeError("Failed to start daemon")
87
+
88
+ try:
89
+ client = self._get_client()
90
+ response = client.post(
91
+ "/search",
92
+ json={
93
+ "query": query,
94
+ "n_results": n_results,
95
+ "project_filter": project_filter,
96
+ "content_type_filter": content_type_filter,
97
+ "source_filter": source_filter,
98
+ "use_semantic": use_semantic,
99
+ "hybrid": hybrid,
100
+ },
101
+ )
102
+ response.raise_for_status()
103
+ return response.json()
104
+
105
+ except httpx.RequestError as e:
106
+ raise RuntimeError(f"Failed to communicate with daemon: {e}")
107
+ except httpx.HTTPStatusError as e:
108
+ raise RuntimeError(f"Search failed: {e.response.text}")
109
+
110
+ def get_context(self, chunk_id: str, before: int = 3, after: int = 3) -> Dict[str, Any]:
111
+ """Get surrounding conversation context for a chunk."""
112
+ if not self._ensure_daemon():
113
+ raise RuntimeError("Failed to start daemon")
114
+
115
+ try:
116
+ client = self._get_client()
117
+ response = client.get(f"/context/{chunk_id}", params={"before": before, "after": after})
118
+ response.raise_for_status()
119
+ return response.json()
120
+
121
+ except httpx.RequestError as e:
122
+ raise RuntimeError(f"Failed to communicate with daemon: {e}")
123
+ except httpx.HTTPStatusError as e:
124
+ raise RuntimeError(f"Context request failed: {e.response.text}")
125
+
126
+ def get_stats(self) -> Dict[str, Any]:
127
+ """Get collection statistics."""
128
+ if not self._ensure_daemon():
129
+ raise RuntimeError("Failed to start daemon")
130
+
131
+ try:
132
+ client = self._get_client()
133
+ response = client.get("/stats")
134
+ response.raise_for_status()
135
+ return response.json()
136
+
137
+ except httpx.RequestError as e:
138
+ raise RuntimeError(f"Failed to communicate with daemon: {e}")
139
+ except httpx.HTTPStatusError as e:
140
+ raise RuntimeError(f"Stats request failed: {e.response.text}")
141
+
142
+ def close(self):
143
+ """Close client connection."""
144
+ if self._client:
145
+ self._client.close()
146
+ self._client = None
147
+
148
+ def __enter__(self):
149
+ return self
150
+
151
+ def __exit__(self, exc_type, exc_val, exc_tb):
152
+ self.close()
153
+
154
+
155
+ # Global client instance
156
+ _client: Optional[DaemonClient] = None
157
+
158
+
159
+ def get_client() -> DaemonClient:
160
+ """Get global daemon client."""
161
+ global _client
162
+ if _client is None:
163
+ _client = DaemonClient()
164
+ return _client