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,472 @@
1
+ """Session-level enrichment pipeline — analyze full conversations, not just chunks.
2
+
3
+ Processes sessions through local LLM (Ollama/MLX) to extract:
4
+ - Session summary + primary intent + outcome
5
+ - Decisions made (with rationale)
6
+ - Corrections (what the user corrected)
7
+ - Learnings (new knowledge gained)
8
+ - Mistakes (what went wrong)
9
+ - Patterns (recurring behaviors)
10
+ - Tool usage statistics
11
+ - Quality scores
12
+
13
+ Usage:
14
+ from brainlayer.pipeline.session_enrichment import enrich_session
15
+ result = enrich_session(store, session_id, call_llm_fn)
16
+
17
+ CLI:
18
+ brainlayer enrich-sessions
19
+ brainlayer enrich-sessions --project my-project --since 2026-01-01
20
+ """
21
+
22
+ import json
23
+ from datetime import datetime
24
+ from typing import Any, Callable, Dict, List, Optional, Tuple
25
+
26
+ from ..vector_store import VectorStore
27
+
28
+ # Valid values for structured fields
29
+ VALID_INTENTS = [
30
+ "debugging",
31
+ "designing",
32
+ "configuring",
33
+ "discussing",
34
+ "deciding",
35
+ "implementing",
36
+ "reviewing",
37
+ "refactoring",
38
+ "deploying",
39
+ "testing",
40
+ ]
41
+ VALID_OUTCOMES = ["success", "partial_success", "failure", "abandoned", "ongoing"]
42
+
43
+ # Maximum conversation length to send to LLM (in characters)
44
+ MAX_CONVERSATION_CHARS = 12_000
45
+
46
+
47
+ def reconstruct_session(store: VectorStore, session_id: str) -> Dict[str, Any]:
48
+ """Reassemble ordered chunks from a session into a coherent conversation.
49
+
50
+ Chunks are identified by source_file matching the session_id pattern.
51
+ Returns a dict with conversation text, message counts, timing, and metadata.
52
+ """
53
+ cursor = store.conn.cursor()
54
+
55
+ # Find chunks belonging to this session, ordered by creation time
56
+ # Session ID maps to source_file (the JSONL filename stem) or conversation_id
57
+ rows = list(
58
+ cursor.execute(
59
+ """SELECT id, content, content_type, source_file, created_at,
60
+ char_count, source, conversation_id
61
+ FROM chunks
62
+ WHERE (source_file LIKE ? OR conversation_id = ?)
63
+ ORDER BY created_at, rowid""",
64
+ (f"%{session_id}%", session_id),
65
+ )
66
+ )
67
+
68
+ if not rows:
69
+ return {"chunks": [], "conversation": "", "message_count": 0}
70
+
71
+ chunks = []
72
+ user_count = 0
73
+ assistant_count = 0
74
+ tool_count = 0
75
+ first_time = None
76
+ last_time = None
77
+
78
+ for row in rows:
79
+ chunk = {
80
+ "id": row[0],
81
+ "content": row[1],
82
+ "content_type": row[2],
83
+ "source_file": row[3],
84
+ "created_at": row[4],
85
+ "char_count": row[5],
86
+ "source": row[6],
87
+ "conversation_id": row[7],
88
+ }
89
+ chunks.append(chunk)
90
+
91
+ # Count message types
92
+ ct = chunk["content_type"] or ""
93
+ if ct == "user_message":
94
+ user_count += 1
95
+ elif ct in ("assistant_text", "ai_code"):
96
+ assistant_count += 1
97
+ elif ct in ("tool_result", "tool_use"):
98
+ tool_count += 1
99
+
100
+ # Track timing
101
+ if chunk["created_at"]:
102
+ if first_time is None:
103
+ first_time = chunk["created_at"]
104
+ last_time = chunk["created_at"]
105
+
106
+ # Build conversation text for LLM analysis
107
+ conversation_parts = []
108
+ total_chars = 0
109
+
110
+ for chunk in chunks:
111
+ ct = chunk["content_type"] or "unknown"
112
+ content = chunk["content"] or ""
113
+
114
+ # Skip noise types
115
+ if ct in ("noise", "dir_listing", "build_log", "queue-operation"):
116
+ continue
117
+
118
+ # Truncate very long chunks (file reads, large code blocks)
119
+ if len(content) > 2000:
120
+ content = content[:2000] + "\n[... truncated]"
121
+
122
+ # Format based on type
123
+ if ct == "user_message":
124
+ conversation_parts.append(f"USER: {content}")
125
+ elif ct == "assistant_text":
126
+ conversation_parts.append(f"ASSISTANT: {content}")
127
+ elif ct == "ai_code":
128
+ conversation_parts.append(f"ASSISTANT [code]: {content}")
129
+ elif ct == "stack_trace":
130
+ conversation_parts.append(f"ERROR: {content}")
131
+ elif ct == "git_diff":
132
+ conversation_parts.append(f"DIFF: {content}")
133
+ elif ct == "file_read":
134
+ # Summarize file reads (often very long)
135
+ lines = content.split("\n")
136
+ conversation_parts.append(f"FILE_READ ({len(lines)} lines): {content[:500]}")
137
+ else:
138
+ conversation_parts.append(f"[{ct}]: {content[:500]}")
139
+
140
+ total_chars += len(conversation_parts[-1])
141
+
142
+ # Stop if we've exceeded the character limit
143
+ if total_chars > MAX_CONVERSATION_CHARS:
144
+ conversation_parts.append(f"\n[... {len(chunks) - len(conversation_parts)} more chunks truncated]")
145
+ break
146
+
147
+ conversation = "\n\n".join(conversation_parts)
148
+
149
+ # Calculate duration
150
+ duration_seconds = None
151
+ if first_time and last_time:
152
+ try:
153
+ t1 = datetime.fromisoformat(first_time.replace("Z", "+00:00"))
154
+ t2 = datetime.fromisoformat(last_time.replace("Z", "+00:00"))
155
+ duration_seconds = int((t2 - t1).total_seconds())
156
+ except (ValueError, TypeError):
157
+ pass
158
+
159
+ return {
160
+ "chunks": chunks,
161
+ "conversation": conversation,
162
+ "message_count": len(chunks),
163
+ "user_message_count": user_count,
164
+ "assistant_message_count": assistant_count,
165
+ "tool_call_count": tool_count,
166
+ "session_start_time": first_time,
167
+ "session_end_time": last_time,
168
+ "duration_seconds": duration_seconds,
169
+ }
170
+
171
+
172
+ # Session analysis prompt — single-pass for local LLM efficiency
173
+ SESSION_ANALYSIS_PROMPT = """You are a session analysis assistant. Analyze this Claude Code conversation and return ONLY a JSON object.
174
+
175
+ CONVERSATION (session from project: {project}):
176
+ ---
177
+ {conversation}
178
+ ---
179
+
180
+ Return this exact JSON structure:
181
+ {{
182
+ "session_summary": "<2-3 sentence summary of what happened in this session>",
183
+ "primary_intent": "<one of: debugging, designing, configuring, discussing, deciding, implementing, reviewing, refactoring, deploying, testing>",
184
+ "outcome": "<one of: success, partial_success, failure, abandoned, ongoing>",
185
+ "complexity_score": <1-10 integer>,
186
+ "session_quality_score": <1-10 integer>,
187
+ "decisions_made": [
188
+ {{"decision": "<what was decided>", "rationale": "<why>"}}
189
+ ],
190
+ "corrections": [
191
+ {{"what_was_wrong": "<what the AI did wrong>", "what_user_wanted": "<correct behavior>"}}
192
+ ],
193
+ "learnings": [
194
+ "<new knowledge or insight gained during this session>"
195
+ ],
196
+ "mistakes": [
197
+ "<what went wrong and how it was resolved>"
198
+ ],
199
+ "patterns": [
200
+ "<recurring behaviors or approaches observed>"
201
+ ],
202
+ "topic_tags": ["<tag1>", "<tag2>"],
203
+ "tool_usage_stats": [
204
+ {{"tool": "<tool name>", "count": <number>}}
205
+ ],
206
+ "what_worked": "<what went well in this session>",
207
+ "what_failed": "<what didn't work or caused problems>"
208
+ }}
209
+
210
+ SCORING RULES:
211
+ - complexity_score: 1-3 trivial (quick fix), 4-6 moderate (feature work), 7-9 complex (architecture), 10 critical
212
+ - session_quality_score: 1-3 poor (many errors, user frustrated), 4-6 average, 7-9 good (smooth), 10 exceptional
213
+
214
+ EXTRACTION RULES:
215
+ - decisions: Only extract REAL decisions — "we chose X over Y because Z"
216
+ - corrections: Only when the user explicitly corrected the AI's approach
217
+ - learnings: Concrete knowledge, not vague observations
218
+ - mistakes: What actually failed, not hypothetical risks
219
+ - topic_tags: lowercase, hyphenated (e.g., "bug-fix", "api-design", "typescript")
220
+ - tool_usage_stats: List tools used (Read, Write, Edit, Bash, etc.) with approximate counts
221
+ - Empty arrays [] are fine when nothing matches a category
222
+
223
+ Return ONLY the JSON object, no other text."""
224
+
225
+
226
+ def build_session_prompt(conversation: str, project: str) -> str:
227
+ """Build the session analysis prompt."""
228
+ # Escape braces in conversation to avoid str.format() crash
229
+ safe_conversation = conversation.replace("{", "{{").replace("}", "}}")
230
+ return SESSION_ANALYSIS_PROMPT.format(
231
+ project=project or "unknown",
232
+ conversation=safe_conversation,
233
+ )
234
+
235
+
236
+ def parse_session_enrichment(text: str) -> Optional[Dict[str, Any]]:
237
+ """Parse LLM's JSON response into session enrichment data."""
238
+ if not text:
239
+ return None
240
+ try:
241
+ # Find JSON in response (handle LLM wrapping in markdown etc.)
242
+ match = None
243
+ for start in range(len(text)):
244
+ if text[start] == "{":
245
+ for end in range(len(text) - 1, start, -1):
246
+ if text[end] == "}":
247
+ try:
248
+ match = json.loads(text[start : end + 1])
249
+ break
250
+ except json.JSONDecodeError:
251
+ continue
252
+ if match:
253
+ break
254
+
255
+ if not match:
256
+ return None
257
+
258
+ result: Dict[str, Any] = {}
259
+
260
+ # Required: session_summary
261
+ summary = match.get("session_summary", "")
262
+ if isinstance(summary, str) and len(summary) > 10:
263
+ result["session_summary"] = summary[:1000]
264
+ else:
265
+ return None # Summary is required
266
+
267
+ # Intent
268
+ intent = match.get("primary_intent", "")
269
+ if isinstance(intent, str) and intent.lower().strip() in VALID_INTENTS:
270
+ result["primary_intent"] = intent.lower().strip()
271
+
272
+ # Outcome
273
+ outcome = match.get("outcome", "")
274
+ if isinstance(outcome, str) and outcome.lower().strip() in VALID_OUTCOMES:
275
+ result["outcome"] = outcome.lower().strip()
276
+
277
+ # Scores
278
+ for score_field in ("complexity_score", "session_quality_score"):
279
+ val = match.get(score_field)
280
+ if isinstance(val, (int, float)):
281
+ result[score_field] = max(1, min(10, int(val)))
282
+
283
+ # JSON array fields
284
+ for field in ("decisions_made", "corrections", "learnings", "mistakes", "patterns"):
285
+ val = match.get(field, [])
286
+ if isinstance(val, list):
287
+ result[field] = val[:20] # Cap at 20 items
288
+
289
+ # Topic tags
290
+ tags = match.get("topic_tags", [])
291
+ if isinstance(tags, list):
292
+ result["topic_tags"] = [str(t).lower().strip() for t in tags if isinstance(t, str)][:15]
293
+
294
+ # Tool usage
295
+ tool_stats = match.get("tool_usage_stats", [])
296
+ if isinstance(tool_stats, list):
297
+ result["tool_usage_stats"] = tool_stats[:20]
298
+
299
+ # Narratives
300
+ for field in ("what_worked", "what_failed"):
301
+ val = match.get(field)
302
+ if isinstance(val, str) and val.strip():
303
+ result[field] = val.strip()[:500]
304
+
305
+ return result
306
+
307
+ except Exception:
308
+ return None
309
+
310
+
311
+ def list_sessions_for_enrichment(
312
+ store: VectorStore,
313
+ project: Optional[str] = None,
314
+ since: Optional[str] = None,
315
+ ) -> List[Tuple[str, str, int]]:
316
+ """List session IDs available for enrichment.
317
+
318
+ Returns list of (session_id, project, chunk_count) tuples.
319
+ Sessions come from:
320
+ 1. session_context table (sessions with git overlay data)
321
+ 2. Distinct source_file values in chunks table (all sessions)
322
+ """
323
+ cursor = store.conn.cursor()
324
+ already_enriched = set(store.list_enriched_sessions())
325
+
326
+ sessions = []
327
+
328
+ # Method 1: session_context table (has richer metadata)
329
+ query = "SELECT session_id, project FROM session_context"
330
+ params: list = []
331
+ if project:
332
+ query += " WHERE project = ?"
333
+ params.append(project)
334
+ for row in cursor.execute(query, params):
335
+ sid, proj = row[0], row[1]
336
+ if sid not in already_enriched:
337
+ # Apply 'since' filter if provided
338
+ if since:
339
+ first_time = list(
340
+ cursor.execute(
341
+ "SELECT MIN(created_at) FROM chunks WHERE source_file LIKE ?",
342
+ (f"%{sid}%",),
343
+ )
344
+ )[0][0]
345
+ if first_time and first_time < since:
346
+ continue
347
+
348
+ # Count chunks for this session
349
+ count = list(
350
+ cursor.execute(
351
+ "SELECT COUNT(*) FROM chunks WHERE source_file LIKE ?",
352
+ (f"%{sid}%",),
353
+ )
354
+ )[0][0]
355
+ if count > 0:
356
+ sessions.append((sid, proj or "", count))
357
+ already_enriched.add(sid)
358
+
359
+ # Method 2: Distinct source_files from chunks (catches sessions without git overlay)
360
+ source_query = """
361
+ SELECT DISTINCT source_file, project, COUNT(*) as cnt
362
+ FROM chunks
363
+ WHERE source IS NULL OR source = 'claude_code'
364
+ GROUP BY source_file
365
+ HAVING cnt >= 3
366
+ """
367
+ for row in cursor.execute(source_query):
368
+ source_file = row[0] or ""
369
+ proj = row[1] or ""
370
+
371
+ if project and proj != project:
372
+ continue
373
+
374
+ # Extract session ID from source_file path
375
+ # Typical format: /path/to/.claude/projects/-Users-janedev-Gits-project/abc123.jsonl
376
+ import os
377
+
378
+ sid = os.path.splitext(os.path.basename(source_file))[0] if source_file else ""
379
+ if not sid or sid in already_enriched:
380
+ continue
381
+
382
+ # Apply 'since' filter if provided
383
+ if since:
384
+ first_time = list(
385
+ cursor.execute(
386
+ "SELECT MIN(created_at) FROM chunks WHERE source_file = ?",
387
+ (source_file,),
388
+ )
389
+ )[0][0]
390
+ if first_time and first_time < since:
391
+ continue
392
+
393
+ sessions.append((sid, proj, row[2]))
394
+ already_enriched.add(sid)
395
+
396
+ return sessions
397
+
398
+
399
+ def enrich_session(
400
+ store: VectorStore,
401
+ session_id: str,
402
+ call_llm_fn: Callable[[str], Optional[str]],
403
+ project: Optional[str] = None,
404
+ embed_fn: Optional[Callable[[str], List[float]]] = None,
405
+ model_name: str = "",
406
+ ) -> Optional[Dict[str, Any]]:
407
+ """Enrich a single session with LLM analysis.
408
+
409
+ Args:
410
+ store: VectorStore instance.
411
+ session_id: Session to enrich.
412
+ call_llm_fn: Function that takes a prompt string and returns LLM response.
413
+ project: Optional project name override.
414
+ embed_fn: Optional embedding function for session summary.
415
+ model_name: Name of the model used for enrichment tracking.
416
+
417
+ Returns:
418
+ Enrichment dict on success, None on failure.
419
+ """
420
+ # Step 1: Reconstruct conversation
421
+ session_data = reconstruct_session(store, session_id)
422
+ if not session_data["chunks"]:
423
+ return None
424
+
425
+ conversation = session_data["conversation"]
426
+ if not conversation or len(conversation) < 50:
427
+ return None
428
+
429
+ # Determine project from chunks if not provided
430
+ if not project:
431
+ # Try to get project from session_context
432
+ ctx = store.get_session_context(session_id)
433
+ if ctx:
434
+ project = ctx.get("project", "")
435
+
436
+ # Step 2: Build prompt and call LLM
437
+ prompt = build_session_prompt(conversation, project or "unknown")
438
+ response = call_llm_fn(prompt)
439
+
440
+ # Step 3: Parse response
441
+ enrichment = parse_session_enrichment(response)
442
+ if not enrichment:
443
+ return None
444
+
445
+ # Step 4: Add session metadata from reconstruction
446
+ enrichment["session_id"] = session_id
447
+ enrichment["file_path"] = session_data["chunks"][0].get("source_file") if session_data["chunks"] else None
448
+ enrichment["message_count"] = session_data["message_count"]
449
+ enrichment["user_message_count"] = session_data["user_message_count"]
450
+ enrichment["assistant_message_count"] = session_data["assistant_message_count"]
451
+ enrichment["tool_call_count"] = session_data["tool_call_count"]
452
+ enrichment["session_start_time"] = session_data["session_start_time"]
453
+ enrichment["session_end_time"] = session_data["session_end_time"]
454
+ enrichment["duration_seconds"] = session_data["duration_seconds"]
455
+ enrichment["enrichment_model"] = model_name
456
+ enrichment["enrichment_version"] = "1.0"
457
+
458
+ # Step 5: Generate summary embedding if embed_fn provided
459
+ if embed_fn and enrichment.get("session_summary"):
460
+ try:
461
+ from ..vector_store import serialize_f32
462
+
463
+ embedding = embed_fn(enrichment["session_summary"])
464
+ enrichment["summary_embedding"] = serialize_f32(embedding)
465
+ except Exception:
466
+ pass # Don't fail enrichment over embedding issues
467
+
468
+ # Step 6: Store in database
469
+ store.upsert_session_enrichment(enrichment)
470
+
471
+ # Return the stored version (with JSON fields properly deserialized)
472
+ return store.get_session_enrichment(session_id)
@@ -0,0 +1,67 @@
1
+ """Embeddings for communication style analysis.
2
+
3
+ Uses StyleDistance (HuggingFace) — the best model for style-aware clustering.
4
+ General embeddings (Qwen3, bge-m3) cluster by topic, not style; StyleDistance
5
+ clusters by how you write (formality, emoji, punctuation, phrasing).
6
+ """
7
+
8
+ from typing import Optional
9
+
10
+ try:
11
+ from sentence_transformers import SentenceTransformer
12
+
13
+ HAS_SENTENCE_TRANSFORMERS = True
14
+ except ImportError:
15
+ HAS_SENTENCE_TRANSFORMERS = False
16
+
17
+ from .unified_timeline import UnifiedMessage
18
+
19
+ # Best for style analysis: clusters by writing style, not content
20
+ STYLE_MODEL = "StyleDistance/mstyledistance"
21
+ MAX_EMBEDDING_CHARS = 8000
22
+
23
+
24
+ def _get_model() -> "SentenceTransformer":
25
+ """Load mStyleDistance. Multilingual (Hebrew+English)."""
26
+ if not HAS_SENTENCE_TRANSFORMERS:
27
+ raise ImportError("sentence-transformers required. Install: pip install sentence-transformers")
28
+ return SentenceTransformer(STYLE_MODEL)
29
+
30
+
31
+ def embed_message(text: str) -> list[float]:
32
+ """Embed a single message for style clustering."""
33
+ content = text[:MAX_EMBEDDING_CHARS] if len(text) > MAX_EMBEDDING_CHARS else text
34
+ encoder = _get_model()
35
+ emb = encoder.encode(content, convert_to_numpy=True)
36
+ return emb.tolist()
37
+
38
+
39
+ def embed_messages(
40
+ messages: list[UnifiedMessage],
41
+ batch_size: int = 32,
42
+ on_progress: Optional[callable] = None,
43
+ ) -> list[tuple[UnifiedMessage, list[float]]]:
44
+ """
45
+ Embed messages for style-aware cluster sampling.
46
+
47
+ Returns:
48
+ List of (message, embedding) tuples.
49
+ """
50
+ total = len(messages)
51
+ encoder = _get_model()
52
+ texts = [m.text[:MAX_EMBEDDING_CHARS] if len(m.text) > MAX_EMBEDDING_CHARS else m.text for m in messages]
53
+ embeddings = encoder.encode(
54
+ texts,
55
+ batch_size=batch_size,
56
+ show_progress_bar=False,
57
+ convert_to_numpy=True,
58
+ )
59
+ results = [(msg, emb.tolist()) for msg, emb in zip(messages, embeddings)]
60
+ if on_progress:
61
+ on_progress(total, total)
62
+ return results
63
+
64
+
65
+ def ensure_model() -> None:
66
+ """Ensure StyleDistance is available. Downloads on first use."""
67
+ _get_model()
@@ -0,0 +1,139 @@
1
+ """Vector index for style message embeddings (ChromaDB)."""
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import chromadb
8
+ from chromadb.config import Settings
9
+
10
+ from .unified_timeline import UnifiedMessage
11
+
12
+ DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "brainlayer" / "chromadb"
13
+ STYLE_COLLECTION = "style_messages"
14
+ CHROMADB_BATCH_SIZE = 1000
15
+
16
+
17
+ def _msg_id(msg: UnifiedMessage, idx: int) -> str:
18
+ """Deterministic ID for a message."""
19
+ content = f"{msg.timestamp.isoformat()}|{msg.source}|{msg.text[:100]}"
20
+ h = hashlib.sha256(content.encode()).hexdigest()[:16]
21
+ return f"style_{idx}_{h}"
22
+
23
+
24
+ def _timestamp_epoch(msg: UnifiedMessage) -> float:
25
+ """Unix epoch for ChromaDB numeric filter."""
26
+ return msg.timestamp.timestamp()
27
+
28
+
29
+ def get_style_client(db_path: Path = DEFAULT_DB_PATH) -> chromadb.Client:
30
+ """Get ChromaDB client for style index."""
31
+ db_path.mkdir(parents=True, exist_ok=True)
32
+ return chromadb.PersistentClient(
33
+ path=str(db_path),
34
+ settings=Settings(anonymized_telemetry=False, allow_reset=True),
35
+ )
36
+
37
+
38
+ def get_style_collection(client: chromadb.Client) -> chromadb.Collection:
39
+ """Get or create the style messages collection."""
40
+ return client.get_or_create_collection(
41
+ name=STYLE_COLLECTION,
42
+ metadata={"hnsw:space": "cosine"},
43
+ )
44
+
45
+
46
+ def index_style_messages(
47
+ messages_with_embeddings: list[tuple[UnifiedMessage, list[float]]],
48
+ collection: chromadb.Collection,
49
+ ) -> int:
50
+ """Index style messages with embeddings. Replaces existing data."""
51
+ if not messages_with_embeddings:
52
+ return 0
53
+
54
+ ids = []
55
+ embeddings = []
56
+ documents = []
57
+ metadatas = []
58
+
59
+ for i, (msg, emb) in enumerate(messages_with_embeddings):
60
+ ids.append(_msg_id(msg, i))
61
+ embeddings.append(emb)
62
+ documents.append(msg.text[:2000])
63
+ metadatas.append(
64
+ {
65
+ "ts_epoch": _timestamp_epoch(msg),
66
+ "timestamp": msg.timestamp.isoformat(),
67
+ "source": msg.source,
68
+ "language": msg.language,
69
+ "chat_id": (msg.chat_id or "")[:200],
70
+ "contact_name": (msg.contact_name or "")[:200],
71
+ "relationship_tag": msg.relationship_tag or "unlabeled",
72
+ }
73
+ )
74
+
75
+ for i in range(0, len(ids), CHROMADB_BATCH_SIZE):
76
+ end = min(i + CHROMADB_BATCH_SIZE, len(ids))
77
+ collection.upsert(
78
+ ids=ids[i:end],
79
+ embeddings=embeddings[i:end],
80
+ documents=documents[i:end],
81
+ metadatas=metadatas[i:end],
82
+ )
83
+
84
+ return len(ids)
85
+
86
+
87
+ def clear_style_collection(collection: chromadb.Collection) -> None:
88
+ """Clear all documents from the style collection."""
89
+ # ChromaDB delete requires IDs; get all and delete
90
+ result = collection.get(include=[])
91
+ if result["ids"]:
92
+ collection.delete(ids=result["ids"])
93
+
94
+
95
+ def query_style_messages(
96
+ collection: chromadb.Collection,
97
+ query_embeddings: list[list[float]],
98
+ n_results: int = 100,
99
+ where: Optional[dict] = None,
100
+ ) -> dict:
101
+ """Query style messages by embedding. Returns documents and metadatas."""
102
+ return collection.query(
103
+ query_embeddings=query_embeddings,
104
+ n_results=n_results,
105
+ where=where,
106
+ include=["documents", "metadatas", "distances"],
107
+ )
108
+
109
+
110
+ def get_embeddings_for_batch(
111
+ collection: chromadb.Collection,
112
+ start_epoch: float,
113
+ end_epoch: float,
114
+ language: Optional[str] = None,
115
+ ) -> tuple[list[list[float]], list[str]]:
116
+ """
117
+ Get all embeddings and documents for a time range (and optionally language).
118
+
119
+ Returns:
120
+ (embeddings, documents) for use in cluster sampling.
121
+ """
122
+ where: dict = {
123
+ "$and": [
124
+ {"ts_epoch": {"$gte": start_epoch}},
125
+ {"ts_epoch": {"$lte": end_epoch}},
126
+ ]
127
+ }
128
+ if language and language != "all":
129
+ where["$and"].append({"language": language})
130
+
131
+ # Get all - no query_embeddings, use get with where
132
+ result = collection.get(
133
+ where=where,
134
+ limit=10000,
135
+ include=["embeddings", "documents"],
136
+ )
137
+ embs = result.get("embeddings") or []
138
+ docs = result.get("documents") or []
139
+ return (embs, docs)