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,567 @@
1
+ """
2
+ Brain Graph Pipeline — Transform BrainLayer chunks into a visualization-ready graph.json.
3
+
4
+ Takes 240K+ chunks, aggregates to session level, computes hybrid similarity,
5
+ runs Leiden community detection, and generates a 500-2K node graph with
6
+ pre-computed 3D coordinates via UMAP.
7
+
8
+ Usage:
9
+ brainlayer brain-export [--output PATH] [--project NAME]
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ import struct
15
+ import time
16
+ from collections import Counter, defaultdict
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ import numpy as np
21
+ from sklearn.feature_extraction.text import TfidfVectorizer
22
+ from sklearn.metrics.pairwise import cosine_similarity
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # ─── Constants ──────────────────────────────────────────────────────
27
+
28
+ DEFAULT_OUTPUT_DIR = Path.home() / ".brainlayer-brain"
29
+ MIN_CHUNKS_PER_SESSION = 3 # Skip tiny sessions
30
+ MAX_SESSIONS = 5000 # Cap for performance
31
+ KNN_NEIGHBORS = 15 # Each node connects to its K nearest neighbors
32
+ LEIDEN_RESOLUTIONS = [0.3, 0.8, 2.0] # Coarse → medium → fine
33
+
34
+
35
+ # ─── Data Loading ───────────────────────────────────────────────────
36
+
37
+
38
+ def load_sessions(db_path: str, project: Optional[str] = None) -> list[dict]:
39
+ """Load session data from BrainLayer DB, using source_file as the session unit.
40
+
41
+ Each source_file (JSONL conversation) becomes one node in the graph.
42
+ Falls back to session_context if available, but builds from chunks otherwise.
43
+ """
44
+ import apsw
45
+ import sqlite_vec
46
+
47
+ conn = apsw.Connection(db_path, flags=apsw.SQLITE_OPEN_READONLY)
48
+ conn.enableloadextension(True)
49
+ conn.loadextension(sqlite_vec.loadable_path())
50
+ conn.enableloadextension(False)
51
+ cursor = conn.cursor()
52
+
53
+ # Build session_context lookup (sparse — only recent sessions have this)
54
+ context_by_sid = {}
55
+ for row in cursor.execute(
56
+ "SELECT session_id, project, branch, pr_number, files_changed, "
57
+ "started_at, ended_at, plan_name, plan_phase FROM session_context"
58
+ ):
59
+ sid, proj, branch, pr, files_json, started, ended, plan, phase = row
60
+ context_by_sid[sid] = {
61
+ "project": proj or "",
62
+ "branch": branch or "",
63
+ "pr_number": pr,
64
+ "files": json.loads(files_json) if files_json else [],
65
+ "started_at": started or "",
66
+ "ended_at": ended or "",
67
+ "plan_name": plan or "",
68
+ "plan_phase": phase or "",
69
+ }
70
+
71
+ # Get all source_files with their chunk counts and metadata
72
+ query = """
73
+ SELECT source_file, project, COUNT(*) as chunk_count,
74
+ GROUP_CONCAT(DISTINCT content_type) as types,
75
+ GROUP_CONCAT(DISTINCT intent) as intents,
76
+ AVG(CASE WHEN importance IS NOT NULL THEN importance END) as avg_importance,
77
+ (SELECT source FROM chunks c2
78
+ WHERE c2.source_file = chunks.source_file AND c2.source IS NOT NULL
79
+ GROUP BY source ORDER BY COUNT(*) DESC LIMIT 1) as dominant_source
80
+ FROM chunks
81
+ """
82
+ params = []
83
+ if project:
84
+ query += " WHERE project = ?"
85
+ params.append(project)
86
+ query += " GROUP BY source_file HAVING chunk_count >= ? ORDER BY chunk_count DESC"
87
+ params.append(MIN_CHUNKS_PER_SESSION)
88
+
89
+ source_files = list(cursor.execute(query, params))
90
+ logger.info(f"Found {len(source_files)} source files with >= {MIN_CHUNKS_PER_SESSION} chunks")
91
+
92
+ # Cap to MAX_SESSIONS by chunk count (biggest sessions = most informative)
93
+ if len(source_files) > MAX_SESSIONS:
94
+ source_files = source_files[:MAX_SESSIONS]
95
+ logger.info(f"Capped to {MAX_SESSIONS} source files")
96
+
97
+ # Build sessions from source files
98
+ sessions = []
99
+ embed_dim = None
100
+ EMBED_SAMPLE = 20 # Sample up to 20 embeddings per session for mean-pooling
101
+
102
+ for i, (
103
+ src_file,
104
+ proj,
105
+ chunk_count,
106
+ types_str,
107
+ intents_str,
108
+ avg_imp,
109
+ dominant_source,
110
+ ) in enumerate(source_files):
111
+ if i % 200 == 0 and i > 0:
112
+ logger.info(f"Processing session {i}/{len(source_files)}...")
113
+
114
+ # Extract session ID from source_file path (UUID part)
115
+ fname = Path(src_file).stem
116
+ sid = fname if len(fname) <= 40 else fname[:36] # UUID or truncated
117
+
118
+ # Try to match with session_context
119
+ ctx = None
120
+ for ctx_sid, ctx_data in context_by_sid.items():
121
+ if ctx_sid[:8] in fname:
122
+ ctx = ctx_data
123
+ break
124
+
125
+ # Get sample of content for TF-IDF (first 20 chunks)
126
+ text_rows = list(
127
+ cursor.execute(
128
+ "SELECT content FROM chunks WHERE source_file = ? AND content IS NOT NULL ORDER BY ROWID ASC LIMIT 20",
129
+ (src_file,),
130
+ )
131
+ )
132
+ text = " ".join(row[0][:2000] for row in text_rows if row[0])
133
+
134
+ # Get sampled embeddings (evenly spaced for representative mean)
135
+ embed_rows = list(
136
+ cursor.execute(
137
+ "SELECT cv.embedding FROM chunk_vectors cv "
138
+ "JOIN chunks c ON cv.chunk_id = c.id "
139
+ "WHERE c.source_file = ? ORDER BY c.ROWID ASC",
140
+ (src_file,),
141
+ )
142
+ )
143
+
144
+ embedding = None
145
+ if embed_rows:
146
+ # Sample evenly from available embeddings
147
+ step = max(1, len(embed_rows) // EMBED_SAMPLE)
148
+ sampled = embed_rows[::step][:EMBED_SAMPLE]
149
+ vecs = []
150
+ for row in sampled:
151
+ if row[0] and len(row[0]) >= 4:
152
+ vec = np.array(struct.unpack(f"{len(row[0]) // 4}f", row[0]))
153
+ vecs.append(vec)
154
+ if embed_dim is None:
155
+ embed_dim = len(vec)
156
+ if vecs:
157
+ embedding = np.mean(vecs, axis=0)
158
+
159
+ if embedding is None:
160
+ continue
161
+
162
+ content_types = Counter(t.strip() for t in (types_str or "").split(",") if t.strip())
163
+ intents = Counter(t.strip() for t in (intents_str or "").split(",") if t.strip())
164
+
165
+ sessions.append(
166
+ {
167
+ "id": sid,
168
+ "source_file": src_file,
169
+ "project": (ctx["project"] if ctx else proj) or "",
170
+ "branch": ctx["branch"] if ctx else "",
171
+ "pr_number": ctx["pr_number"] if ctx else None,
172
+ "files": ctx["files"] if ctx else [],
173
+ "started_at": ctx["started_at"] if ctx else "",
174
+ "ended_at": ctx["ended_at"] if ctx else "",
175
+ "plan_name": ctx["plan_name"] if ctx else "",
176
+ "plan_phase": ctx["plan_phase"] if ctx else "",
177
+ "chunk_count": chunk_count,
178
+ "content_types": content_types,
179
+ "intents": intents,
180
+ "importance": float(avg_imp) if avg_imp is not None else 5.0,
181
+ "source": dominant_source or "claude_code",
182
+ "text": text,
183
+ "embedding": embedding,
184
+ }
185
+ )
186
+
187
+ logger.info(f"Built {len(sessions)} sessions with embeddings")
188
+ return sessions
189
+
190
+
191
+ # ─── Similarity Matrix ──────────────────────────────────────────────
192
+
193
+
194
+ def compute_similarity_matrix(sessions: list[dict]) -> np.ndarray:
195
+ """Build hybrid similarity: 40% semantic + 35% file overlap + 15% temporal + 10% branch."""
196
+ n = len(sessions)
197
+ logger.info(f"Computing {n}x{n} similarity matrix...")
198
+ t0 = time.time()
199
+
200
+ # 1. Semantic similarity (cosine on mean embeddings) — 40%
201
+ embeddings = np.array([s["embedding"] for s in sessions])
202
+ semantic_sim = cosine_similarity(embeddings)
203
+ np.fill_diagonal(semantic_sim, 0)
204
+
205
+ # 2. File overlap (Jaccard) — 35%
206
+ file_sim = np.zeros((n, n))
207
+ file_sets = [set(s["files"]) for s in sessions]
208
+ for i in range(n):
209
+ if not file_sets[i]:
210
+ continue
211
+ for j in range(i + 1, n):
212
+ if not file_sets[j]:
213
+ continue
214
+ intersection = len(file_sets[i] & file_sets[j])
215
+ union = len(file_sets[i] | file_sets[j])
216
+ if union > 0:
217
+ sim = intersection / union
218
+ file_sim[i, j] = sim
219
+ file_sim[j, i] = sim
220
+
221
+ # 3. Temporal proximity — 15%
222
+ temporal_sim = np.zeros((n, n))
223
+ timestamps = []
224
+ for s in sessions:
225
+ try:
226
+ from datetime import datetime
227
+
228
+ ts = datetime.fromisoformat(s["started_at"].replace("Z", "+00:00"))
229
+ timestamps.append(ts.timestamp())
230
+ except (ValueError, AttributeError):
231
+ timestamps.append(0)
232
+
233
+ for i in range(n):
234
+ if not timestamps[i]:
235
+ continue
236
+ for j in range(i + 1, n):
237
+ if not timestamps[j]:
238
+ continue
239
+ hours_apart = abs(timestamps[i] - timestamps[j]) / 3600
240
+ # Decay: sessions within 24h are similar, beyond 7 days → 0
241
+ sim = max(0, 1 - hours_apart / 168) # 168 hours = 7 days
242
+ temporal_sim[i, j] = sim
243
+ temporal_sim[j, i] = sim
244
+
245
+ # 4. Branch/PR similarity — 10%
246
+ branch_sim = np.zeros((n, n))
247
+ for i in range(n):
248
+ if not sessions[i]["branch"]:
249
+ continue
250
+ for j in range(i + 1, n):
251
+ if sessions[i]["branch"] == sessions[j]["branch"]:
252
+ branch_sim[i, j] = 1.0
253
+ branch_sim[j, i] = 1.0
254
+ elif sessions[i]["plan_name"] and sessions[i]["plan_name"] == sessions[j]["plan_name"]:
255
+ branch_sim[i, j] = 0.7
256
+ branch_sim[j, i] = 0.7
257
+
258
+ # Combine with weights
259
+ hybrid = 0.40 * semantic_sim + 0.35 * file_sim + 0.15 * temporal_sim + 0.10 * branch_sim
260
+
261
+ elapsed = time.time() - t0
262
+ logger.info(f"Similarity matrix computed in {elapsed:.1f}s")
263
+ return hybrid
264
+
265
+
266
+ # ─── Community Detection ────────────────────────────────────────────
267
+
268
+
269
+ def detect_communities(
270
+ similarity: np.ndarray,
271
+ sessions: list[dict],
272
+ ) -> dict[str, list[int]]:
273
+ """Run hierarchical Leiden community detection at multiple resolutions.
274
+
275
+ Uses KNN graph instead of threshold — each node connects to its K nearest
276
+ neighbors, guaranteeing every node has connections. This avoids the
277
+ threshold cliff problem (similarity distribution has a sharp drop-off).
278
+ """
279
+ import igraph as ig
280
+ import leidenalg
281
+
282
+ n = len(sessions)
283
+ k = min(KNN_NEIGHBORS, n - 1)
284
+
285
+ # Build symmetric KNN graph
286
+ edge_set: set[tuple[int, int]] = set()
287
+ weight_map: dict[tuple[int, int], float] = {}
288
+
289
+ for i in range(n):
290
+ # Get top-K most similar nodes for this node
291
+ sims = similarity[i].copy()
292
+ sims[i] = -1 # Exclude self
293
+ top_k = np.argpartition(sims, -k)[-k:]
294
+ for j_idx in top_k:
295
+ j = int(j_idx)
296
+ edge = (min(i, j), max(i, j))
297
+ if edge not in edge_set:
298
+ edge_set.add(edge)
299
+ weight_map[edge] = float(similarity[i, j])
300
+ else:
301
+ weight_map[edge] = max(weight_map[edge], float(similarity[i, j]))
302
+
303
+ edges = list(edge_set)
304
+ weights = [max(0.001, weight_map[e]) for e in edges] # Leiden rejects negatives
305
+
306
+ g = ig.Graph(n=n, edges=edges, directed=False)
307
+ g.es["weight"] = weights
308
+
309
+ logger.info(f"KNN graph (k={k}): {g.vcount()} nodes, {g.ecount()} edges")
310
+
311
+ hierarchy = {}
312
+ for resolution in LEIDEN_RESOLUTIONS:
313
+ partition = leidenalg.find_partition(
314
+ g,
315
+ leidenalg.RBConfigurationVertexPartition,
316
+ weights=weights if weights else None,
317
+ resolution_parameter=resolution,
318
+ n_iterations=-1, # Run until convergence
319
+ )
320
+ label = (
321
+ "coarse"
322
+ if resolution == LEIDEN_RESOLUTIONS[0]
323
+ else "fine"
324
+ if resolution == LEIDEN_RESOLUTIONS[-1]
325
+ else "medium"
326
+ )
327
+ hierarchy[label] = partition.membership
328
+ n_communities = len(set(partition.membership))
329
+ logger.info(
330
+ f"Leiden {label} (res={resolution}): {n_communities} communities, modularity={partition.modularity:.3f}"
331
+ )
332
+
333
+ return hierarchy
334
+
335
+
336
+ # ─── Cluster Labeling ───────────────────────────────────────────────
337
+
338
+
339
+ def label_communities(
340
+ sessions: list[dict],
341
+ membership: list[int],
342
+ ) -> dict[int, str]:
343
+ """Generate c-TF-IDF labels for each community."""
344
+ communities = defaultdict(list)
345
+ for idx, comm_id in enumerate(membership):
346
+ communities[comm_id].append(idx)
347
+
348
+ # Build per-community documents
349
+ docs = []
350
+ comm_ids = sorted(communities.keys())
351
+ for comm_id in comm_ids:
352
+ members = communities[comm_id]
353
+ text = " ".join(sessions[i]["text"][:5000] for i in members)
354
+ docs.append(text)
355
+
356
+ if not docs:
357
+ return {}
358
+
359
+ # TF-IDF
360
+ vectorizer = TfidfVectorizer(
361
+ max_features=1000,
362
+ stop_words="english",
363
+ ngram_range=(1, 2),
364
+ min_df=1,
365
+ max_df=0.8,
366
+ )
367
+ try:
368
+ tfidf = vectorizer.fit_transform(docs)
369
+ except ValueError:
370
+ return {c: f"cluster-{c}" for c in comm_ids}
371
+
372
+ feature_names = vectorizer.get_feature_names_out()
373
+ labels = {}
374
+ for i, comm_id in enumerate(comm_ids):
375
+ top_indices = tfidf[i].toarray()[0].argsort()[-3:][::-1]
376
+ top_terms = [feature_names[idx] for idx in top_indices if tfidf[i, idx] > 0]
377
+ labels[comm_id] = " / ".join(top_terms[:3]) if top_terms else f"cluster-{comm_id}"
378
+
379
+ return labels
380
+
381
+
382
+ # ─── UMAP 3D Layout ────────────────────────────────────────────────
383
+
384
+
385
+ def compute_layout(sessions: list[dict]) -> np.ndarray:
386
+ """Compute 3D positions via UMAP on session embeddings."""
387
+ import umap
388
+
389
+ embeddings = np.array([s["embedding"] for s in sessions])
390
+ n = len(sessions)
391
+
392
+ n_neighbors = min(15, n - 1) if n > 1 else 1
393
+ reducer = umap.UMAP(
394
+ n_components=3,
395
+ n_neighbors=n_neighbors,
396
+ min_dist=0.3,
397
+ metric="cosine",
398
+ random_state=42,
399
+ )
400
+ coords = reducer.fit_transform(embeddings)
401
+
402
+ # Normalize to [-50, 50] range for Three.js
403
+ for dim in range(3):
404
+ mn, mx = coords[:, dim].min(), coords[:, dim].max()
405
+ if mx > mn:
406
+ coords[:, dim] = (coords[:, dim] - mn) / (mx - mn) * 100 - 50
407
+
408
+ logger.info(f"UMAP 3D layout computed for {n} nodes")
409
+ return coords
410
+
411
+
412
+ # ─── Graph Export ───────────────────────────────────────────────────
413
+
414
+
415
+ def dominant_type(counter: Counter) -> str:
416
+ """Get the most common content type or intent."""
417
+ if not counter:
418
+ return "unknown"
419
+ return counter.most_common(1)[0][0]
420
+
421
+
422
+ def build_graph_json(
423
+ sessions: list[dict],
424
+ similarity: np.ndarray,
425
+ hierarchy: dict[str, list[int]],
426
+ labels: dict[int, str],
427
+ coords: np.ndarray,
428
+ ) -> dict:
429
+ """Build the final graph.json structure."""
430
+ medium_membership = hierarchy.get("medium", hierarchy.get("coarse", [0] * len(sessions)))
431
+
432
+ nodes = []
433
+ for i, s in enumerate(sessions):
434
+ comm = medium_membership[i] if i < len(medium_membership) else 0
435
+ nodes.append(
436
+ {
437
+ "id": s["id"][:8],
438
+ "session_id": s["id"],
439
+ "label": labels.get(comm, f"cluster-{comm}"),
440
+ "community": {
441
+ level: membership[i] if i < len(membership) else 0 for level, membership in hierarchy.items()
442
+ },
443
+ "x": round(float(coords[i, 0]), 2),
444
+ "y": round(float(coords[i, 1]), 2),
445
+ "z": round(float(coords[i, 2]), 2),
446
+ "size": round(float(np.clip(s["importance"] / 10 * 5 + 1, 1, 8)), 2),
447
+ "color_type": dominant_type(s["intents"]),
448
+ "source": s.get("source", "claude_code"),
449
+ "project": s["project"],
450
+ "branch": s["branch"],
451
+ "plan": s["plan_name"],
452
+ "chunk_count": s["chunk_count"],
453
+ "files_count": len(s["files"]),
454
+ "started_at": s["started_at"],
455
+ "importance": round(float(s["importance"]), 2),
456
+ }
457
+ )
458
+
459
+ # Build edges (top N per node to avoid clutter)
460
+ edges = []
461
+ n = len(sessions)
462
+ max_edges_per_node = 5
463
+ for i in range(n):
464
+ # Get top connections for this node
465
+ row = similarity[i].copy()
466
+ row[i] = 0
467
+ top_j = np.argsort(row)[-max_edges_per_node:]
468
+ for j in top_j:
469
+ if row[j] > 0 and i < j:
470
+ edges.append(
471
+ {
472
+ "source": sessions[i]["id"][:8],
473
+ "target": sessions[j]["id"][:8],
474
+ "weight": round(float(row[j]), 3),
475
+ }
476
+ )
477
+
478
+ # Build hierarchy info
479
+ hierarchy_info = {}
480
+ for level, membership in hierarchy.items():
481
+ communities = defaultdict(list)
482
+ for idx, comm_id in enumerate(membership):
483
+ communities[comm_id].append(sessions[idx]["id"][:8])
484
+ hierarchy_info[level] = {
485
+ str(comm_id): {
486
+ "label": labels.get(comm_id, f"cluster-{comm_id}"),
487
+ "members": members,
488
+ "size": len(members),
489
+ }
490
+ for comm_id, members in communities.items()
491
+ }
492
+
493
+ return {
494
+ "nodes": nodes,
495
+ "edges": edges,
496
+ "hierarchy": hierarchy_info,
497
+ "meta": {
498
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
499
+ "session_count": len(sessions),
500
+ "node_count": len(nodes),
501
+ "edge_count": len(edges),
502
+ "community_counts": {level: len(set(m)) for level, m in hierarchy.items()},
503
+ },
504
+ }
505
+
506
+
507
+ # ─── Main Pipeline ──────────────────────────────────────────────────
508
+
509
+
510
+ from ..paths import DEFAULT_DB_PATH
511
+
512
+
513
+ def generate_brain_graph(
514
+ db_path: Optional[str] = None,
515
+ output_dir: Optional[str] = None,
516
+ project: Optional[str] = None,
517
+ ) -> Path:
518
+ """Run the full brain graph pipeline."""
519
+ db = db_path or str(DEFAULT_DB_PATH)
520
+ out = Path(output_dir) if output_dir else DEFAULT_OUTPUT_DIR
521
+ out.mkdir(parents=True, exist_ok=True)
522
+
523
+ t0 = time.time()
524
+
525
+ # Step 1: Load sessions
526
+ logger.info("Step 1: Loading sessions...")
527
+ sessions = load_sessions(db, project)
528
+ if len(sessions) < 2:
529
+ raise ValueError(f"Need at least 2 sessions, found {len(sessions)}")
530
+
531
+ # Step 2: Compute similarity matrix
532
+ logger.info("Step 2: Computing similarity matrix...")
533
+ similarity = compute_similarity_matrix(sessions)
534
+
535
+ # Step 3: Community detection
536
+ logger.info("Step 3: Running Leiden community detection...")
537
+ hierarchy = detect_communities(similarity, sessions)
538
+
539
+ # Step 4: Label communities
540
+ logger.info("Step 4: Generating community labels...")
541
+ medium = hierarchy.get("medium", hierarchy.get("coarse", []))
542
+ labels = label_communities(sessions, medium)
543
+
544
+ # Step 5: UMAP 3D layout
545
+ logger.info("Step 5: Computing UMAP 3D layout...")
546
+ coords = compute_layout(sessions)
547
+
548
+ # Step 6: Build and export graph
549
+ logger.info("Step 6: Building graph.json...")
550
+ graph = build_graph_json(sessions, similarity, hierarchy, labels, coords)
551
+
552
+ graph_path = out / "graph.json"
553
+ with open(graph_path, "w") as f:
554
+ json.dump(graph, f, indent=2)
555
+
556
+ meta_path = out / "metadata.json"
557
+ with open(meta_path, "w") as f:
558
+ json.dump(graph["meta"], f, indent=2)
559
+
560
+ elapsed = time.time() - t0
561
+ logger.info(
562
+ f"Brain graph exported to {graph_path} "
563
+ f"({graph['meta']['node_count']} nodes, {graph['meta']['edge_count']} edges) "
564
+ f"in {elapsed:.1f}s"
565
+ )
566
+
567
+ return graph_path
@@ -0,0 +1,63 @@
1
+ """Chat tagging for relationship context (family, friends, co-workers)."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ try:
7
+ import yaml
8
+
9
+ HAS_YAML = True
10
+ except ImportError:
11
+ HAS_YAML = False
12
+
13
+
14
+ def get_chat_tags_path() -> Path:
15
+ """Default path for chat-tags config."""
16
+ return Path.home() / ".config" / "brainlayer" / "chat-tags.yaml"
17
+
18
+
19
+ def load_chat_tags(config_path: Optional[Path] = None) -> dict[str, str]:
20
+ """
21
+ Load chat_id/contact_name -> tag mapping from YAML.
22
+
23
+ Returns:
24
+ Dict mapping contact_name or chat_id to tag (family, friends, co-workers, etc.)
25
+ """
26
+ if not HAS_YAML:
27
+ return {}
28
+
29
+ path = config_path or get_chat_tags_path()
30
+ if not path.exists():
31
+ return {}
32
+
33
+ with open(path, "r", encoding="utf-8") as f:
34
+ data = yaml.safe_load(f)
35
+
36
+ if not data or "tags" not in data:
37
+ return {}
38
+
39
+ mapping = {}
40
+ for entry in data["tags"]:
41
+ tag = entry.get("tag")
42
+ if not tag:
43
+ continue
44
+ if "contact" in entry:
45
+ mapping[entry["contact"]] = tag
46
+ if "jid" in entry or "chat_id" in entry:
47
+ jid = entry.get("jid") or entry.get("chat_id")
48
+ mapping[jid] = tag
49
+
50
+ return mapping
51
+
52
+
53
+ def get_tag_for_message(
54
+ contact_name: Optional[str],
55
+ chat_id: Optional[str],
56
+ tags: dict[str, str],
57
+ ) -> Optional[str]:
58
+ """Get relationship tag for a message. Checks contact_name first, then chat_id."""
59
+ if contact_name and contact_name in tags:
60
+ return tags[contact_name]
61
+ if chat_id and chat_id in tags:
62
+ return tags[chat_id]
63
+ return None