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,203 @@
1
+ """Temporal Chains Pipeline — Link sessions by shared files.
2
+
3
+ Phase 8d: Build topic chains across sessions and enable
4
+ regression detection queries.
5
+
6
+ Usage:
7
+ from brainlayer.pipeline.temporal_chains import run_temporal_chains
8
+ run_temporal_chains(vector_store, project="my-project")
9
+ """
10
+
11
+ import logging
12
+ from collections import defaultdict
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _parse_timestamp(ts: Optional[str]) -> Optional[float]:
19
+ """Parse ISO timestamp to epoch seconds."""
20
+ if not ts:
21
+ return None
22
+ try:
23
+ from datetime import datetime
24
+
25
+ ts_clean = ts.replace("Z", "+00:00")
26
+ dt = datetime.fromisoformat(ts_clean)
27
+ return dt.timestamp()
28
+ except (ValueError, TypeError):
29
+ return None
30
+
31
+
32
+ def build_file_session_map(
33
+ vector_store: Any,
34
+ project: Optional[str] = None,
35
+ ) -> Dict[str, List[Dict[str, Any]]]:
36
+ """Build a map of file → sessions that touched it.
37
+
38
+ Returns:
39
+ Dict mapping file_path to list of session info dicts
40
+ (session_id, actions, first_timestamp, last_timestamp)
41
+ """
42
+ cursor = vector_store.conn.cursor()
43
+ query = """
44
+ SELECT fi.file_path, fi.session_id,
45
+ fi.action, fi.timestamp
46
+ FROM file_interactions fi
47
+ """
48
+ params: list = []
49
+ if project:
50
+ query += " WHERE fi.project = ?"
51
+ params.append(project)
52
+ query += " ORDER BY fi.file_path, fi.timestamp"
53
+
54
+ rows = list(cursor.execute(query, params))
55
+
56
+ # Group by file, then by session
57
+ file_sessions: Dict[str, Dict[str, Dict[str, Any]]] = defaultdict(
58
+ lambda: defaultdict(
59
+ lambda: {
60
+ "actions": [],
61
+ "first_ts": None,
62
+ "last_ts": None,
63
+ }
64
+ )
65
+ )
66
+
67
+ for row in rows:
68
+ fp, sid, action, ts = row[0], row[1], row[2], row[3]
69
+ entry = file_sessions[fp][sid]
70
+ if action:
71
+ entry["actions"].append(action)
72
+ if ts:
73
+ if not entry["first_ts"] or ts < entry["first_ts"]:
74
+ entry["first_ts"] = ts
75
+ if not entry["last_ts"] or ts > entry["last_ts"]:
76
+ entry["last_ts"] = ts
77
+
78
+ # Convert to list format
79
+ result: Dict[str, List[Dict[str, Any]]] = {}
80
+ for fp, sessions in file_sessions.items():
81
+ session_list = []
82
+ for sid, info in sessions.items():
83
+ session_list.append(
84
+ {
85
+ "session_id": sid,
86
+ "action_count": len(info["actions"]),
87
+ "actions": list(set(info["actions"])),
88
+ "first_ts": info["first_ts"],
89
+ "last_ts": info["last_ts"],
90
+ }
91
+ )
92
+ # Sort by first timestamp
93
+ session_list.sort(key=lambda x: x.get("first_ts") or "")
94
+ result[fp] = session_list
95
+
96
+ return result
97
+
98
+
99
+ def build_topic_chains(
100
+ file_session_map: Dict[str, List[Dict[str, Any]]],
101
+ project: Optional[str] = None,
102
+ min_sessions: int = 2,
103
+ ) -> List[Dict[str, Any]]:
104
+ """Build topic chains from file→session map.
105
+
106
+ A topic chain links consecutive sessions that touched
107
+ the same file. This reveals how work on a file evolves
108
+ across sessions.
109
+
110
+ Args:
111
+ file_session_map: Output from build_file_session_map
112
+ project: Project name for metadata
113
+ min_sessions: Min sessions per file to create chains
114
+
115
+ Returns:
116
+ List of chain dicts for store_topic_chains()
117
+ """
118
+ chains = []
119
+
120
+ for fp, sessions in file_session_map.items():
121
+ if len(sessions) < min_sessions:
122
+ continue
123
+
124
+ # Link consecutive sessions
125
+ for i in range(len(sessions) - 1):
126
+ sa = sessions[i]
127
+ sb = sessions[i + 1]
128
+
129
+ # Calculate time delta
130
+ ta = _parse_timestamp(sa.get("last_ts"))
131
+ tb = _parse_timestamp(sb.get("first_ts"))
132
+ delta_hours = None
133
+ if ta and tb:
134
+ delta_hours = round((tb - ta) / 3600, 2)
135
+
136
+ # Count shared actions
137
+ shared = len(set(sa["actions"]) & set(sb["actions"]))
138
+
139
+ chains.append(
140
+ {
141
+ "file_path": fp,
142
+ "session_a": sa["session_id"],
143
+ "session_b": sb["session_id"],
144
+ "shared_actions": shared,
145
+ "time_delta_hours": delta_hours,
146
+ "project": project,
147
+ }
148
+ )
149
+
150
+ return chains
151
+
152
+
153
+ def run_temporal_chains(
154
+ vector_store: Any,
155
+ project: Optional[str] = None,
156
+ force: bool = False,
157
+ ) -> Dict[str, int]:
158
+ """Run temporal chain building.
159
+
160
+ Args:
161
+ vector_store: VectorStore instance
162
+ project: Filter to specific project
163
+ force: Clear existing chains first
164
+
165
+ Returns:
166
+ Dict with counts: files_analyzed, chains_created
167
+ """
168
+ if force:
169
+ vector_store.clear_topic_chains(project)
170
+
171
+ # Check if chains already exist
172
+ stats = vector_store.get_topic_chain_stats()
173
+ if stats["total_chains"] > 0 and not force:
174
+ logger.info(
175
+ "Topic chains already exist (%d chains). Use --force to rebuild.",
176
+ stats["total_chains"],
177
+ )
178
+ return {
179
+ "files_analyzed": 0,
180
+ "chains_created": 0,
181
+ }
182
+
183
+ # Build file→session map
184
+ file_map = build_file_session_map(vector_store, project=project)
185
+ logger.info(
186
+ "Built file map: %d files across sessions",
187
+ len(file_map),
188
+ )
189
+
190
+ # Build chains
191
+ chains = build_topic_chains(file_map, project=project)
192
+ logger.info("Found %d topic chains", len(chains))
193
+
194
+ # Store
195
+ if chains:
196
+ count = vector_store.store_topic_chains(chains)
197
+ else:
198
+ count = 0
199
+
200
+ return {
201
+ "files_analyzed": len(file_map),
202
+ "chains_created": count,
203
+ }
@@ -0,0 +1,248 @@
1
+ """Time-based batching for longitudinal analysis.
2
+
3
+ Groups messages by time periods (half-year by default) for evolution tracking.
4
+ """
5
+
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from typing import Literal
10
+
11
+ from .unified_timeline import UnifiedMessage, UnifiedTimeline
12
+
13
+
14
+ @dataclass
15
+ class TimeBatch:
16
+ """A batch of messages from a specific time period."""
17
+
18
+ period: str # e.g., "2024-H1", "2025-H2"
19
+ start_date: datetime
20
+ end_date: datetime
21
+ messages: list[UnifiedMessage]
22
+
23
+ @property
24
+ def count(self) -> int:
25
+ return len(self.messages)
26
+
27
+ @property
28
+ def hebrew_messages(self) -> list[UnifiedMessage]:
29
+ return [m for m in self.messages if m.language == "hebrew"]
30
+
31
+ @property
32
+ def english_messages(self) -> list[UnifiedMessage]:
33
+ return [m for m in self.messages if m.language == "english"]
34
+
35
+ def get_relationship_context(self) -> str:
36
+ """Get relationship mix for prompt enrichment (e.g. '~60% family, ~30% friends')."""
37
+ tagged = [m for m in self.messages if m.relationship_tag]
38
+ if not tagged:
39
+ return ""
40
+ from collections import Counter
41
+
42
+ counts = Counter(m.relationship_tag for m in tagged)
43
+ total = len(tagged)
44
+ parts = [f"~{100 * c // total}% {tag}" for tag, c in counts.most_common(5)]
45
+ contacts = list({m.contact_name for m in tagged if m.contact_name})[:8]
46
+ return f"Relationship context: {', '.join(parts)}. Contacts: {', '.join(contacts)}."
47
+
48
+ def get_stats(self) -> dict:
49
+ """Get statistics for this batch."""
50
+ if not self.messages:
51
+ return {
52
+ "period": self.period,
53
+ "total": 0,
54
+ "hebrew": 0,
55
+ "english": 0,
56
+ "avg_length": 0,
57
+ }
58
+
59
+ total_length = sum(len(m.text) for m in self.messages)
60
+
61
+ return {
62
+ "period": self.period,
63
+ "total": self.count,
64
+ "hebrew": len(self.hebrew_messages),
65
+ "english": len(self.english_messages),
66
+ "avg_length": total_length / self.count,
67
+ "date_range": f"{self.start_date.date()} to {self.end_date.date()}",
68
+ }
69
+
70
+
71
+ def get_period_key(timestamp: datetime, granularity: Literal["year", "half", "quarter", "month"] = "half") -> str:
72
+ """
73
+ Get the period key for a timestamp.
74
+
75
+ Args:
76
+ timestamp: The datetime to categorize
77
+ granularity: How to group periods
78
+ - "year": "2024", "2025"
79
+ - "half": "2024-H1", "2024-H2"
80
+ - "quarter": "2024-Q1", "2024-Q2", "2024-Q3", "2024-Q4"
81
+ - "month": "2024-01", "2024-02"
82
+
83
+ Returns:
84
+ Period key string
85
+ """
86
+ year = timestamp.year
87
+ month = timestamp.month
88
+
89
+ if granularity == "year":
90
+ return str(year)
91
+ elif granularity == "half":
92
+ half = "H1" if month <= 6 else "H2"
93
+ return f"{year}-{half}"
94
+ elif granularity == "quarter":
95
+ quarter = (month - 1) // 3 + 1
96
+ return f"{year}-Q{quarter}"
97
+ elif granularity == "month":
98
+ return f"{year}-{month:02d}"
99
+ else:
100
+ raise ValueError(f"Unknown granularity: {granularity}")
101
+
102
+
103
+ def get_period_dates(
104
+ period: str, granularity: Literal["year", "half", "quarter", "month"] = "half"
105
+ ) -> tuple[datetime, datetime]:
106
+ """
107
+ Get the start and end dates for a period.
108
+
109
+ Returns:
110
+ (start_date, end_date) tuple
111
+ """
112
+ if granularity == "year":
113
+ year = int(period)
114
+ return (datetime(year, 1, 1), datetime(year, 12, 31, 23, 59, 59))
115
+ elif granularity == "half":
116
+ year, half = period.split("-")
117
+ year = int(year)
118
+ if half == "H1":
119
+ return (datetime(year, 1, 1), datetime(year, 6, 30, 23, 59, 59))
120
+ else:
121
+ return (datetime(year, 7, 1), datetime(year, 12, 31, 23, 59, 59))
122
+ elif granularity == "quarter":
123
+ year, quarter = period.split("-Q")
124
+ year = int(year)
125
+ quarter = int(quarter)
126
+ start_month = (quarter - 1) * 3 + 1
127
+ end_month = quarter * 3
128
+ if end_month == 12:
129
+ end_day = 31
130
+ elif end_month in [4, 6, 9, 11]:
131
+ end_day = 30
132
+ else:
133
+ end_day = 31 # Simplified
134
+ return (datetime(year, start_month, 1), datetime(year, end_month, end_day, 23, 59, 59))
135
+ elif granularity == "month":
136
+ year, month = period.split("-")
137
+ year = int(year)
138
+ month = int(month)
139
+ # Last day of month (simplified)
140
+ if month == 12:
141
+ end_date = datetime(year + 1, 1, 1) - timedelta(seconds=1)
142
+ else:
143
+ end_date = datetime(year, month + 1, 1) - timedelta(seconds=1)
144
+ return (datetime(year, month, 1), end_date)
145
+ else:
146
+ raise ValueError(f"Unknown granularity: {granularity}")
147
+
148
+
149
+ from datetime import timedelta
150
+
151
+
152
+ def create_time_batches(
153
+ timeline: UnifiedTimeline,
154
+ granularity: Literal["year", "half", "quarter", "month"] = "half",
155
+ min_messages: int = 10,
156
+ ) -> list[TimeBatch]:
157
+ """
158
+ Create time batches from a unified timeline.
159
+
160
+ Args:
161
+ timeline: The unified timeline to batch
162
+ granularity: How to group periods
163
+ min_messages: Minimum messages required for a batch (otherwise merged)
164
+
165
+ Returns:
166
+ List of TimeBatch objects sorted by period
167
+ """
168
+ # Group messages by period
169
+ period_messages: dict[str, list[UnifiedMessage]] = defaultdict(list)
170
+
171
+ for msg in timeline.messages:
172
+ period = get_period_key(msg.timestamp, granularity)
173
+ period_messages[period].append(msg)
174
+
175
+ # Create batches
176
+ batches = []
177
+ for period in sorted(period_messages.keys()):
178
+ messages = period_messages[period]
179
+ start_date, end_date = get_period_dates(period, granularity)
180
+
181
+ batches.append(
182
+ TimeBatch(
183
+ period=period,
184
+ start_date=start_date,
185
+ end_date=end_date,
186
+ messages=messages,
187
+ )
188
+ )
189
+
190
+ # Optionally merge small batches (skip for now, can add later)
191
+ # if min_messages > 0:
192
+ # batches = merge_small_batches(batches, min_messages)
193
+
194
+ return batches
195
+
196
+
197
+ def get_period_weight(period: str, current_year: int = None) -> float:
198
+ """
199
+ Get weight for a period (more recent = higher weight).
200
+
201
+ Args:
202
+ period: Period string like "2024-H1"
203
+ current_year: Current year for reference (default: now)
204
+
205
+ Returns:
206
+ Weight between 0.4 and 1.0
207
+ """
208
+ if current_year is None:
209
+ current_year = datetime.now().year
210
+
211
+ # Extract year from period
212
+ try:
213
+ year = int(period.split("-")[0])
214
+ except (ValueError, IndexError):
215
+ return 0.5 # Default weight
216
+
217
+ years_ago = current_year - year
218
+
219
+ if years_ago <= 0:
220
+ return 1.0 # Current year
221
+ elif years_ago == 1:
222
+ return 0.8
223
+ elif years_ago == 2:
224
+ return 0.6
225
+ else:
226
+ return 0.4 # Older
227
+
228
+
229
+ def format_batches_summary(batches: list[TimeBatch]) -> str:
230
+ """Format a summary of all batches."""
231
+ lines = ["# Time Batches Summary\n"]
232
+
233
+ total_messages = sum(b.count for b in batches)
234
+ lines.append(f"Total batches: {len(batches)}")
235
+ lines.append(f"Total messages: {total_messages:,}")
236
+ lines.append("")
237
+ lines.append("| Period | Messages | Hebrew | English | Avg Length |")
238
+ lines.append("|--------|----------|--------|---------|------------|")
239
+
240
+ for batch in batches:
241
+ stats = batch.get_stats()
242
+ lines.append(
243
+ f"| {stats['period']} | {stats['total']:,} | "
244
+ f"{stats['hebrew']:,} | {stats['english']:,} | "
245
+ f"{stats['avg_length']:.0f} |"
246
+ )
247
+
248
+ return "\n".join(lines)