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,227 @@
1
+ """Extract WhatsApp messages for communication pattern analysis.
2
+
3
+ Extracts messages from WhatsApp's SQLite database to analyze:
4
+ - User's writing style and patterns
5
+ - Response patterns
6
+ - Communication preferences
7
+ """
8
+
9
+ import sqlite3
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Iterator, Optional
13
+
14
+
15
+ def get_whatsapp_db_path() -> Path:
16
+ """Get path to WhatsApp's ChatStorage database."""
17
+ return Path.home() / "Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlite"
18
+
19
+
20
+ def get_contacts_db_path() -> Path:
21
+ """Get path to WhatsApp's Contacts database."""
22
+ return Path.home() / "Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ContactsV2.sqlite"
23
+
24
+
25
+ def extract_whatsapp_messages(
26
+ db_path: Optional[Path] = None,
27
+ limit: Optional[int] = None,
28
+ only_from_me: bool = False,
29
+ exclude_groups: bool = True,
30
+ min_char_count: int = 10,
31
+ ) -> Iterator[dict]:
32
+ """
33
+ Extract WhatsApp messages from SQLite database.
34
+
35
+ Args:
36
+ db_path: Path to ChatStorage.sqlite (auto-detected if None)
37
+ limit: Maximum number of messages to extract
38
+ only_from_me: Only extract messages sent by user
39
+ exclude_groups: Exclude group chat messages
40
+ min_char_count: Minimum message length to include (filters emoji-only etc.)
41
+
42
+ Yields:
43
+ Message dictionaries with text, timestamp, sender info
44
+ """
45
+ if db_path is None:
46
+ db_path = get_whatsapp_db_path()
47
+
48
+ if not db_path.exists():
49
+ raise FileNotFoundError(f"WhatsApp database not found at: {db_path}")
50
+
51
+ # Open database in read-only mode
52
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
53
+ conn.row_factory = sqlite3.Row
54
+
55
+ try:
56
+ cursor = conn.cursor()
57
+
58
+ # Build query
59
+ query = """
60
+ SELECT
61
+ Z_PK as id,
62
+ ZTEXT as text,
63
+ ZISFROMME as is_from_me,
64
+ ZMESSAGEDATE as message_date,
65
+ ZSENTDATE as sent_date,
66
+ ZFROMJID as from_jid,
67
+ ZTOJID as to_jid,
68
+ ZPUSHNAME as push_name,
69
+ ZMESSAGETYPE as message_type,
70
+ ZMESSAGESTATUS as message_status,
71
+ ZSTARRED as starred,
72
+ ZSTANZAID as stanza_id
73
+ FROM ZWAMESSAGE
74
+ WHERE ZTEXT IS NOT NULL
75
+ AND ZTEXT != ''
76
+ AND LENGTH(ZTEXT) >= ?
77
+ """
78
+ params = [min_char_count]
79
+
80
+ # Add filters
81
+ if only_from_me:
82
+ query += " AND ZISFROMME = 1"
83
+
84
+ if exclude_groups:
85
+ # Group JIDs typically contain '@g.us'
86
+ query += " AND (ZTOJID NOT LIKE '%@g.us' OR ZTOJID IS NULL)"
87
+
88
+ # Order by date descending (most recent first)
89
+ query += " ORDER BY ZMESSAGEDATE DESC"
90
+
91
+ if limit:
92
+ query += " LIMIT ?"
93
+ params.append(limit)
94
+
95
+ cursor.execute(query, params)
96
+
97
+ for row in cursor:
98
+ # Convert Core Data timestamp to Unix timestamp
99
+ # Core Data uses seconds since 2001-01-01
100
+ core_data_epoch = 978307200 # Unix timestamp for 2001-01-01
101
+ message_timestamp = row["message_date"] + core_data_epoch if row["message_date"] else None
102
+
103
+ yield {
104
+ "id": row["id"],
105
+ "text": row["text"],
106
+ "is_from_me": bool(row["is_from_me"]),
107
+ "timestamp": message_timestamp,
108
+ "datetime": datetime.fromtimestamp(message_timestamp) if message_timestamp else None,
109
+ "from_jid": row["from_jid"],
110
+ "to_jid": row["to_jid"],
111
+ "contact_name": row["push_name"],
112
+ "message_type": row["message_type"],
113
+ "starred": bool(row["starred"]),
114
+ "stanza_id": row["stanza_id"],
115
+ }
116
+
117
+ finally:
118
+ conn.close()
119
+
120
+
121
+ def get_contact_info(jid: str, contacts_db_path: Optional[Path] = None) -> Optional[dict]:
122
+ """
123
+ Get contact information for a JID.
124
+
125
+ Args:
126
+ jid: WhatsApp JID (e.g., "1234567890@s.whatsapp.net")
127
+ contacts_db_path: Path to ContactsV2.sqlite
128
+
129
+ Returns:
130
+ Contact info dict or None if not found
131
+ """
132
+ if contacts_db_path is None:
133
+ contacts_db_path = get_contacts_db_path()
134
+
135
+ if not contacts_db_path.exists():
136
+ return None
137
+
138
+ conn = sqlite3.connect(f"file:{contacts_db_path}?mode=ro", uri=True)
139
+ conn.row_factory = sqlite3.Row
140
+
141
+ try:
142
+ cursor = conn.cursor()
143
+ cursor.execute(
144
+ """
145
+ SELECT
146
+ ZCONTACTNAME as name,
147
+ ZPHONENUMBER as phone,
148
+ ZSTATUSTEXT as status
149
+ FROM ZWACONTACT
150
+ WHERE ZCONTACTJID = ?
151
+ """,
152
+ (jid,),
153
+ )
154
+
155
+ row = cursor.fetchone()
156
+ if row:
157
+ return {
158
+ "name": row["name"],
159
+ "phone": row["phone"],
160
+ "status": row["status"],
161
+ }
162
+ return None
163
+
164
+ finally:
165
+ conn.close()
166
+
167
+
168
+ def analyze_writing_style(messages: list[dict]) -> dict:
169
+ """
170
+ Analyze user's writing style from messages.
171
+
172
+ Returns patterns like:
173
+ - Average message length
174
+ - Common phrases
175
+ - Punctuation usage
176
+ - Emoji usage
177
+ - Response time patterns
178
+ """
179
+ user_messages = [m for m in messages if m["is_from_me"]]
180
+
181
+ if not user_messages:
182
+ return {}
183
+
184
+ total_length = sum(len(m["text"]) for m in user_messages)
185
+ avg_length = total_length / len(user_messages)
186
+
187
+ # Count emoji usage
188
+ emoji_count = sum(
189
+ 1
190
+ for m in user_messages
191
+ for char in m["text"]
192
+ if ord(char) > 0x1F300 # Basic emoji range
193
+ )
194
+
195
+ # Analyze punctuation
196
+ exclamation_count = sum(m["text"].count("!") for m in user_messages)
197
+ question_count = sum(m["text"].count("?") for m in user_messages)
198
+
199
+ return {
200
+ "total_messages": len(user_messages),
201
+ "avg_message_length": avg_length,
202
+ "emoji_usage_rate": emoji_count / len(user_messages),
203
+ "exclamation_rate": exclamation_count / len(user_messages),
204
+ "question_rate": question_count / len(user_messages),
205
+ "sample_messages": [m["text"] for m in user_messages[:10]],
206
+ }
207
+
208
+
209
+ def format_whatsapp_for_pipeline(message: dict) -> dict:
210
+ """
211
+ Format WhatsApp message to match brainlayer pipeline format.
212
+
213
+ Converts to a format similar to Claude Code conversations.
214
+ """
215
+ return {
216
+ "type": "whatsapp_message",
217
+ "id": f"whatsapp_{message['id']}",
218
+ "timestamp": message["timestamp"],
219
+ "role": "user" if message["is_from_me"] else "contact",
220
+ "content": message["text"],
221
+ "metadata": {
222
+ "source": "whatsapp",
223
+ "contact": message["contact_name"],
224
+ "jid": message["from_jid"] if not message["is_from_me"] else message["to_jid"],
225
+ "starred": message["starred"],
226
+ },
227
+ }
@@ -0,0 +1,301 @@
1
+ """Git Overlay Pipeline — Cross-reference sessions with git history.
2
+
3
+ Phase 8b: For each indexed session, extract git metadata (branch, commits,
4
+ PR number, files changed) and build file-interaction timelines.
5
+
6
+ Usage:
7
+ from brainlayer.pipeline.git_overlay import run_git_overlay
8
+ run_git_overlay(vector_store, project="-Users-username-Projects-myapp")
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import re
14
+ import subprocess
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def slug_to_path(slug: str) -> Optional[Path]:
22
+ """Convert a project slug to a filesystem path.
23
+
24
+ Example: '-Users-username-Projects-myapp' -> '/Users/username/Projects/myapp'
25
+ """
26
+ if not slug:
27
+ return None
28
+ # Replace leading '-' with '/', then '-' with '/'
29
+ path_str = "/" + slug.lstrip("-").replace("-", "/")
30
+ path = Path(path_str)
31
+ if path.exists() and path.is_dir():
32
+ return path
33
+ return None
34
+
35
+
36
+ def slug_to_display(slug: str) -> str:
37
+ """Convert project slug to display name.
38
+
39
+ Example: '-Users-username-Projects-myapp' -> 'Projects/myapp'
40
+ """
41
+ return re.sub(r"^-Users-[^-]+-", "", slug).replace("-", "/")
42
+
43
+
44
+ def _git_cmd(cmd: List[str], cwd: Path, timeout: int = 10) -> Optional[str]:
45
+ """Run a git command, return stdout or None on failure."""
46
+ try:
47
+ result = subprocess.run(
48
+ ["git"] + cmd,
49
+ cwd=str(cwd),
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=timeout,
53
+ )
54
+ if result.returncode == 0:
55
+ return result.stdout.strip()
56
+ return None
57
+ except (subprocess.TimeoutExpired, FileNotFoundError):
58
+ return None
59
+
60
+
61
+ def is_git_repo(path: Path) -> bool:
62
+ """Check if path is inside a git repository."""
63
+ return _git_cmd(["rev-parse", "--git-dir"], path) is not None
64
+
65
+
66
+ def get_session_timestamps(jsonl_path: Path) -> Tuple[Optional[str], Optional[str]]:
67
+ """Extract first and last timestamps from a session JSONL file."""
68
+ first_ts = None
69
+ last_ts = None
70
+ try:
71
+ with open(jsonl_path) as f:
72
+ for line in f:
73
+ line = line.strip()
74
+ if not line:
75
+ continue
76
+ try:
77
+ obj = json.loads(line)
78
+ ts = obj.get("timestamp")
79
+ if ts:
80
+ if first_ts is None:
81
+ first_ts = ts
82
+ last_ts = ts
83
+ except json.JSONDecodeError:
84
+ continue
85
+ except (OSError, IOError):
86
+ pass
87
+ return first_ts, last_ts
88
+
89
+
90
+ def extract_file_actions(jsonl_path: Path, session_id: str) -> List[Dict[str, Any]]:
91
+ """Extract file interaction records from a session JSONL.
92
+
93
+ Looks for tool_use blocks in assistant messages with file paths.
94
+ """
95
+ interactions: List[Dict[str, Any]] = []
96
+ tool_to_action = {
97
+ "Read": "read",
98
+ "Edit": "edit",
99
+ "Write": "write",
100
+ "Glob": "search",
101
+ "Grep": "search",
102
+ }
103
+
104
+ try:
105
+ with open(jsonl_path) as f:
106
+ for line in f:
107
+ line = line.strip()
108
+ if not line:
109
+ continue
110
+ try:
111
+ obj = json.loads(line)
112
+ if obj.get("type") != "assistant":
113
+ continue
114
+ msg = obj.get("message", {})
115
+ content = msg.get("content", [])
116
+ timestamp = obj.get("timestamp")
117
+ if not isinstance(content, list):
118
+ continue
119
+ for block in content:
120
+ if not isinstance(block, dict):
121
+ continue
122
+ if block.get("type") != "tool_use":
123
+ continue
124
+ tool_name = block.get("name", "")
125
+ action = tool_to_action.get(tool_name)
126
+ if not action:
127
+ continue
128
+ inp = block.get("input", {})
129
+ file_path = inp.get("file_path") or inp.get("path")
130
+ if file_path:
131
+ interactions.append(
132
+ {
133
+ "file_path": file_path,
134
+ "timestamp": timestamp,
135
+ "session_id": session_id,
136
+ "action": action,
137
+ }
138
+ )
139
+ except json.JSONDecodeError:
140
+ continue
141
+ except (OSError, IOError):
142
+ pass
143
+ return interactions
144
+
145
+
146
+ def get_git_context(
147
+ repo_path: Path,
148
+ start_ts: str,
149
+ end_ts: str,
150
+ ) -> Dict[str, Any]:
151
+ """Get git context for a time range in a repo.
152
+
153
+ Returns branch, commits, PR number, and files changed.
154
+ """
155
+ context: Dict[str, Any] = {
156
+ "branch": None,
157
+ "pr_number": None,
158
+ "commit_shas": [],
159
+ "files_changed": [],
160
+ }
161
+
162
+ # Current branch (at time of overlay, not session time)
163
+ branch = _git_cmd(["rev-parse", "--abbrev-ref", "HEAD"], repo_path)
164
+ context["branch"] = branch
165
+
166
+ # Commits in the time range
167
+ log_output = _git_cmd(
168
+ ["log", f"--after={start_ts}", f"--before={end_ts}", "--format=%H %s", "--all"],
169
+ repo_path,
170
+ )
171
+ if log_output:
172
+ for line in log_output.strip().split("\n"):
173
+ if line.strip():
174
+ parts = line.split(" ", 1)
175
+ context["commit_shas"].append(parts[0])
176
+
177
+ # Files changed in those commits
178
+ if context["commit_shas"]:
179
+ first_sha = context["commit_shas"][-1] # oldest
180
+ last_sha = context["commit_shas"][0] # newest
181
+ diff_output = _git_cmd(
182
+ ["diff", "--name-only", f"{first_sha}~1", last_sha],
183
+ repo_path,
184
+ )
185
+ if diff_output:
186
+ context["files_changed"] = [f for f in diff_output.strip().split("\n") if f.strip()]
187
+
188
+ # Try to extract PR number from branch name
189
+ if branch and branch not in ("master", "main", "HEAD"):
190
+ # Try gh CLI for PR number
191
+ try:
192
+ result = subprocess.run(
193
+ ["gh", "pr", "list", "--head", branch, "--json", "number", "--limit", "1"],
194
+ cwd=str(repo_path),
195
+ capture_output=True,
196
+ text=True,
197
+ timeout=10,
198
+ )
199
+ if result.returncode == 0:
200
+ prs = json.loads(result.stdout)
201
+ if prs:
202
+ context["pr_number"] = prs[0]["number"]
203
+ except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
204
+ pass
205
+
206
+ return context
207
+
208
+
209
+ def run_git_overlay(
210
+ vector_store: Any,
211
+ project: Optional[str] = None,
212
+ force: bool = False,
213
+ max_sessions: int = 0,
214
+ ) -> Dict[str, int]:
215
+ """Run git overlay on indexed sessions.
216
+
217
+ Args:
218
+ vector_store: VectorStore instance
219
+ project: Filter to specific project slug
220
+ force: Re-process sessions that already have context
221
+ max_sessions: Limit number of sessions (0 = all)
222
+
223
+ Returns:
224
+ Dict with counts: sessions_processed, file_interactions_added
225
+ """
226
+ projects_dir = Path.home() / ".claude" / "projects"
227
+ if not projects_dir.exists():
228
+ logger.warning("No projects directory found at %s", projects_dir)
229
+ return {"sessions_processed": 0, "file_interactions_added": 0}
230
+
231
+ stats = {"sessions_processed": 0, "file_interactions_added": 0}
232
+ processed = 0
233
+
234
+ for proj_dir in sorted(projects_dir.iterdir()):
235
+ if not proj_dir.is_dir():
236
+ continue
237
+ if project and proj_dir.name != project:
238
+ continue
239
+
240
+ # Resolve project path
241
+ repo_path = slug_to_path(proj_dir.name)
242
+ display_name = slug_to_display(proj_dir.name)
243
+ has_git = repo_path and is_git_repo(repo_path)
244
+
245
+ for jsonl_file in sorted(proj_dir.glob("*.jsonl")):
246
+ if max_sessions and processed >= max_sessions:
247
+ break
248
+
249
+ session_id = jsonl_file.stem
250
+
251
+ # Skip if already processed (unless force)
252
+ if not force:
253
+ existing = vector_store.get_session_context(session_id)
254
+ if existing:
255
+ continue
256
+
257
+ # Get timestamps
258
+ start_ts, end_ts = get_session_timestamps(jsonl_file)
259
+ if not start_ts:
260
+ continue
261
+
262
+ # Extract file interactions from JSONL
263
+ file_actions = extract_file_actions(jsonl_file, session_id)
264
+ for fa in file_actions:
265
+ fa["project"] = display_name
266
+
267
+ # Get git context if repo exists
268
+ git_ctx = {}
269
+ if has_git and repo_path:
270
+ git_ctx = get_git_context(repo_path, start_ts, end_ts)
271
+
272
+ # Store session context
273
+ vector_store.store_session_context(
274
+ session_id=session_id,
275
+ project=display_name,
276
+ branch=git_ctx.get("branch"),
277
+ pr_number=git_ctx.get("pr_number"),
278
+ commit_shas=git_ctx.get("commit_shas", []),
279
+ files_changed=git_ctx.get("files_changed", []),
280
+ started_at=start_ts,
281
+ ended_at=end_ts,
282
+ )
283
+
284
+ # Store file interactions
285
+ count = vector_store.store_file_interactions(file_actions)
286
+ stats["file_interactions_added"] += count
287
+ stats["sessions_processed"] += 1
288
+ processed += 1
289
+
290
+ logger.info(
291
+ "Session %s: %s files, %d interactions, branch=%s",
292
+ session_id[:8],
293
+ len(git_ctx.get("files_changed", [])),
294
+ count,
295
+ git_ctx.get("branch", "n/a"),
296
+ )
297
+
298
+ if max_sessions and processed >= max_sessions:
299
+ break
300
+
301
+ return stats