dev-recall 0.2.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.
- dev_recall-0.2.0.dist-info/METADATA +281 -0
- dev_recall-0.2.0.dist-info/RECORD +34 -0
- dev_recall-0.2.0.dist-info/WHEEL +5 -0
- dev_recall-0.2.0.dist-info/entry_points.txt +2 -0
- dev_recall-0.2.0.dist-info/top_level.txt +1 -0
- recall/__init__.py +3 -0
- recall/_hooks.py +211 -0
- recall/cli.py +1032 -0
- recall/collectors/__init__.py +1 -0
- recall/collectors/ai_chat.py +644 -0
- recall/collectors/containers.py +164 -0
- recall/collectors/git.py +540 -0
- recall/collectors/linux_process.py +230 -0
- recall/collectors/linux_session.py +229 -0
- recall/collectors/linux_window.py +199 -0
- recall/collectors/shell.py +300 -0
- recall/collectors/vscode.py +175 -0
- recall/config.py +257 -0
- recall/daemon.py +466 -0
- recall/daemon_main.py +25 -0
- recall/mcp_server.py +290 -0
- recall/models.py +225 -0
- recall/processor/__init__.py +1 -0
- recall/processor/embedder.py +213 -0
- recall/processor/enricher.py +213 -0
- recall/processor/session.py +142 -0
- recall/query/__init__.py +1 -0
- recall/query/context.py +130 -0
- recall/query/llm.py +85 -0
- recall/query/retriever.py +147 -0
- recall/query/timeparser.py +188 -0
- recall/storage/__init__.py +1 -0
- recall/storage/db.py +528 -0
- recall/storage/vectors.py +166 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Collectors package."""
|
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"""AI chat log collector — watches Copilot, Claude Code, Aider, and Cursor logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
from watchdog.events import FileModifiedEvent, FileSystemEventHandler
|
|
16
|
+
from watchdog.observers import Observer
|
|
17
|
+
|
|
18
|
+
from recall.models import Event, EventType, Source, build_content
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Maximum characters to store from AI chat messages
|
|
23
|
+
_MAX_CHARS = 200
|
|
24
|
+
|
|
25
|
+
# KV key prefix for per-file byte offsets
|
|
26
|
+
_OFFSET_KEY_PREFIX = "ai_chat_offset:"
|
|
27
|
+
|
|
28
|
+
# KV key for set of processed message content hashes
|
|
29
|
+
_HASH_SET_KEY = "ai_chat_hashes"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AIChatCollector:
|
|
33
|
+
"""
|
|
34
|
+
Watches known AI tool log directories for new conversation messages.
|
|
35
|
+
|
|
36
|
+
Supported sources:
|
|
37
|
+
- GitHub Copilot Chat (~/.config/Code/User/workspaceStorage/*/GitHub.copilot-chat/debug-logs/*.jsonl)
|
|
38
|
+
- Claude Code (~/.claude/projects/*/sessions/*.jsonl)
|
|
39
|
+
- Aider (.aider.chat.history.md in any git repo root)
|
|
40
|
+
- Cursor (~/.config/Cursor/User/workspaceStorage/*)
|
|
41
|
+
- Gemini CLI (~/.gemini/logs/*.jsonl)
|
|
42
|
+
- Continue.dev (~/.continue/sessions/*.json)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
event_callback: Callable[[Event], None],
|
|
48
|
+
get_kv: Callable[[str], Optional[str]],
|
|
49
|
+
set_kv: Callable[[str, str], None],
|
|
50
|
+
ai_chat_max_chars: int = _MAX_CHARS,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._callback = event_callback
|
|
53
|
+
self._get_kv = get_kv
|
|
54
|
+
self._set_kv = set_kv
|
|
55
|
+
self._max_chars = ai_chat_max_chars
|
|
56
|
+
|
|
57
|
+
self._observer = Observer()
|
|
58
|
+
self._stop_event = threading.Event()
|
|
59
|
+
self._lock = threading.Lock()
|
|
60
|
+
# file_path → byte offset
|
|
61
|
+
self._offsets: dict[str, int] = {}
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
# Public
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def start(self) -> None:
|
|
68
|
+
watch_dirs = self._collect_watch_dirs()
|
|
69
|
+
for watch_dir, recursive in watch_dirs:
|
|
70
|
+
if watch_dir.exists():
|
|
71
|
+
handler = _AIChatHandler(self, watch_dir)
|
|
72
|
+
self._observer.schedule(handler, str(watch_dir), recursive=recursive)
|
|
73
|
+
logger.info("AIChatCollector watching %s (recursive=%s)", watch_dir, recursive)
|
|
74
|
+
|
|
75
|
+
# Aider: watch each git repo root found at startup so live edits are captured
|
|
76
|
+
for repo_root in _find_git_repos_shallow(Path.home(), depth=3):
|
|
77
|
+
if not self._observer.is_alive() if hasattr(self._observer, 'is_alive') else False:
|
|
78
|
+
break
|
|
79
|
+
handler = _AIChatHandler(self, repo_root)
|
|
80
|
+
try:
|
|
81
|
+
self._observer.schedule(handler, str(repo_root), recursive=False)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
# Scan existing files on startup
|
|
86
|
+
self._scan_existing()
|
|
87
|
+
|
|
88
|
+
self._observer.start()
|
|
89
|
+
logger.info("AIChatCollector started")
|
|
90
|
+
|
|
91
|
+
def stop(self) -> None:
|
|
92
|
+
self._stop_event.set()
|
|
93
|
+
self._observer.stop()
|
|
94
|
+
self._observer.join(timeout=5)
|
|
95
|
+
logger.info("AIChatCollector stopped")
|
|
96
|
+
|
|
97
|
+
def handle_file_change(self, file_path: Path) -> None:
|
|
98
|
+
"""Called by watchdog handler when a watched file changes."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
self._process_file(file_path)
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Watch directories
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _collect_watch_dirs() -> list[tuple[Path, bool]]:
|
|
108
|
+
"""Return (directory, recursive) pairs to watch."""
|
|
109
|
+
home = Path.home()
|
|
110
|
+
dirs = [
|
|
111
|
+
# Copilot Chat
|
|
112
|
+
(home / ".config" / "Code" / "User" / "workspaceStorage", True),
|
|
113
|
+
# Claude Code
|
|
114
|
+
(home / ".claude" / "projects", True),
|
|
115
|
+
# Cursor
|
|
116
|
+
(home / ".config" / "Cursor" / "User" / "workspaceStorage", True),
|
|
117
|
+
# Gemini CLI
|
|
118
|
+
(home / ".gemini" / "logs", False),
|
|
119
|
+
# Continue.dev
|
|
120
|
+
(home / ".continue" / "sessions", False),
|
|
121
|
+
]
|
|
122
|
+
return [(d, recursive) for d, recursive in dirs]
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Scan on startup
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def _scan_existing(self) -> None:
|
|
129
|
+
"""Process any log files that already exist (catch-up on daemon start)."""
|
|
130
|
+
home = Path.home()
|
|
131
|
+
jsonl_bases = [
|
|
132
|
+
home / ".config" / "Code" / "User" / "workspaceStorage",
|
|
133
|
+
home / ".claude" / "projects",
|
|
134
|
+
home / ".config" / "Cursor" / "User" / "workspaceStorage",
|
|
135
|
+
]
|
|
136
|
+
for base in jsonl_bases:
|
|
137
|
+
if not base.exists():
|
|
138
|
+
continue
|
|
139
|
+
for jsonl in base.rglob("*.jsonl"):
|
|
140
|
+
self._process_file(jsonl)
|
|
141
|
+
|
|
142
|
+
# Gemini CLI logs
|
|
143
|
+
gemini_dir = home / ".gemini" / "logs"
|
|
144
|
+
if gemini_dir.exists():
|
|
145
|
+
for f in gemini_dir.glob("*.jsonl"):
|
|
146
|
+
self._process_file(f)
|
|
147
|
+
|
|
148
|
+
# Continue.dev sessions
|
|
149
|
+
continue_dir = home / ".continue" / "sessions"
|
|
150
|
+
if continue_dir.exists():
|
|
151
|
+
for f in continue_dir.glob("*.json"):
|
|
152
|
+
self._process_file(f)
|
|
153
|
+
|
|
154
|
+
# Aider: scan git repos for .aider.chat.history.md
|
|
155
|
+
for repo_root in _find_git_repos_shallow(home, depth=3):
|
|
156
|
+
aider_log = repo_root / ".aider.chat.history.md"
|
|
157
|
+
if aider_log.exists():
|
|
158
|
+
self._process_file(aider_log)
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# File processing
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def _process_file(self, path: Path) -> None:
|
|
165
|
+
suffix = path.suffix.lower()
|
|
166
|
+
name = path.name
|
|
167
|
+
path_str = str(path)
|
|
168
|
+
if suffix == ".jsonl":
|
|
169
|
+
if "copilot-chat" in path_str or "GitHub.copilot-chat" in path_str:
|
|
170
|
+
self._process_copilot_jsonl(path)
|
|
171
|
+
elif ".claude" in path_str:
|
|
172
|
+
self._process_claude_jsonl(path)
|
|
173
|
+
elif "Cursor" in path_str:
|
|
174
|
+
self._process_copilot_jsonl(path) # same format
|
|
175
|
+
elif ".gemini" in path_str:
|
|
176
|
+
self._process_gemini_jsonl(path)
|
|
177
|
+
elif suffix == ".json" and ".continue" in path_str:
|
|
178
|
+
self._process_continue_session_json(path)
|
|
179
|
+
elif name == ".aider.chat.history.md":
|
|
180
|
+
self._process_aider_md(path)
|
|
181
|
+
|
|
182
|
+
def _get_file_offset(self, path: Path) -> int:
|
|
183
|
+
key = _OFFSET_KEY_PREFIX + str(path)
|
|
184
|
+
stored = self._offsets.get(str(path))
|
|
185
|
+
if stored is not None:
|
|
186
|
+
return stored
|
|
187
|
+
kv = self._get_kv(key)
|
|
188
|
+
offset = int(kv) if kv else 0
|
|
189
|
+
self._offsets[str(path)] = offset
|
|
190
|
+
return offset
|
|
191
|
+
|
|
192
|
+
def _set_file_offset(self, path: Path, offset: int) -> None:
|
|
193
|
+
self._offsets[str(path)] = offset
|
|
194
|
+
self._set_kv(_OFFSET_KEY_PREFIX + str(path), str(offset))
|
|
195
|
+
|
|
196
|
+
def _read_new_content(self, path: Path) -> Optional[bytes]:
|
|
197
|
+
"""Read new bytes since last offset, returning None on error."""
|
|
198
|
+
offset = self._get_file_offset(path)
|
|
199
|
+
try:
|
|
200
|
+
size = path.stat().st_size
|
|
201
|
+
except OSError:
|
|
202
|
+
return None
|
|
203
|
+
if size <= offset:
|
|
204
|
+
return None
|
|
205
|
+
try:
|
|
206
|
+
with path.open("rb") as fh:
|
|
207
|
+
fh.seek(offset)
|
|
208
|
+
content = fh.read()
|
|
209
|
+
self._set_file_offset(path, fh.tell())
|
|
210
|
+
return content
|
|
211
|
+
except OSError:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
# Copilot / Cursor JSONL parser
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def _process_copilot_jsonl(self, path: Path) -> None:
|
|
219
|
+
new_bytes = self._read_new_content(path)
|
|
220
|
+
if not new_bytes:
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
# Derive workspace/repo from path
|
|
224
|
+
repo_name = _repo_name_from_copilot_path(path)
|
|
225
|
+
ai_source = "copilot" if "copilot" in str(path).lower() else "cursor"
|
|
226
|
+
|
|
227
|
+
for raw_line in new_bytes.decode("utf-8", errors="replace").splitlines():
|
|
228
|
+
raw_line = raw_line.strip()
|
|
229
|
+
if not raw_line:
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
entry = json.loads(raw_line)
|
|
233
|
+
except json.JSONDecodeError:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
events = self._extract_copilot_messages(entry, repo_name, ai_source)
|
|
237
|
+
for event in events:
|
|
238
|
+
if not self._is_duplicate(event):
|
|
239
|
+
try:
|
|
240
|
+
self._callback(event)
|
|
241
|
+
except Exception:
|
|
242
|
+
logger.exception("Error in ai_chat event callback")
|
|
243
|
+
|
|
244
|
+
def _extract_copilot_messages(
|
|
245
|
+
self, entry: dict, repo_name: str, ai_source: str
|
|
246
|
+
) -> list[Event]:
|
|
247
|
+
results: list[Event]= []
|
|
248
|
+
# Copilot debug logs have various shapes — try common structures
|
|
249
|
+
messages: list[dict] = []
|
|
250
|
+
|
|
251
|
+
if "messages" in entry and isinstance(entry["messages"], list):
|
|
252
|
+
messages = entry["messages"]
|
|
253
|
+
elif "request" in entry and isinstance(entry.get("request"), dict):
|
|
254
|
+
req = entry["request"]
|
|
255
|
+
if "messages" in req:
|
|
256
|
+
messages = req["messages"]
|
|
257
|
+
elif "role" in entry and "content" in entry:
|
|
258
|
+
messages = [entry]
|
|
259
|
+
|
|
260
|
+
ts = _extract_ts(entry)
|
|
261
|
+
for msg in messages:
|
|
262
|
+
event = self._build_ai_event(msg, ts, repo_name, ai_source, Source.AI_CHAT_PARSER)
|
|
263
|
+
if event:
|
|
264
|
+
results.append(event)
|
|
265
|
+
return results
|
|
266
|
+
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
# Claude Code JSONL parser
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def _process_claude_jsonl(self, path: Path) -> None:
|
|
272
|
+
new_bytes = self._read_new_content(path)
|
|
273
|
+
if not new_bytes:
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
repo_name = _repo_name_from_claude_path(path)
|
|
277
|
+
|
|
278
|
+
for raw_line in new_bytes.decode("utf-8", errors="replace").splitlines():
|
|
279
|
+
raw_line = raw_line.strip()
|
|
280
|
+
if not raw_line:
|
|
281
|
+
continue
|
|
282
|
+
try:
|
|
283
|
+
entry = json.loads(raw_line)
|
|
284
|
+
except json.JSONDecodeError:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
ts = _extract_ts(entry)
|
|
288
|
+
# Claude Code entries typically have "role" and "content" at the top level
|
|
289
|
+
msg_list: list[dict] = []
|
|
290
|
+
if "role" in entry:
|
|
291
|
+
msg_list = [entry]
|
|
292
|
+
elif "messages" in entry and isinstance(entry["messages"], list):
|
|
293
|
+
msg_list = entry["messages"]
|
|
294
|
+
|
|
295
|
+
for msg in msg_list:
|
|
296
|
+
event = self._build_ai_event(msg, ts, repo_name, "claude", Source.AI_CHAT_PARSER)
|
|
297
|
+
if event and not self._is_duplicate(event):
|
|
298
|
+
try:
|
|
299
|
+
self._callback(event)
|
|
300
|
+
except Exception:
|
|
301
|
+
logger.exception("Error in claude event callback")
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
# Gemini CLI JSONL parser
|
|
305
|
+
# ------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
def _process_gemini_jsonl(self, path: Path) -> None:
|
|
308
|
+
new_bytes = self._read_new_content(path)
|
|
309
|
+
if not new_bytes:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
for raw_line in new_bytes.decode("utf-8", errors="replace").splitlines():
|
|
313
|
+
raw_line = raw_line.strip()
|
|
314
|
+
if not raw_line:
|
|
315
|
+
continue
|
|
316
|
+
try:
|
|
317
|
+
entry = json.loads(raw_line)
|
|
318
|
+
except json.JSONDecodeError:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
# Gemini CLI stores {role, parts: [{text: ...}]} or {role, content: ...}
|
|
322
|
+
role = entry.get("role", "")
|
|
323
|
+
if role not in ("user", "model", "assistant"):
|
|
324
|
+
# Might be a wrapper — try extracting messages list
|
|
325
|
+
messages = entry.get("messages") or entry.get("contents", [])
|
|
326
|
+
ts = _extract_ts(entry)
|
|
327
|
+
for msg in messages if isinstance(messages, list) else []:
|
|
328
|
+
normalized_role = "assistant" if msg.get("role") in ("model",) else msg.get("role", "")
|
|
329
|
+
event = self._build_ai_event(
|
|
330
|
+
{"role": normalized_role, "content": _extract_gemini_text(msg)},
|
|
331
|
+
ts, "", "gemini", Source.AI_CHAT_PARSER,
|
|
332
|
+
)
|
|
333
|
+
if event and not self._is_duplicate(event):
|
|
334
|
+
try:
|
|
335
|
+
self._callback(event)
|
|
336
|
+
except Exception:
|
|
337
|
+
logger.exception("Error in gemini event callback")
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
# Normalize "model" → "assistant"
|
|
341
|
+
normalized_role = "assistant" if role == "model" else role
|
|
342
|
+
text = _extract_gemini_text(entry)
|
|
343
|
+
ts = _extract_ts(entry)
|
|
344
|
+
event = self._build_ai_event(
|
|
345
|
+
{"role": normalized_role, "content": text},
|
|
346
|
+
ts, "", "gemini", Source.AI_CHAT_PARSER,
|
|
347
|
+
)
|
|
348
|
+
if event and not self._is_duplicate(event):
|
|
349
|
+
try:
|
|
350
|
+
self._callback(event)
|
|
351
|
+
except Exception:
|
|
352
|
+
logger.exception("Error in gemini event callback")
|
|
353
|
+
|
|
354
|
+
# ------------------------------------------------------------------
|
|
355
|
+
# Continue.dev session JSON parser
|
|
356
|
+
# ------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
def _process_continue_session_json(self, path: Path) -> None:
|
|
359
|
+
"""Parse a Continue.dev session JSON file.
|
|
360
|
+
|
|
361
|
+
Format: list of {"message": {"role": ..., "content": ...}, ...}
|
|
362
|
+
or: {"sessionId": ..., "history": [{"message": {...}}, ...]}
|
|
363
|
+
"""
|
|
364
|
+
try:
|
|
365
|
+
raw = path.read_bytes()
|
|
366
|
+
except OSError:
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
# Only re-process if file content changed (use size as proxy)
|
|
370
|
+
offset = self._get_file_offset(path)
|
|
371
|
+
current_size = len(raw)
|
|
372
|
+
if current_size <= offset:
|
|
373
|
+
return
|
|
374
|
+
self._set_file_offset(path, current_size)
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
data = json.loads(raw.decode("utf-8", errors="replace"))
|
|
378
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
repo_name = path.stem # session ID as a proxy name
|
|
382
|
+
now_ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
383
|
+
|
|
384
|
+
# Normalise to a flat list of messages
|
|
385
|
+
messages: list[dict] = []
|
|
386
|
+
if isinstance(data, list):
|
|
387
|
+
# [{"message": {"role": ..., "content": ...}}, ...]
|
|
388
|
+
for item in data:
|
|
389
|
+
if isinstance(item, dict):
|
|
390
|
+
msg = item.get("message", item)
|
|
391
|
+
messages.append(msg)
|
|
392
|
+
elif isinstance(data, dict):
|
|
393
|
+
history = data.get("history", data.get("messages", []))
|
|
394
|
+
for item in history if isinstance(history, list) else []:
|
|
395
|
+
if isinstance(item, dict):
|
|
396
|
+
msg = item.get("message", item)
|
|
397
|
+
messages.append(msg)
|
|
398
|
+
|
|
399
|
+
for msg in messages:
|
|
400
|
+
ts = _extract_ts(msg) if isinstance(msg, dict) else now_ts
|
|
401
|
+
event = self._build_ai_event(msg, ts, repo_name, "continue", Source.AI_CHAT_PARSER)
|
|
402
|
+
if event and not self._is_duplicate(event):
|
|
403
|
+
try:
|
|
404
|
+
self._callback(event)
|
|
405
|
+
except Exception:
|
|
406
|
+
logger.exception("Error in continue.dev event callback")
|
|
407
|
+
|
|
408
|
+
# ------------------------------------------------------------------
|
|
409
|
+
# Aider markdown parser
|
|
410
|
+
# ------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
def _process_aider_md(self, path: Path) -> None:
|
|
413
|
+
new_bytes = self._read_new_content(path)
|
|
414
|
+
if not new_bytes:
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
repo_name = path.parent.name
|
|
418
|
+
text = new_bytes.decode("utf-8", errors="replace")
|
|
419
|
+
|
|
420
|
+
# Aider uses "> " prefix for user messages and no prefix for assistant
|
|
421
|
+
# Format: #### timestamp\n> user message\n\nassistant message\n\n
|
|
422
|
+
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
423
|
+
now_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
424
|
+
|
|
425
|
+
for chunk in re.split(r"^####\s+", text, flags=re.MULTILINE):
|
|
426
|
+
if not chunk.strip():
|
|
427
|
+
continue
|
|
428
|
+
lines = chunk.splitlines()
|
|
429
|
+
ts_str = now_str
|
|
430
|
+
date_str = now_date
|
|
431
|
+
# Try to parse timestamp from first line
|
|
432
|
+
if lines:
|
|
433
|
+
try:
|
|
434
|
+
dt = datetime.fromisoformat(lines[0].strip().replace("Z", "+00:00"))
|
|
435
|
+
ts_str = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
436
|
+
date_str = dt.astimezone(timezone.utc).strftime("%Y-%m-%d")
|
|
437
|
+
lines = lines[1:]
|
|
438
|
+
except ValueError:
|
|
439
|
+
pass
|
|
440
|
+
|
|
441
|
+
body = "\n".join(lines)
|
|
442
|
+
# User lines start with "> "
|
|
443
|
+
user_parts = []
|
|
444
|
+
assistant_parts = []
|
|
445
|
+
for line in body.splitlines():
|
|
446
|
+
if line.startswith("> "):
|
|
447
|
+
user_parts.append(line[2:])
|
|
448
|
+
else:
|
|
449
|
+
assistant_parts.append(line)
|
|
450
|
+
|
|
451
|
+
for role, parts in [("user", user_parts), ("assistant", assistant_parts)]:
|
|
452
|
+
if not parts:
|
|
453
|
+
continue
|
|
454
|
+
preview = " ".join(parts).strip()[: self._max_chars]
|
|
455
|
+
if not preview:
|
|
456
|
+
continue
|
|
457
|
+
raw = {
|
|
458
|
+
"role": role,
|
|
459
|
+
"message_preview": preview,
|
|
460
|
+
"repo_name": repo_name,
|
|
461
|
+
"ai_source": "aider",
|
|
462
|
+
}
|
|
463
|
+
event = Event(
|
|
464
|
+
timestamp=ts_str,
|
|
465
|
+
date=date_str,
|
|
466
|
+
event_type=EventType.AI_CHAT,
|
|
467
|
+
source=Source.AI_CHAT_PARSER,
|
|
468
|
+
content=build_content(EventType.AI_CHAT, raw),
|
|
469
|
+
raw_data=raw,
|
|
470
|
+
repo_name=repo_name,
|
|
471
|
+
repo_path=str(path.parent),
|
|
472
|
+
)
|
|
473
|
+
if not self._is_duplicate(event):
|
|
474
|
+
try:
|
|
475
|
+
self._callback(event)
|
|
476
|
+
except Exception:
|
|
477
|
+
logger.exception("Error in aider event callback")
|
|
478
|
+
|
|
479
|
+
# ------------------------------------------------------------------
|
|
480
|
+
# Shared helpers
|
|
481
|
+
# ------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
def _build_ai_event(
|
|
484
|
+
self,
|
|
485
|
+
msg: dict,
|
|
486
|
+
ts: str,
|
|
487
|
+
repo_name: str,
|
|
488
|
+
ai_source: str,
|
|
489
|
+
source: Source,
|
|
490
|
+
) -> Optional[Event]:
|
|
491
|
+
role = msg.get("role", "")
|
|
492
|
+
if role not in ("user", "assistant"):
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
content_raw = msg.get("content", "")
|
|
496
|
+
if isinstance(content_raw, list):
|
|
497
|
+
# Content can be an array of content blocks
|
|
498
|
+
parts = [
|
|
499
|
+
block.get("text", "") if isinstance(block, dict) else str(block)
|
|
500
|
+
for block in content_raw
|
|
501
|
+
]
|
|
502
|
+
content_raw = " ".join(parts)
|
|
503
|
+
|
|
504
|
+
preview = str(content_raw).strip()[: self._max_chars]
|
|
505
|
+
if not preview:
|
|
506
|
+
return None
|
|
507
|
+
|
|
508
|
+
# Derive date from timestamp
|
|
509
|
+
try:
|
|
510
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
511
|
+
date_str = dt.astimezone(timezone.utc).strftime("%Y-%m-%d")
|
|
512
|
+
except ValueError:
|
|
513
|
+
dt = datetime.now(timezone.utc)
|
|
514
|
+
ts = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
515
|
+
date_str = dt.strftime("%Y-%m-%d")
|
|
516
|
+
|
|
517
|
+
raw = {
|
|
518
|
+
"role": role,
|
|
519
|
+
"message_preview": preview,
|
|
520
|
+
"repo_name": repo_name,
|
|
521
|
+
"ai_source": ai_source,
|
|
522
|
+
}
|
|
523
|
+
return Event(
|
|
524
|
+
timestamp=ts,
|
|
525
|
+
date=date_str,
|
|
526
|
+
event_type=EventType.AI_CHAT,
|
|
527
|
+
source=source,
|
|
528
|
+
content=build_content(EventType.AI_CHAT, raw),
|
|
529
|
+
raw_data=raw,
|
|
530
|
+
repo_name=repo_name if repo_name else None,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
def _is_duplicate(self, event: Event) -> bool:
|
|
534
|
+
"""Return True if this event's (content, timestamp) hash has been seen before."""
|
|
535
|
+
h = hashlib.sha256(f"{event.content}|{event.timestamp}".encode()).hexdigest()[:16]
|
|
536
|
+
seen_json = self._get_kv(_HASH_SET_KEY) or "[]"
|
|
537
|
+
try:
|
|
538
|
+
seen: list[str] = json.loads(seen_json)
|
|
539
|
+
except json.JSONDecodeError:
|
|
540
|
+
seen = []
|
|
541
|
+
if h in seen:
|
|
542
|
+
return True
|
|
543
|
+
# Keep last 10 000 hashes to bound storage
|
|
544
|
+
seen.append(h)
|
|
545
|
+
if len(seen) > 10_000:
|
|
546
|
+
seen = seen[-10_000:]
|
|
547
|
+
self._set_kv(_HASH_SET_KEY, json.dumps(seen))
|
|
548
|
+
return False
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# ---------------------------------------------------------------------------
|
|
552
|
+
# Watchdog handler
|
|
553
|
+
# ---------------------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class _AIChatHandler(FileSystemEventHandler):
|
|
557
|
+
def __init__(self, collector: AIChatCollector, base_dir: Path) -> None:
|
|
558
|
+
self._collector = collector
|
|
559
|
+
self._base = base_dir
|
|
560
|
+
|
|
561
|
+
def on_modified(self, event: FileModifiedEvent) -> None:
|
|
562
|
+
if event.is_directory:
|
|
563
|
+
return
|
|
564
|
+
path = Path(str(event.src_path))
|
|
565
|
+
if path.suffix in (".jsonl", ".json") or path.name == ".aider.chat.history.md":
|
|
566
|
+
self._collector.handle_file_change(path)
|
|
567
|
+
|
|
568
|
+
def on_created(self, event) -> None:
|
|
569
|
+
self.on_modified(event)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# ---------------------------------------------------------------------------
|
|
573
|
+
# Path helpers
|
|
574
|
+
# ---------------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _repo_name_from_copilot_path(path: Path) -> str:
|
|
578
|
+
"""
|
|
579
|
+
Copilot logs live inside workspaceStorage/<hash>/GitHub.copilot-chat/debug-logs/
|
|
580
|
+
The workspace name is encoded in the hash or the parent workspaceStorage metadata.
|
|
581
|
+
Best we can do without reading VS Code internals: use the hash folder name.
|
|
582
|
+
"""
|
|
583
|
+
parts = path.parts
|
|
584
|
+
for i, part in enumerate(parts):
|
|
585
|
+
if part == "workspaceStorage" and i + 1 < len(parts):
|
|
586
|
+
return parts[i + 1][:8] # short hash
|
|
587
|
+
return ""
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _repo_name_from_claude_path(path: Path) -> str:
|
|
591
|
+
"""Claude projects: ~/.claude/projects/<project_name>/sessions/*.jsonl"""
|
|
592
|
+
parts = path.parts
|
|
593
|
+
for i, part in enumerate(parts):
|
|
594
|
+
if part == "projects" and i + 1 < len(parts):
|
|
595
|
+
return parts[i + 1]
|
|
596
|
+
return ""
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _extract_ts(entry: dict) -> str:
|
|
600
|
+
"""Extract a timestamp from a log entry dict, falling back to now."""
|
|
601
|
+
for key in ("timestamp", "ts", "time", "created_at", "date"):
|
|
602
|
+
if key in entry:
|
|
603
|
+
val = str(entry[key])
|
|
604
|
+
try:
|
|
605
|
+
dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
|
|
606
|
+
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
607
|
+
except ValueError:
|
|
608
|
+
pass
|
|
609
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _find_git_repos_shallow(root: Path, depth: int) -> list[Path]:
|
|
613
|
+
repos: list[Path] = []
|
|
614
|
+
try:
|
|
615
|
+
for child in root.iterdir():
|
|
616
|
+
if not child.is_dir() or child.name.startswith("."):
|
|
617
|
+
continue
|
|
618
|
+
if (child / ".git").exists():
|
|
619
|
+
repos.append(child)
|
|
620
|
+
elif depth > 1:
|
|
621
|
+
repos.extend(_find_git_repos_shallow(child, depth - 1))
|
|
622
|
+
except (PermissionError, OSError):
|
|
623
|
+
pass
|
|
624
|
+
return repos
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _extract_gemini_text(entry: dict) -> str:
|
|
628
|
+
"""Extract plain text from a Gemini CLI message entry.
|
|
629
|
+
|
|
630
|
+
Gemini uses `parts: [{text: ...}]` or `content: ...`.
|
|
631
|
+
"""
|
|
632
|
+
# parts: [{text: ...}, ...]
|
|
633
|
+
parts = entry.get("parts") or entry.get("content", [])
|
|
634
|
+
if isinstance(parts, list):
|
|
635
|
+
texts = []
|
|
636
|
+
for part in parts:
|
|
637
|
+
if isinstance(part, dict):
|
|
638
|
+
texts.append(str(part.get("text", "")))
|
|
639
|
+
elif isinstance(part, str):
|
|
640
|
+
texts.append(part)
|
|
641
|
+
return " ".join(texts)
|
|
642
|
+
if isinstance(parts, str):
|
|
643
|
+
return parts
|
|
644
|
+
return str(entry.get("text", ""))
|