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,486 @@
1
+ """Operation Grouping Pipeline — Group chunks into logical operations.
2
+
3
+ Phase 8a: Detect patterns like read→edit→test, search→read chains,
4
+ and user→plan→implement cycles using heuristic rules.
5
+
6
+ Usage:
7
+ from brainlayer.pipeline.operation_grouping import run_operation_grouping
8
+ run_operation_grouping(vector_store, project="my-project")
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import uuid
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Operation type definitions
19
+ OP_EDIT_CYCLE = "edit-cycle" # read → edit → test
20
+ OP_RESEARCH = "research" # search/grep → read multiple
21
+ OP_FEATURE_CYCLE = "feature-cycle" # user asks → plan → implement
22
+ OP_DEBUG = "debug" # error → read → try fix → test
23
+ OP_CONFIG = "config" # write/edit config files
24
+ OP_REVIEW = "review" # read multiple files, no edits
25
+
26
+ # Tool action categories (tuples for deterministic order)
27
+ SEARCH_TOOLS = ("Grep", "Glob")
28
+ READ_TOOLS = ("Read",)
29
+ EDIT_TOOLS = ("Edit", "Write")
30
+ TEST_TOOLS = ("Bash",) # detected by content patterns
31
+ # Ordered: Edit/Write first so they take priority over Read
32
+ ALL_FILE_TOOLS = EDIT_TOOLS + SEARCH_TOOLS + READ_TOOLS
33
+
34
+ # Max time gap between chunks in same operation (seconds)
35
+ MAX_GAP_SECONDS = 300 # 5 minutes
36
+
37
+
38
+ def _parse_timestamp(ts: Optional[str]) -> Optional[float]:
39
+ """Parse ISO timestamp to epoch seconds."""
40
+ if not ts:
41
+ return None
42
+ try:
43
+ from datetime import datetime as dt_cls
44
+
45
+ # Handle various ISO formats
46
+ ts_clean = ts.replace("Z", "+00:00")
47
+ dt = dt_cls.fromisoformat(ts_clean)
48
+ return dt.timestamp()
49
+ except (ValueError, TypeError):
50
+ return None
51
+
52
+
53
+ def _extract_tool_info(chunk: Dict[str, Any]) -> Dict[str, Any]:
54
+ """Extract tool usage info from a chunk's content."""
55
+ content = chunk.get("content", "")
56
+ content_type = chunk.get("content_type", "")
57
+ metadata = chunk.get("metadata", "{}")
58
+
59
+ if isinstance(metadata, str):
60
+ try:
61
+ metadata = json.loads(metadata)
62
+ except (json.JSONDecodeError, TypeError):
63
+ metadata = {}
64
+
65
+ info = {
66
+ "chunk_id": chunk.get("id", ""),
67
+ "content_type": content_type,
68
+ "tool": None,
69
+ "action": None,
70
+ "file_path": None,
71
+ "is_test": False,
72
+ "is_error": False,
73
+ "is_user_message": content_type == "user_message",
74
+ "is_assistant": content_type == "assistant_text",
75
+ "timestamp": chunk.get("timestamp"),
76
+ "snippet": "", # brief context for summaries
77
+ }
78
+
79
+ # Extract a short snippet for summary context
80
+ # Take first meaningful line (skip empty, very short)
81
+ for line in content.split("\n"):
82
+ line = line.strip()
83
+ if len(line) > 10 and not line.startswith("#"):
84
+ info["snippet"] = line[:80]
85
+ break
86
+
87
+ # Extract file paths from content
88
+ import re
89
+
90
+ file_match = re.search(
91
+ r'(?:file_path|path)[=:]\s*["\']?'
92
+ r'([^\s"\']+\.\w+)',
93
+ content,
94
+ )
95
+ if file_match:
96
+ info["file_path"] = file_match.group(1)
97
+ else:
98
+ # Also match bare file paths
99
+ bare_match = re.search(
100
+ r"(?:^|\s)(/[^\s]+\.\w{1,5})",
101
+ content,
102
+ )
103
+ if bare_match:
104
+ info["file_path"] = bare_match.group(1)
105
+
106
+ # Detect tool calls from content patterns
107
+ # Use word-boundary matching to avoid false positives
108
+ # (e.g., "Read" matching "README")
109
+ has_tool_marker = "tool_use" in content or "Tool:" in content
110
+ if has_tool_marker or content_type == "assistant_text":
111
+ for tool in ALL_FILE_TOOLS:
112
+ # Match tool name as a whole word
113
+ pattern = r"\b" + re.escape(tool) + r"\b"
114
+ if re.search(pattern, content):
115
+ info["tool"] = tool
116
+ if tool in SEARCH_TOOLS:
117
+ info["action"] = "search"
118
+ elif tool in READ_TOOLS:
119
+ info["action"] = "read"
120
+ elif tool in EDIT_TOOLS:
121
+ info["action"] = "edit"
122
+ break
123
+
124
+ # Detect from content_type
125
+ if content_type == "ai_code":
126
+ info["action"] = "code"
127
+ elif content_type == "stack_trace":
128
+ info["is_error"] = True
129
+ info["action"] = "error"
130
+
131
+ # Detect test runs
132
+ if any(
133
+ kw in content.lower()
134
+ for kw in [
135
+ "bun test",
136
+ "pytest",
137
+ "npm test",
138
+ "jest",
139
+ "vitest",
140
+ "test passed",
141
+ "test failed",
142
+ "tests pass",
143
+ ]
144
+ ):
145
+ info["is_test"] = True
146
+ info["action"] = "test"
147
+
148
+ # Detect errors (avoid false positives from test summaries)
149
+ content_lower = content.lower()
150
+ if any(
151
+ kw in content_lower
152
+ for kw in [
153
+ "error:",
154
+ "exception:",
155
+ "traceback",
156
+ "exit code 1",
157
+ ]
158
+ ):
159
+ info["is_error"] = True
160
+ # "failed" only counts as error if not in test summary
161
+ if "failed" in content_lower and "0 fail" not in content_lower and "pass" not in content_lower:
162
+ info["is_error"] = True
163
+
164
+ return info
165
+
166
+
167
+ def _classify_operation(
168
+ steps: List[Dict[str, Any]],
169
+ ) -> str:
170
+ """Classify an operation based on its step patterns."""
171
+ actions = [s["action"] for s in steps if s["action"]]
172
+ has_search = "search" in actions
173
+ has_read = "read" in actions
174
+ has_edit = "edit" in actions
175
+ has_test = any(s["is_test"] for s in steps)
176
+ has_error = any(s["is_error"] for s in steps)
177
+ has_code = "code" in actions
178
+ has_user = any(s["is_user_message"] for s in steps)
179
+
180
+ # Debug cycle: error → investigation → fix → test
181
+ if has_error and (has_edit or has_code) and has_test:
182
+ return OP_DEBUG
183
+ if has_error and (has_edit or has_code):
184
+ return OP_DEBUG
185
+
186
+ # Edit cycle: read → edit → test
187
+ if has_edit and has_test:
188
+ return OP_EDIT_CYCLE
189
+ if has_edit and has_read:
190
+ return OP_EDIT_CYCLE
191
+
192
+ # Feature cycle: user request → implementation
193
+ if has_user and (has_edit or has_code):
194
+ return OP_FEATURE_CYCLE
195
+
196
+ # Research: search/read without edits
197
+ if has_search and has_read and not has_edit:
198
+ return OP_RESEARCH
199
+ if has_search and not has_edit:
200
+ return OP_RESEARCH
201
+
202
+ # Review: reading multiple files
203
+ read_count = sum(1 for s in steps if s["action"] == "read")
204
+ if read_count >= 3 and not has_edit:
205
+ return OP_REVIEW
206
+
207
+ # Config: editing config-like files
208
+ if has_edit:
209
+ return OP_EDIT_CYCLE
210
+
211
+ return OP_RESEARCH # default
212
+
213
+
214
+ def _generate_summary(
215
+ op_type: str,
216
+ steps: List[Dict[str, Any]],
217
+ ) -> str:
218
+ """Generate a brief summary of an operation."""
219
+ step_count = len(steps)
220
+ actions = [s["action"] for s in steps if s["action"]]
221
+ unique_actions = list(dict.fromkeys(actions)) # ordered
222
+
223
+ action_str = " -> ".join(unique_actions[:5])
224
+ if len(unique_actions) > 5:
225
+ action_str += " -> ..."
226
+
227
+ type_labels = {
228
+ OP_EDIT_CYCLE: "Edit cycle",
229
+ OP_RESEARCH: "Research",
230
+ OP_FEATURE_CYCLE: "Feature cycle",
231
+ OP_DEBUG: "Debug cycle",
232
+ OP_CONFIG: "Config change",
233
+ OP_REVIEW: "Code review",
234
+ }
235
+ label = type_labels.get(op_type, op_type)
236
+
237
+ # Extract file context from steps
238
+ files = []
239
+ for s in steps:
240
+ fp = s.get("file_path")
241
+ if fp:
242
+ # Extract just the filename
243
+ import os
244
+
245
+ basename = os.path.basename(fp)
246
+ if basename and basename not in files:
247
+ files.append(basename)
248
+ file_ctx = ""
249
+ if files:
250
+ shown = files[:3]
251
+ file_ctx = " on " + ", ".join(shown)
252
+ if len(files) > 3:
253
+ file_ctx += f" +{len(files) - 3}"
254
+
255
+ # Extract topic from first user message or snippet
256
+ topic = ""
257
+ if not file_ctx:
258
+ for s in steps:
259
+ if s.get("snippet"):
260
+ topic = f": {s['snippet'][:50]}"
261
+ break
262
+
263
+ parts = [label]
264
+ if action_str:
265
+ parts.append(f"({action_str})")
266
+ if file_ctx:
267
+ parts.append(file_ctx)
268
+ elif topic:
269
+ parts.append(topic)
270
+ parts.append(f"[{step_count} steps]")
271
+ return " ".join(parts)
272
+
273
+
274
+ def group_session_chunks(
275
+ chunks: List[Dict[str, Any]],
276
+ session_id: str,
277
+ max_gap: int = MAX_GAP_SECONDS,
278
+ ) -> List[Dict[str, Any]]:
279
+ """Group a session's chunks into logical operations.
280
+
281
+ Uses temporal proximity and tool-type patterns.
282
+
283
+ Args:
284
+ chunks: List of chunk dicts (must have id, content,
285
+ content_type, and optionally timestamp in metadata)
286
+ session_id: The session identifier
287
+ max_gap: Max seconds between chunks in same operation
288
+
289
+ Returns:
290
+ List of operation dicts ready for store_operations()
291
+ """
292
+ if not chunks:
293
+ return []
294
+
295
+ # Extract tool info for each chunk
296
+ steps = []
297
+ for chunk in chunks:
298
+ info = _extract_tool_info(chunk)
299
+ # Try to get timestamp from chunk metadata
300
+ meta = chunk.get("metadata", "{}")
301
+ if isinstance(meta, str):
302
+ try:
303
+ meta = json.loads(meta)
304
+ except (json.JSONDecodeError, TypeError):
305
+ meta = {}
306
+ ts = meta.get("timestamp") or chunk.get("timestamp")
307
+ info["timestamp"] = ts
308
+ info["epoch"] = _parse_timestamp(ts)
309
+ steps.append(info)
310
+
311
+ # Group by temporal proximity
312
+ groups: List[List[Dict[str, Any]]] = []
313
+ current_group: List[Dict[str, Any]] = []
314
+
315
+ for step in steps:
316
+ if not current_group:
317
+ current_group.append(step)
318
+ continue
319
+
320
+ # Check time gap
321
+ prev_epoch = current_group[-1].get("epoch")
322
+ curr_epoch = step.get("epoch")
323
+
324
+ if prev_epoch and curr_epoch:
325
+ gap = curr_epoch - prev_epoch
326
+ if gap > max_gap:
327
+ # Time gap too large — start new group
328
+ if len(current_group) >= 2:
329
+ groups.append(current_group)
330
+ current_group = [step]
331
+ continue
332
+
333
+ # Check for natural boundaries
334
+ # A new user message often starts a new operation
335
+ if step["is_user_message"] and len(current_group) >= 3:
336
+ groups.append(current_group)
337
+ current_group = [step]
338
+ continue
339
+
340
+ current_group.append(step)
341
+
342
+ # Cap group size
343
+ if len(current_group) >= 50:
344
+ groups.append(current_group)
345
+ current_group = []
346
+
347
+ # Don't forget the last group
348
+ if len(current_group) >= 2:
349
+ groups.append(current_group)
350
+
351
+ # Convert groups to operation dicts
352
+ operations = []
353
+ for group in groups:
354
+ op_type = _classify_operation(group)
355
+ chunk_ids = [s["chunk_id"] for s in group if s["chunk_id"]]
356
+
357
+ timestamps = [s["timestamp"] for s in group if s.get("timestamp")]
358
+ started = timestamps[0] if timestamps else None
359
+ ended = timestamps[-1] if timestamps else None
360
+
361
+ # Determine outcome
362
+ has_error = any(s["is_error"] for s in group)
363
+ has_test_pass = any(s["is_test"] and not s["is_error"] for s in group)
364
+ if has_test_pass:
365
+ outcome = "success"
366
+ elif has_error:
367
+ outcome = "failure"
368
+ else:
369
+ outcome = "unknown"
370
+
371
+ operations.append(
372
+ {
373
+ "id": str(uuid.uuid4()),
374
+ "session_id": session_id,
375
+ "operation_type": op_type,
376
+ "chunk_ids": chunk_ids,
377
+ "summary": _generate_summary(op_type, group),
378
+ "outcome": outcome,
379
+ "started_at": started,
380
+ "ended_at": ended,
381
+ "step_count": len(group),
382
+ }
383
+ )
384
+
385
+ return operations
386
+
387
+
388
+ def run_operation_grouping(
389
+ vector_store: Any,
390
+ project: Optional[str] = None,
391
+ force: bool = False,
392
+ max_sessions: int = 0,
393
+ ) -> Dict[str, int]:
394
+ """Run operation grouping on indexed sessions.
395
+
396
+ Args:
397
+ vector_store: VectorStore instance
398
+ project: Filter to specific project name
399
+ force: Re-process sessions with existing operations
400
+ max_sessions: Limit number of sessions (0 = all)
401
+
402
+ Returns:
403
+ Dict with counts: sessions_processed, operations_added
404
+ """
405
+ stats = {
406
+ "sessions_processed": 0,
407
+ "operations_added": 0,
408
+ }
409
+
410
+ # Get all sessions from session_context table
411
+ cursor = vector_store.conn.cursor()
412
+ query = "SELECT session_id, project FROM session_context"
413
+ params: list = []
414
+ if project:
415
+ query += " WHERE project = ?"
416
+ params.append(project)
417
+ query += " ORDER BY started_at"
418
+
419
+ sessions = list(cursor.execute(query, params))
420
+
421
+ processed = 0
422
+ for session_id, proj in sessions:
423
+ if max_sessions and processed >= max_sessions:
424
+ break
425
+
426
+ # Skip if already has operations (unless force)
427
+ if not force:
428
+ existing = vector_store.get_session_operations(session_id)
429
+ if existing:
430
+ continue
431
+ else:
432
+ # Clear existing operations for re-processing
433
+ vector_store.clear_session_operations(session_id)
434
+
435
+ # Get chunks for this session
436
+ source_file_pattern = f"%{session_id}%"
437
+ chunk_rows = list(
438
+ cursor.execute(
439
+ """SELECT id, content, content_type, metadata
440
+ FROM chunks
441
+ WHERE source_file LIKE ?
442
+ ORDER BY ROWID""",
443
+ (source_file_pattern,),
444
+ )
445
+ )
446
+
447
+ if not chunk_rows:
448
+ continue
449
+
450
+ chunks = []
451
+ for row in chunk_rows:
452
+ meta = row[3] or "{}"
453
+ if isinstance(meta, str):
454
+ try:
455
+ meta_dict = json.loads(meta)
456
+ except (json.JSONDecodeError, TypeError):
457
+ meta_dict = {}
458
+ else:
459
+ meta_dict = meta
460
+
461
+ chunks.append(
462
+ {
463
+ "id": row[0],
464
+ "content": row[1],
465
+ "content_type": row[2],
466
+ "metadata": meta_dict,
467
+ }
468
+ )
469
+
470
+ # Group into operations
471
+ operations = group_session_chunks(chunks, session_id)
472
+
473
+ if operations:
474
+ count = vector_store.store_operations(operations)
475
+ stats["operations_added"] += count
476
+ logger.info(
477
+ "Session %s: %d operations from %d chunks",
478
+ session_id[:8],
479
+ count,
480
+ len(chunks),
481
+ )
482
+
483
+ stats["sessions_processed"] += 1
484
+ processed += 1
485
+
486
+ return stats