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.
@@ -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", ""))