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,227 @@
|
|
|
1
|
+
"""Extract WhatsApp messages for communication pattern analysis.
|
|
2
|
+
|
|
3
|
+
Extracts messages from WhatsApp's SQLite database to analyze:
|
|
4
|
+
- User's writing style and patterns
|
|
5
|
+
- Response patterns
|
|
6
|
+
- Communication preferences
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterator, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_whatsapp_db_path() -> Path:
|
|
16
|
+
"""Get path to WhatsApp's ChatStorage database."""
|
|
17
|
+
return Path.home() / "Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlite"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_contacts_db_path() -> Path:
|
|
21
|
+
"""Get path to WhatsApp's Contacts database."""
|
|
22
|
+
return Path.home() / "Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ContactsV2.sqlite"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract_whatsapp_messages(
|
|
26
|
+
db_path: Optional[Path] = None,
|
|
27
|
+
limit: Optional[int] = None,
|
|
28
|
+
only_from_me: bool = False,
|
|
29
|
+
exclude_groups: bool = True,
|
|
30
|
+
min_char_count: int = 10,
|
|
31
|
+
) -> Iterator[dict]:
|
|
32
|
+
"""
|
|
33
|
+
Extract WhatsApp messages from SQLite database.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
db_path: Path to ChatStorage.sqlite (auto-detected if None)
|
|
37
|
+
limit: Maximum number of messages to extract
|
|
38
|
+
only_from_me: Only extract messages sent by user
|
|
39
|
+
exclude_groups: Exclude group chat messages
|
|
40
|
+
min_char_count: Minimum message length to include (filters emoji-only etc.)
|
|
41
|
+
|
|
42
|
+
Yields:
|
|
43
|
+
Message dictionaries with text, timestamp, sender info
|
|
44
|
+
"""
|
|
45
|
+
if db_path is None:
|
|
46
|
+
db_path = get_whatsapp_db_path()
|
|
47
|
+
|
|
48
|
+
if not db_path.exists():
|
|
49
|
+
raise FileNotFoundError(f"WhatsApp database not found at: {db_path}")
|
|
50
|
+
|
|
51
|
+
# Open database in read-only mode
|
|
52
|
+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
53
|
+
conn.row_factory = sqlite3.Row
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
cursor = conn.cursor()
|
|
57
|
+
|
|
58
|
+
# Build query
|
|
59
|
+
query = """
|
|
60
|
+
SELECT
|
|
61
|
+
Z_PK as id,
|
|
62
|
+
ZTEXT as text,
|
|
63
|
+
ZISFROMME as is_from_me,
|
|
64
|
+
ZMESSAGEDATE as message_date,
|
|
65
|
+
ZSENTDATE as sent_date,
|
|
66
|
+
ZFROMJID as from_jid,
|
|
67
|
+
ZTOJID as to_jid,
|
|
68
|
+
ZPUSHNAME as push_name,
|
|
69
|
+
ZMESSAGETYPE as message_type,
|
|
70
|
+
ZMESSAGESTATUS as message_status,
|
|
71
|
+
ZSTARRED as starred,
|
|
72
|
+
ZSTANZAID as stanza_id
|
|
73
|
+
FROM ZWAMESSAGE
|
|
74
|
+
WHERE ZTEXT IS NOT NULL
|
|
75
|
+
AND ZTEXT != ''
|
|
76
|
+
AND LENGTH(ZTEXT) >= ?
|
|
77
|
+
"""
|
|
78
|
+
params = [min_char_count]
|
|
79
|
+
|
|
80
|
+
# Add filters
|
|
81
|
+
if only_from_me:
|
|
82
|
+
query += " AND ZISFROMME = 1"
|
|
83
|
+
|
|
84
|
+
if exclude_groups:
|
|
85
|
+
# Group JIDs typically contain '@g.us'
|
|
86
|
+
query += " AND (ZTOJID NOT LIKE '%@g.us' OR ZTOJID IS NULL)"
|
|
87
|
+
|
|
88
|
+
# Order by date descending (most recent first)
|
|
89
|
+
query += " ORDER BY ZMESSAGEDATE DESC"
|
|
90
|
+
|
|
91
|
+
if limit:
|
|
92
|
+
query += " LIMIT ?"
|
|
93
|
+
params.append(limit)
|
|
94
|
+
|
|
95
|
+
cursor.execute(query, params)
|
|
96
|
+
|
|
97
|
+
for row in cursor:
|
|
98
|
+
# Convert Core Data timestamp to Unix timestamp
|
|
99
|
+
# Core Data uses seconds since 2001-01-01
|
|
100
|
+
core_data_epoch = 978307200 # Unix timestamp for 2001-01-01
|
|
101
|
+
message_timestamp = row["message_date"] + core_data_epoch if row["message_date"] else None
|
|
102
|
+
|
|
103
|
+
yield {
|
|
104
|
+
"id": row["id"],
|
|
105
|
+
"text": row["text"],
|
|
106
|
+
"is_from_me": bool(row["is_from_me"]),
|
|
107
|
+
"timestamp": message_timestamp,
|
|
108
|
+
"datetime": datetime.fromtimestamp(message_timestamp) if message_timestamp else None,
|
|
109
|
+
"from_jid": row["from_jid"],
|
|
110
|
+
"to_jid": row["to_jid"],
|
|
111
|
+
"contact_name": row["push_name"],
|
|
112
|
+
"message_type": row["message_type"],
|
|
113
|
+
"starred": bool(row["starred"]),
|
|
114
|
+
"stanza_id": row["stanza_id"],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
finally:
|
|
118
|
+
conn.close()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_contact_info(jid: str, contacts_db_path: Optional[Path] = None) -> Optional[dict]:
|
|
122
|
+
"""
|
|
123
|
+
Get contact information for a JID.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
jid: WhatsApp JID (e.g., "1234567890@s.whatsapp.net")
|
|
127
|
+
contacts_db_path: Path to ContactsV2.sqlite
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Contact info dict or None if not found
|
|
131
|
+
"""
|
|
132
|
+
if contacts_db_path is None:
|
|
133
|
+
contacts_db_path = get_contacts_db_path()
|
|
134
|
+
|
|
135
|
+
if not contacts_db_path.exists():
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
conn = sqlite3.connect(f"file:{contacts_db_path}?mode=ro", uri=True)
|
|
139
|
+
conn.row_factory = sqlite3.Row
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
cursor = conn.cursor()
|
|
143
|
+
cursor.execute(
|
|
144
|
+
"""
|
|
145
|
+
SELECT
|
|
146
|
+
ZCONTACTNAME as name,
|
|
147
|
+
ZPHONENUMBER as phone,
|
|
148
|
+
ZSTATUSTEXT as status
|
|
149
|
+
FROM ZWACONTACT
|
|
150
|
+
WHERE ZCONTACTJID = ?
|
|
151
|
+
""",
|
|
152
|
+
(jid,),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
row = cursor.fetchone()
|
|
156
|
+
if row:
|
|
157
|
+
return {
|
|
158
|
+
"name": row["name"],
|
|
159
|
+
"phone": row["phone"],
|
|
160
|
+
"status": row["status"],
|
|
161
|
+
}
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
finally:
|
|
165
|
+
conn.close()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def analyze_writing_style(messages: list[dict]) -> dict:
|
|
169
|
+
"""
|
|
170
|
+
Analyze user's writing style from messages.
|
|
171
|
+
|
|
172
|
+
Returns patterns like:
|
|
173
|
+
- Average message length
|
|
174
|
+
- Common phrases
|
|
175
|
+
- Punctuation usage
|
|
176
|
+
- Emoji usage
|
|
177
|
+
- Response time patterns
|
|
178
|
+
"""
|
|
179
|
+
user_messages = [m for m in messages if m["is_from_me"]]
|
|
180
|
+
|
|
181
|
+
if not user_messages:
|
|
182
|
+
return {}
|
|
183
|
+
|
|
184
|
+
total_length = sum(len(m["text"]) for m in user_messages)
|
|
185
|
+
avg_length = total_length / len(user_messages)
|
|
186
|
+
|
|
187
|
+
# Count emoji usage
|
|
188
|
+
emoji_count = sum(
|
|
189
|
+
1
|
|
190
|
+
for m in user_messages
|
|
191
|
+
for char in m["text"]
|
|
192
|
+
if ord(char) > 0x1F300 # Basic emoji range
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Analyze punctuation
|
|
196
|
+
exclamation_count = sum(m["text"].count("!") for m in user_messages)
|
|
197
|
+
question_count = sum(m["text"].count("?") for m in user_messages)
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
"total_messages": len(user_messages),
|
|
201
|
+
"avg_message_length": avg_length,
|
|
202
|
+
"emoji_usage_rate": emoji_count / len(user_messages),
|
|
203
|
+
"exclamation_rate": exclamation_count / len(user_messages),
|
|
204
|
+
"question_rate": question_count / len(user_messages),
|
|
205
|
+
"sample_messages": [m["text"] for m in user_messages[:10]],
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def format_whatsapp_for_pipeline(message: dict) -> dict:
|
|
210
|
+
"""
|
|
211
|
+
Format WhatsApp message to match brainlayer pipeline format.
|
|
212
|
+
|
|
213
|
+
Converts to a format similar to Claude Code conversations.
|
|
214
|
+
"""
|
|
215
|
+
return {
|
|
216
|
+
"type": "whatsapp_message",
|
|
217
|
+
"id": f"whatsapp_{message['id']}",
|
|
218
|
+
"timestamp": message["timestamp"],
|
|
219
|
+
"role": "user" if message["is_from_me"] else "contact",
|
|
220
|
+
"content": message["text"],
|
|
221
|
+
"metadata": {
|
|
222
|
+
"source": "whatsapp",
|
|
223
|
+
"contact": message["contact_name"],
|
|
224
|
+
"jid": message["from_jid"] if not message["is_from_me"] else message["to_jid"],
|
|
225
|
+
"starred": message["starred"],
|
|
226
|
+
},
|
|
227
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Git Overlay Pipeline — Cross-reference sessions with git history.
|
|
2
|
+
|
|
3
|
+
Phase 8b: For each indexed session, extract git metadata (branch, commits,
|
|
4
|
+
PR number, files changed) and build file-interaction timelines.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from brainlayer.pipeline.git_overlay import run_git_overlay
|
|
8
|
+
run_git_overlay(vector_store, project="-Users-username-Projects-myapp")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def slug_to_path(slug: str) -> Optional[Path]:
|
|
22
|
+
"""Convert a project slug to a filesystem path.
|
|
23
|
+
|
|
24
|
+
Example: '-Users-username-Projects-myapp' -> '/Users/username/Projects/myapp'
|
|
25
|
+
"""
|
|
26
|
+
if not slug:
|
|
27
|
+
return None
|
|
28
|
+
# Replace leading '-' with '/', then '-' with '/'
|
|
29
|
+
path_str = "/" + slug.lstrip("-").replace("-", "/")
|
|
30
|
+
path = Path(path_str)
|
|
31
|
+
if path.exists() and path.is_dir():
|
|
32
|
+
return path
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def slug_to_display(slug: str) -> str:
|
|
37
|
+
"""Convert project slug to display name.
|
|
38
|
+
|
|
39
|
+
Example: '-Users-username-Projects-myapp' -> 'Projects/myapp'
|
|
40
|
+
"""
|
|
41
|
+
return re.sub(r"^-Users-[^-]+-", "", slug).replace("-", "/")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _git_cmd(cmd: List[str], cwd: Path, timeout: int = 10) -> Optional[str]:
|
|
45
|
+
"""Run a git command, return stdout or None on failure."""
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
["git"] + cmd,
|
|
49
|
+
cwd=str(cwd),
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
)
|
|
54
|
+
if result.returncode == 0:
|
|
55
|
+
return result.stdout.strip()
|
|
56
|
+
return None
|
|
57
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_git_repo(path: Path) -> bool:
|
|
62
|
+
"""Check if path is inside a git repository."""
|
|
63
|
+
return _git_cmd(["rev-parse", "--git-dir"], path) is not None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_session_timestamps(jsonl_path: Path) -> Tuple[Optional[str], Optional[str]]:
|
|
67
|
+
"""Extract first and last timestamps from a session JSONL file."""
|
|
68
|
+
first_ts = None
|
|
69
|
+
last_ts = None
|
|
70
|
+
try:
|
|
71
|
+
with open(jsonl_path) as f:
|
|
72
|
+
for line in f:
|
|
73
|
+
line = line.strip()
|
|
74
|
+
if not line:
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
obj = json.loads(line)
|
|
78
|
+
ts = obj.get("timestamp")
|
|
79
|
+
if ts:
|
|
80
|
+
if first_ts is None:
|
|
81
|
+
first_ts = ts
|
|
82
|
+
last_ts = ts
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
continue
|
|
85
|
+
except (OSError, IOError):
|
|
86
|
+
pass
|
|
87
|
+
return first_ts, last_ts
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def extract_file_actions(jsonl_path: Path, session_id: str) -> List[Dict[str, Any]]:
|
|
91
|
+
"""Extract file interaction records from a session JSONL.
|
|
92
|
+
|
|
93
|
+
Looks for tool_use blocks in assistant messages with file paths.
|
|
94
|
+
"""
|
|
95
|
+
interactions: List[Dict[str, Any]] = []
|
|
96
|
+
tool_to_action = {
|
|
97
|
+
"Read": "read",
|
|
98
|
+
"Edit": "edit",
|
|
99
|
+
"Write": "write",
|
|
100
|
+
"Glob": "search",
|
|
101
|
+
"Grep": "search",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
with open(jsonl_path) as f:
|
|
106
|
+
for line in f:
|
|
107
|
+
line = line.strip()
|
|
108
|
+
if not line:
|
|
109
|
+
continue
|
|
110
|
+
try:
|
|
111
|
+
obj = json.loads(line)
|
|
112
|
+
if obj.get("type") != "assistant":
|
|
113
|
+
continue
|
|
114
|
+
msg = obj.get("message", {})
|
|
115
|
+
content = msg.get("content", [])
|
|
116
|
+
timestamp = obj.get("timestamp")
|
|
117
|
+
if not isinstance(content, list):
|
|
118
|
+
continue
|
|
119
|
+
for block in content:
|
|
120
|
+
if not isinstance(block, dict):
|
|
121
|
+
continue
|
|
122
|
+
if block.get("type") != "tool_use":
|
|
123
|
+
continue
|
|
124
|
+
tool_name = block.get("name", "")
|
|
125
|
+
action = tool_to_action.get(tool_name)
|
|
126
|
+
if not action:
|
|
127
|
+
continue
|
|
128
|
+
inp = block.get("input", {})
|
|
129
|
+
file_path = inp.get("file_path") or inp.get("path")
|
|
130
|
+
if file_path:
|
|
131
|
+
interactions.append(
|
|
132
|
+
{
|
|
133
|
+
"file_path": file_path,
|
|
134
|
+
"timestamp": timestamp,
|
|
135
|
+
"session_id": session_id,
|
|
136
|
+
"action": action,
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
except json.JSONDecodeError:
|
|
140
|
+
continue
|
|
141
|
+
except (OSError, IOError):
|
|
142
|
+
pass
|
|
143
|
+
return interactions
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_git_context(
|
|
147
|
+
repo_path: Path,
|
|
148
|
+
start_ts: str,
|
|
149
|
+
end_ts: str,
|
|
150
|
+
) -> Dict[str, Any]:
|
|
151
|
+
"""Get git context for a time range in a repo.
|
|
152
|
+
|
|
153
|
+
Returns branch, commits, PR number, and files changed.
|
|
154
|
+
"""
|
|
155
|
+
context: Dict[str, Any] = {
|
|
156
|
+
"branch": None,
|
|
157
|
+
"pr_number": None,
|
|
158
|
+
"commit_shas": [],
|
|
159
|
+
"files_changed": [],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Current branch (at time of overlay, not session time)
|
|
163
|
+
branch = _git_cmd(["rev-parse", "--abbrev-ref", "HEAD"], repo_path)
|
|
164
|
+
context["branch"] = branch
|
|
165
|
+
|
|
166
|
+
# Commits in the time range
|
|
167
|
+
log_output = _git_cmd(
|
|
168
|
+
["log", f"--after={start_ts}", f"--before={end_ts}", "--format=%H %s", "--all"],
|
|
169
|
+
repo_path,
|
|
170
|
+
)
|
|
171
|
+
if log_output:
|
|
172
|
+
for line in log_output.strip().split("\n"):
|
|
173
|
+
if line.strip():
|
|
174
|
+
parts = line.split(" ", 1)
|
|
175
|
+
context["commit_shas"].append(parts[0])
|
|
176
|
+
|
|
177
|
+
# Files changed in those commits
|
|
178
|
+
if context["commit_shas"]:
|
|
179
|
+
first_sha = context["commit_shas"][-1] # oldest
|
|
180
|
+
last_sha = context["commit_shas"][0] # newest
|
|
181
|
+
diff_output = _git_cmd(
|
|
182
|
+
["diff", "--name-only", f"{first_sha}~1", last_sha],
|
|
183
|
+
repo_path,
|
|
184
|
+
)
|
|
185
|
+
if diff_output:
|
|
186
|
+
context["files_changed"] = [f for f in diff_output.strip().split("\n") if f.strip()]
|
|
187
|
+
|
|
188
|
+
# Try to extract PR number from branch name
|
|
189
|
+
if branch and branch not in ("master", "main", "HEAD"):
|
|
190
|
+
# Try gh CLI for PR number
|
|
191
|
+
try:
|
|
192
|
+
result = subprocess.run(
|
|
193
|
+
["gh", "pr", "list", "--head", branch, "--json", "number", "--limit", "1"],
|
|
194
|
+
cwd=str(repo_path),
|
|
195
|
+
capture_output=True,
|
|
196
|
+
text=True,
|
|
197
|
+
timeout=10,
|
|
198
|
+
)
|
|
199
|
+
if result.returncode == 0:
|
|
200
|
+
prs = json.loads(result.stdout)
|
|
201
|
+
if prs:
|
|
202
|
+
context["pr_number"] = prs[0]["number"]
|
|
203
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
return context
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def run_git_overlay(
|
|
210
|
+
vector_store: Any,
|
|
211
|
+
project: Optional[str] = None,
|
|
212
|
+
force: bool = False,
|
|
213
|
+
max_sessions: int = 0,
|
|
214
|
+
) -> Dict[str, int]:
|
|
215
|
+
"""Run git overlay on indexed sessions.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
vector_store: VectorStore instance
|
|
219
|
+
project: Filter to specific project slug
|
|
220
|
+
force: Re-process sessions that already have context
|
|
221
|
+
max_sessions: Limit number of sessions (0 = all)
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Dict with counts: sessions_processed, file_interactions_added
|
|
225
|
+
"""
|
|
226
|
+
projects_dir = Path.home() / ".claude" / "projects"
|
|
227
|
+
if not projects_dir.exists():
|
|
228
|
+
logger.warning("No projects directory found at %s", projects_dir)
|
|
229
|
+
return {"sessions_processed": 0, "file_interactions_added": 0}
|
|
230
|
+
|
|
231
|
+
stats = {"sessions_processed": 0, "file_interactions_added": 0}
|
|
232
|
+
processed = 0
|
|
233
|
+
|
|
234
|
+
for proj_dir in sorted(projects_dir.iterdir()):
|
|
235
|
+
if not proj_dir.is_dir():
|
|
236
|
+
continue
|
|
237
|
+
if project and proj_dir.name != project:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# Resolve project path
|
|
241
|
+
repo_path = slug_to_path(proj_dir.name)
|
|
242
|
+
display_name = slug_to_display(proj_dir.name)
|
|
243
|
+
has_git = repo_path and is_git_repo(repo_path)
|
|
244
|
+
|
|
245
|
+
for jsonl_file in sorted(proj_dir.glob("*.jsonl")):
|
|
246
|
+
if max_sessions and processed >= max_sessions:
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
session_id = jsonl_file.stem
|
|
250
|
+
|
|
251
|
+
# Skip if already processed (unless force)
|
|
252
|
+
if not force:
|
|
253
|
+
existing = vector_store.get_session_context(session_id)
|
|
254
|
+
if existing:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
# Get timestamps
|
|
258
|
+
start_ts, end_ts = get_session_timestamps(jsonl_file)
|
|
259
|
+
if not start_ts:
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
# Extract file interactions from JSONL
|
|
263
|
+
file_actions = extract_file_actions(jsonl_file, session_id)
|
|
264
|
+
for fa in file_actions:
|
|
265
|
+
fa["project"] = display_name
|
|
266
|
+
|
|
267
|
+
# Get git context if repo exists
|
|
268
|
+
git_ctx = {}
|
|
269
|
+
if has_git and repo_path:
|
|
270
|
+
git_ctx = get_git_context(repo_path, start_ts, end_ts)
|
|
271
|
+
|
|
272
|
+
# Store session context
|
|
273
|
+
vector_store.store_session_context(
|
|
274
|
+
session_id=session_id,
|
|
275
|
+
project=display_name,
|
|
276
|
+
branch=git_ctx.get("branch"),
|
|
277
|
+
pr_number=git_ctx.get("pr_number"),
|
|
278
|
+
commit_shas=git_ctx.get("commit_shas", []),
|
|
279
|
+
files_changed=git_ctx.get("files_changed", []),
|
|
280
|
+
started_at=start_ts,
|
|
281
|
+
ended_at=end_ts,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Store file interactions
|
|
285
|
+
count = vector_store.store_file_interactions(file_actions)
|
|
286
|
+
stats["file_interactions_added"] += count
|
|
287
|
+
stats["sessions_processed"] += 1
|
|
288
|
+
processed += 1
|
|
289
|
+
|
|
290
|
+
logger.info(
|
|
291
|
+
"Session %s: %s files, %d interactions, branch=%s",
|
|
292
|
+
session_id[:8],
|
|
293
|
+
len(git_ctx.get("files_changed", [])),
|
|
294
|
+
count,
|
|
295
|
+
git_ctx.get("branch", "n/a"),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if max_sessions and processed >= max_sessions:
|
|
299
|
+
break
|
|
300
|
+
|
|
301
|
+
return stats
|