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.
- brainlayer/__init__.py +3 -0
- brainlayer/cli/__init__.py +1545 -0
- brainlayer/cli/wizard.py +132 -0
- brainlayer/cli_new.py +151 -0
- brainlayer/client.py +164 -0
- brainlayer/clustering.py +736 -0
- brainlayer/daemon.py +1105 -0
- brainlayer/dashboard/README.md +129 -0
- brainlayer/dashboard/__init__.py +5 -0
- brainlayer/dashboard/app.py +151 -0
- brainlayer/dashboard/search.py +229 -0
- brainlayer/dashboard/views.py +230 -0
- brainlayer/embeddings.py +131 -0
- brainlayer/engine.py +550 -0
- brainlayer/index_new.py +87 -0
- brainlayer/mcp/__init__.py +1558 -0
- brainlayer/migrate.py +205 -0
- brainlayer/paths.py +43 -0
- brainlayer/pipeline/__init__.py +47 -0
- brainlayer/pipeline/analyze_communication.py +508 -0
- brainlayer/pipeline/brain_graph.py +567 -0
- brainlayer/pipeline/chat_tags.py +63 -0
- brainlayer/pipeline/chunk.py +422 -0
- brainlayer/pipeline/classify.py +472 -0
- brainlayer/pipeline/cluster_sampling.py +73 -0
- brainlayer/pipeline/enrichment.py +810 -0
- brainlayer/pipeline/extract.py +66 -0
- brainlayer/pipeline/extract_claude_desktop.py +149 -0
- brainlayer/pipeline/extract_corrections.py +231 -0
- brainlayer/pipeline/extract_markdown.py +195 -0
- brainlayer/pipeline/extract_whatsapp.py +227 -0
- brainlayer/pipeline/git_overlay.py +301 -0
- brainlayer/pipeline/longitudinal_analyzer.py +568 -0
- brainlayer/pipeline/obsidian_export.py +455 -0
- brainlayer/pipeline/operation_grouping.py +486 -0
- brainlayer/pipeline/plan_linking.py +313 -0
- brainlayer/pipeline/sanitize.py +549 -0
- brainlayer/pipeline/semantic_style.py +574 -0
- brainlayer/pipeline/session_enrichment.py +472 -0
- brainlayer/pipeline/style_embed.py +67 -0
- brainlayer/pipeline/style_index.py +139 -0
- brainlayer/pipeline/temporal_chains.py +203 -0
- brainlayer/pipeline/time_batcher.py +248 -0
- brainlayer/pipeline/unified_timeline.py +569 -0
- brainlayer/storage.py +66 -0
- brainlayer/store.py +155 -0
- brainlayer/taxonomy.json +80 -0
- brainlayer/vector_store.py +1891 -0
- brainlayer-1.0.0.dist-info/METADATA +313 -0
- brainlayer-1.0.0.dist-info/RECORD +53 -0
- brainlayer-1.0.0.dist-info/WHEEL +4 -0
- brainlayer-1.0.0.dist-info/entry_points.txt +4 -0
- 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
|