multi-agent-archiver 0.1.5__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.
chatarch/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """chatarch: archive local AI chat histories to S3."""
2
+
3
+ from chatarch.archiver import ChatArchiver
4
+
5
+ __all__ = ["ChatArchiver"]
chatarch/archiver.py ADDED
@@ -0,0 +1,635 @@
1
+ import os
2
+ import json
3
+ import yaml
4
+ import glob
5
+ import re
6
+ import boto3
7
+ from pathlib import Path
8
+ import logging
9
+ from logging.handlers import TimedRotatingFileHandler
10
+ from google import genai
11
+ from datetime import datetime, timedelta, timezone
12
+ import time
13
+ import random
14
+ from tqdm import tqdm
15
+ from rich.console import Console
16
+ import subprocess
17
+
18
+ from multi_agent_registry import discover_agent_chats, get_chat_workspace, get_chat_last_active
19
+
20
+ # Categories whose file discovery is delegated to agent-cli-registry instead
21
+ # of the `paths` globs in config.yaml. `search_roots` is only meaningful for
22
+ # patterns relative to a project (e.g. Aider's history file); registry-global
23
+ # patterns (e.g. Claude Code's ~/.claude/projects/**) ignore it.
24
+ REGISTRY_BACKED_CATEGORIES = {
25
+ "claude": {"agent_id": "claude"},
26
+ "aider": {
27
+ "agent_id": "aider",
28
+ "search_roots": [Path.home() / "repos", Path.home() / "projects"],
29
+ },
30
+ }
31
+
32
+ LOG_DIR = Path(".logs")
33
+ LOG_RETENTION_DAYS = 180
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _logging_configured = False
38
+
39
+
40
+ def _configure_logging():
41
+ """Set up daily rotating file logging, on first actual use only.
42
+
43
+ Deferred out of module import so that a no-op invocation (bare `cax`,
44
+ `--version`, `--help`) never touches the filesystem -- only creating
45
+ .logs/archiver.log once an archiver actually runs.
46
+ """
47
+ global _logging_configured
48
+ if _logging_configured:
49
+ return
50
+ _logging_configured = True
51
+
52
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
53
+ file_handler = TimedRotatingFileHandler(
54
+ LOG_DIR / "archiver.log",
55
+ when="midnight",
56
+ backupCount=LOG_RETENTION_DAYS,
57
+ encoding="utf-8",
58
+ )
59
+ file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
60
+
61
+ # Console handler for critical errors only
62
+ console_handler = logging.StreamHandler()
63
+ console_handler.setLevel(logging.WARNING)
64
+
65
+ logging.basicConfig(level=logging.INFO, handlers=[file_handler, console_handler])
66
+
67
+
68
+ class ChatArchiver:
69
+ def __init__(self, config_path="config.yaml", dry_run=False, limit=None, days_override=None):
70
+ _configure_logging()
71
+ with open(config_path, "r") as f:
72
+ self.config = yaml.safe_load(f)
73
+
74
+ self.dry_run = dry_run
75
+ self.limit = limit
76
+ self.days_override = days_override
77
+ self.processed_count = 0
78
+
79
+ self.retention_days = self.days_override if self.days_override is not None else self.config.get('retention', {}).get('days', 20)
80
+ self.keep_count = self.config.get('retention', {}).get('keep_recent', 5)
81
+
82
+ # Load workspace index if it exists
83
+ self.workspace_index = {}
84
+ index_path = Path("data/workspace_index.json")
85
+ if index_path.exists():
86
+ try:
87
+ with open(index_path, "r") as f:
88
+ data = json.load(f)
89
+ for entry in data.get("workspaces", []) + data.get("unmatched_hashes", []):
90
+ if entry.get("hash"):
91
+ self.workspace_index[entry["hash"]] = entry.get("resolved_path") or entry.get("clue_path")
92
+ except Exception as e:
93
+ logger.error(f"Error loading workspace index: {e}")
94
+
95
+ self._s3_client = None
96
+ self._gemini_client = None
97
+ self.gemini_api_key = None
98
+ self._gemini_key_fetch_attempted = False
99
+
100
+ self.request_delay = self.config.get('summarization', {}).get('delay', 10)
101
+ self.total_bytes_saved = 0
102
+ self.console = Console()
103
+
104
+ @property
105
+ def s3_client(self):
106
+ if self._s3_client is None:
107
+ self._s3_client = boto3.client('s3')
108
+ return self._s3_client
109
+
110
+ @property
111
+ def client(self):
112
+ """Lazy loader for the Gemini client."""
113
+ if self._gemini_client is None and not self._gemini_key_fetch_attempted:
114
+ self._gemini_key_fetch_attempted = True
115
+ # Check environment for GEMINI_API_KEY or legacy GOOGLE_API_KEY
116
+ self.gemini_api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
117
+
118
+ if not self.gemini_api_key:
119
+ # Attempt to fetch from 1Password
120
+ secret_ref = "op://Private/GEMINI_API_KEY/credential"
121
+ try:
122
+ logger.info(f"GEMINI_API_KEY not found in environment. Fetching from 1Password ({secret_ref})...")
123
+
124
+ # Try 'op' first, then 'op.exe' for WSL compatibility with Windows Hello.
125
+ # A timeout is required: op.exe can block indefinitely on an
126
+ # unanswered Windows Hello prompt in a headless run.
127
+ for op_cmd in ["op", "op.exe"]:
128
+ try:
129
+ result = subprocess.run(
130
+ [op_cmd, "read", secret_ref],
131
+ capture_output=True,
132
+ text=True,
133
+ check=True,
134
+ timeout=10,
135
+ )
136
+ self.gemini_api_key = result.stdout.strip()
137
+ if self.gemini_api_key:
138
+ break
139
+ except subprocess.TimeoutExpired:
140
+ logger.warning(f"Timed out waiting for '{op_cmd}' (10s) -- skipping.")
141
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
142
+ # Only warn on the last attempt
143
+ if op_cmd == "op.exe":
144
+ error_msg = getattr(e, 'stderr', str(e)).strip()
145
+ logger.warning(f"Failed to fetch GEMINI_API_KEY from 1Password: {error_msg}")
146
+ except Exception as e:
147
+ logger.warning(f"An unexpected error occurred while fetching from 1Password: {e}")
148
+
149
+ if self.gemini_api_key:
150
+ self._gemini_client = genai.Client(api_key=self.gemini_api_key)
151
+ else:
152
+ logger.warning("Summarization client unavailable (missing GEMINI_API_KEY).")
153
+ return self._gemini_client
154
+
155
+ def format_size(self, bytes_val):
156
+ for unit in ['B', 'KB', 'MB', 'GB']:
157
+ if bytes_val < 1024:
158
+ return f"{bytes_val:.2f} {unit}"
159
+ bytes_val /= 1024
160
+ return f"{bytes_val:.2f} TB"
161
+
162
+ def discover_files(self, category):
163
+ if category in REGISTRY_BACKED_CATEGORIES:
164
+ cfg = REGISTRY_BACKED_CATEGORIES[category]
165
+ chats = discover_agent_chats(
166
+ agent_id=cfg["agent_id"],
167
+ search_roots=cfg.get("search_roots"),
168
+ )
169
+ return sorted({str(c.path) for c in chats})
170
+
171
+ patterns = self.config['paths'].get(category, [])
172
+ files = []
173
+ for pattern in patterns:
174
+ expanded_pattern = str(Path(pattern).expanduser())
175
+ found = glob.glob(expanded_pattern, recursive=True)
176
+ files.extend(found)
177
+ return sorted(list(set(files)))
178
+
179
+ def is_gemini_commit_only(self, file_path):
180
+ try:
181
+ with open(file_path, "r") as f:
182
+ data = json.load(f)
183
+
184
+ messages = data.get("messages", [])
185
+
186
+ # Case 1: Empty conversation
187
+ if not messages:
188
+ return True, "Empty conversation"
189
+
190
+ # Check the very first message
191
+ first_msg_raw = messages[0].get("content", "")
192
+ if isinstance(first_msg_raw, list):
193
+ first_msg_text = " ".join([str(p) for p in first_msg_raw])
194
+ else:
195
+ first_msg_text = str(first_msg_raw)
196
+
197
+ # Case 2: Contentless conversation
198
+ if not first_msg_text.strip():
199
+ return True, "No content in first message"
200
+
201
+ first_msg_lower = first_msg_text.lower()
202
+ commit_patterns = [
203
+ "git diff --cached",
204
+ "create a git commit",
205
+ "perform the commit without asking",
206
+ "create a git commit for all staged",
207
+ "The commit has been successfully created"
208
+ ]
209
+
210
+ is_commit_prompt = any(pattern in first_msg_lower for pattern in commit_patterns)
211
+ preview = first_msg_text.strip().replace("\n", " ")[:50]
212
+
213
+ # Case 3: Automated commit prompt with more leeway for messages (retries/errors)
214
+ if is_commit_prompt and len(messages) <= 15:
215
+ return True, preview
216
+
217
+ return False, preview
218
+ except Exception as e:
219
+ logger.error(f"Error checking {file_path}: {e}")
220
+ return False, ""
221
+
222
+ def get_summary(self, content):
223
+ # Access self.client to trigger secret fetch even in dry-run
224
+ client = self.client
225
+
226
+ if self.dry_run:
227
+ return "Dry Run Title", "Dry run summary (Secret Fetch Verified). This is a mock 1-3 paragraph summary to simulate the new format."
228
+
229
+ if not client:
230
+ return "Summary unavailable", "Summary unavailable (No API Key)"
231
+
232
+ # Initial configured delay
233
+ time.sleep(self.request_delay)
234
+
235
+ retries = 0
236
+ max_retries = 5
237
+ base_delay = 5
238
+
239
+ while retries < max_retries:
240
+ try:
241
+ # Stricter prompt for structured title and 1-3 paragraph summary
242
+ prompt = (
243
+ "Summarize the following AI chat conversation. Provide your response in exactly two parts:\n"
244
+ "TITLE: A single-line, direct, technical title for the conversation.\n"
245
+ "SUMMARY: A 1 to 3 paragraph detailed summary focusing on technical objectives, "
246
+ "specific problems addressed, and the final resolution.\n\n"
247
+ "CRITICAL: Do not include any preamble like 'This conversation is about...', 'In this chat...', "
248
+ "or 'The following discussed...'. Start both the title and the summary directly with the core content.\n\n"
249
+ f"Conversation content:\n{content[:15000]}"
250
+ )
251
+
252
+ response = client.models.generate_content(
253
+ model=self.config['summarization']['model'],
254
+ contents=prompt
255
+ )
256
+ text = response.text.strip()
257
+
258
+ title = "Untitled Conversation"
259
+ summary = text
260
+
261
+ # Try to parse title and summary from the response
262
+ if "TITLE:" in text and "SUMMARY:" in text:
263
+ parts = text.split("SUMMARY:", 1)
264
+ title_part = parts[0].replace("TITLE:", "").strip()
265
+ summary_part = parts[1].strip()
266
+
267
+ if title_part:
268
+ title = title_part
269
+ if summary_part:
270
+ summary = summary_part
271
+
272
+ # Cleanup generic prefixes if they still occur
273
+ prefixes_to_strip = [
274
+ "This conversation is about", "This chat is about",
275
+ "In this conversation", "In this chat",
276
+ "The conversation discussed", "The following discussed",
277
+ "The conversation begins with", "The chat starts with",
278
+ "The conversation is an", "The chat is an"
279
+ ]
280
+ for prefix in prefixes_to_strip:
281
+ if title.lower().startswith(prefix.lower()):
282
+ title = title[len(prefix):].strip().lstrip(':').strip()
283
+ if summary.lower().startswith(prefix.lower()):
284
+ summary = summary[len(prefix):].strip().lstrip(':').strip()
285
+
286
+ return title[:200], summary
287
+ except Exception as e:
288
+ is_rate_limit = False
289
+ if hasattr(e, 'code') and e.code == 429:
290
+ is_rate_limit = True
291
+ elif "429" in str(e) or "RESOURCE_EXHAUSTED" in str(e):
292
+ is_rate_limit = True
293
+
294
+ if is_rate_limit:
295
+ delay = (base_delay * (2 ** retries)) + random.uniform(0, 1)
296
+ logger.warning(f"Rate limit hit. Retrying in {delay:.2f}s... (Attempt {retries + 1}/{max_retries})")
297
+ time.sleep(delay)
298
+ retries += 1
299
+ else:
300
+ logger.error(f"Error getting summary: {e}")
301
+ raise e
302
+
303
+ raise Exception("Max retries exceeded for rate limiting.")
304
+
305
+ def process_gemini_cleanup(self):
306
+ logger.info("Cleaning up commit-only Gemini chats...")
307
+ files = self.discover_files('gemini')
308
+ logger.info(f"Scanning {len(files)} Gemini chats for cleanup.")
309
+
310
+ deleted_count = 0
311
+ for f in tqdm(files, desc="Cleaning Gemini chats", unit="file"):
312
+ is_commit, preview = self.is_gemini_commit_only(f)
313
+ if is_commit:
314
+ file_size = Path(f).stat().st_size
315
+ if self.dry_run:
316
+ tqdm.write(f"[DRY-RUN] Identifying commit-only chat: {f} | Prompt: {preview}...")
317
+
318
+ logger.info(f"Identified commit-only Gemini chat: {f} ({self.format_size(file_size)})")
319
+ if not self.dry_run:
320
+ try:
321
+ os.remove(f)
322
+ logger.info(f"Deleted {f}")
323
+ self.total_bytes_saved += file_size
324
+ deleted_count += 1
325
+ except OSError as e:
326
+ logger.error(f"Error deleting {f}: {e}")
327
+ else:
328
+ self.total_bytes_saved += file_size
329
+ deleted_count += 1
330
+
331
+ logger.info(f"Cleanup complete. Identified {deleted_count} files for deletion.")
332
+
333
+ def get_workspace_info(self, file_path, category):
334
+ p = Path(file_path)
335
+ if category == 'gemini':
336
+ # Use hash from path
337
+ h = p.parent.parent.name
338
+ resolved = self.workspace_index.get(h)
339
+ if resolved:
340
+ return resolved, Path(resolved).name
341
+ return str(p.parent.parent), h
342
+ elif category == 'roo':
343
+ # Extract from JSON if possible
344
+ try:
345
+ with open(file_path, 'r') as f:
346
+ content = f.read(10000)
347
+ match = re.search(r'Current Workspace Directory \((.*?)\)', content)
348
+ if match:
349
+ ws_path = match.group(1)
350
+ return ws_path, Path(ws_path).name
351
+ except Exception:
352
+ pass
353
+ return str(p.parent.parent.parent), p.parent.parent.parent.name
354
+ elif category == 'aider':
355
+ return str(p.parent), p.parent.name
356
+ elif category == 'claude':
357
+ ws = get_chat_workspace(self._as_discovered_chat(file_path, category))
358
+ if ws:
359
+ return str(ws), ws.name
360
+ return "Unknown", "Unknown"
361
+ return "Unknown", "Unknown"
362
+
363
+ def _as_discovered_chat(self, file_path, category):
364
+ from multi_agent_registry import DiscoveredChat
365
+ parser_type = "jsonl" if category == "claude" else "markdown"
366
+ return DiscoveredChat(agent_id=category, path=Path(file_path), parser_type=parser_type)
367
+
368
+ def process_category_archival(self, category):
369
+ if self.limit and self.processed_count >= self.limit:
370
+ return
371
+
372
+ logger.info(f"Processing {category} chats for archival...")
373
+ files = self.discover_files(category)
374
+
375
+ # Group files by workspace
376
+ workspaces = {}
377
+ for f in files:
378
+ ws_path, ws_id = self.get_workspace_info(f, category)
379
+ if ws_path not in workspaces:
380
+ workspaces[ws_path] = []
381
+ workspaces[ws_path].append(f)
382
+
383
+ cutoff_date = datetime.now(timezone.utc) - timedelta(days=self.retention_days)
384
+ keep_count = self.keep_count
385
+
386
+ # Collect all files to process to show overall progress
387
+ files_to_archive = []
388
+ for ws_path, ws_files in workspaces.items():
389
+ # Sort by true last-active time where available (e.g. Claude Code's
390
+ # per-message timestamps), falling back to file mtime otherwise --
391
+ # mtime alone can be misleading for metadata-only stub rewrites.
392
+ ws_files_with_mtime = []
393
+ for f in ws_files:
394
+ try:
395
+ last_active = None
396
+ if category == "claude":
397
+ last_active = get_chat_last_active(self._as_discovered_chat(f, category))
398
+ mtime = last_active or datetime.fromtimestamp(Path(f).stat().st_mtime, tz=timezone.utc)
399
+ ws_files_with_mtime.append((f, mtime, ws_path))
400
+ except OSError:
401
+ continue
402
+
403
+ ws_files_with_mtime.sort(key=lambda x: x[1], reverse=True)
404
+
405
+ # Process files, skipping the most recent N
406
+ for i, (f, mtime, ws_p) in enumerate(ws_files_with_mtime):
407
+ if i < keep_count:
408
+ continue
409
+
410
+ if mtime < cutoff_date:
411
+ files_to_archive.append((f, ws_p))
412
+
413
+ if not files_to_archive:
414
+ logger.info(f"No {category} files found for archival.")
415
+ return
416
+
417
+ desc = f"Archiving {category.capitalize()}"
418
+ for f, ws_p in tqdm(files_to_archive, desc=desc, unit="file"):
419
+ if self.limit and self.processed_count >= self.limit:
420
+ break
421
+ self.archive_file(f, category, ws_p)
422
+
423
+ def process_gemini_archive(self):
424
+ self.process_category_archival('gemini')
425
+
426
+ def process_roo(self):
427
+ self.process_category_archival('roo')
428
+
429
+ def process_aider(self):
430
+ self.process_category_archival('aider')
431
+
432
+ def process_claude(self):
433
+ self.process_category_archival('claude')
434
+
435
+ def to_markdown(self, category, content, workspace_path=None):
436
+ md = [f"# Archive: {category.capitalize()} Chat", "---"]
437
+ if workspace_path:
438
+ md.append(f"**Workspace:** `{workspace_path}`\n")
439
+
440
+ if category == 'gemini':
441
+ try:
442
+ data = content if isinstance(content, dict) else json.loads(content)
443
+ for msg in data.get("messages", []):
444
+ role = msg.get("role", msg.get("type", "unknown")).upper()
445
+ ts = msg.get("timestamp", "")
446
+ text = msg.get("content", "")
447
+ md.append(f"### {role} ({ts})")
448
+ md.append(f"{text}\n")
449
+ except Exception:
450
+ md.append(str(content))
451
+
452
+ elif category == 'roo':
453
+ try:
454
+ data = content if isinstance(content, list) else json.loads(content)
455
+ for msg in data:
456
+ mtype = msg.get("type", "")
457
+ say = msg.get("say", "")
458
+ text = msg.get("text", "")
459
+
460
+ if mtype == "say" and say in ["text", "user_feedback"]:
461
+ role = "USER" if say == "user_feedback" else "ROO"
462
+ md.append(f"### {role}")
463
+ md.append(f"{text}\n")
464
+ elif mtype == "ask" and msg.get("ask") == "tool":
465
+ md.append("### ROO (Tool Use)")
466
+ md.append(f"{text}\n")
467
+ elif mtype == "ask" and msg.get("ask") == "command":
468
+ md.append("### ROO (Command)")
469
+ md.append(f"```bash\n{text}\n```\n")
470
+ except Exception:
471
+ md.append("Error parsing Roo JSON")
472
+
473
+ elif category == 'aider':
474
+ md.append(str(content))
475
+
476
+ elif category == 'claude':
477
+ try:
478
+ data = content if isinstance(content, list) else json.loads(content)
479
+ for entry in data:
480
+ etype = entry.get("type")
481
+ if etype not in ("user", "assistant"):
482
+ continue
483
+ message = entry.get("message", {})
484
+ role = (message.get("role") or etype).upper()
485
+ ts = entry.get("timestamp", "")
486
+ raw = message.get("content", "")
487
+
488
+ if isinstance(raw, list):
489
+ parts = []
490
+ for block in raw:
491
+ if not isinstance(block, dict):
492
+ continue
493
+ btype = block.get("type")
494
+ if btype == "text":
495
+ parts.append(block.get("text", ""))
496
+ elif btype == "tool_use":
497
+ parts.append(f"[Tool call: {block.get('name', 'unknown')}]")
498
+ elif btype == "tool_result":
499
+ result_content = block.get("content", "")
500
+ if isinstance(result_content, list):
501
+ result_content = " ".join(
502
+ b.get("text", "") for b in result_content if isinstance(b, dict)
503
+ )
504
+ parts.append(f"[Tool result: {str(result_content)[:300]}]")
505
+ text = "\n".join(p for p in parts if p)
506
+ else:
507
+ text = str(raw)
508
+
509
+ if not text.strip():
510
+ continue
511
+ md.append(f"### {role} ({ts})")
512
+ md.append(f"{text}\n")
513
+ except Exception:
514
+ md.append("Error parsing Claude Code JSONL")
515
+
516
+ return "\n".join(md)
517
+
518
+ def slugify(self, text):
519
+ text = text.lower()
520
+ text = re.sub(r'[^\w\s-]', '', text)
521
+ text = re.sub(r'[-\s]+', '-', text)
522
+ return text.strip('-')[:60]
523
+
524
+ def archive_file(self, file_path, category, workspace_path=None):
525
+ try:
526
+ p = Path(file_path)
527
+ file_size = p.stat().st_size
528
+ with open(file_path, "r") as f:
529
+ raw_content = f.read()
530
+
531
+ parsed_content = None
532
+ if file_path.endswith(".json"):
533
+ try:
534
+ parsed_content = json.loads(raw_content)
535
+ except json.JSONDecodeError:
536
+ parsed_content = raw_content
537
+ elif file_path.endswith(".jsonl"):
538
+ parsed_content = []
539
+ for line in raw_content.splitlines():
540
+ line = line.strip()
541
+ if not line:
542
+ continue
543
+ try:
544
+ parsed_content.append(json.loads(line))
545
+ except json.JSONDecodeError:
546
+ continue
547
+ else:
548
+ parsed_content = raw_content
549
+
550
+ # Determine Date
551
+ date_str = datetime.now().strftime("%Y-%m-%d")
552
+ match = re.search(r'(\d{4}-\d{2}-\d{2})', p.name)
553
+ if match:
554
+ date_str = match.group(1)
555
+ else:
556
+ # Fallback to file modification time
557
+ mtime = datetime.fromtimestamp(p.stat().st_mtime)
558
+ date_str = mtime.strftime("%Y-%m-%d")
559
+
560
+ markdown_content = self.to_markdown(category, parsed_content, workspace_path)
561
+ title, summary = self.get_summary(markdown_content)
562
+
563
+ # Generate slug from the title
564
+ slug = self.slugify(title)
565
+ if not slug:
566
+ slug = "conversation"
567
+
568
+ base_key_name = f"{date_str}-{slug}"
569
+
570
+ # Prepend title and summary to markdown for better readability in the archive
571
+ full_markdown = f"# Title: {title}\n\n# Summary\n{summary}\n\n{markdown_content}"
572
+
573
+ # Use both title and summary in the summary.txt file
574
+ full_summary_text = f"Title: {title}\n\nSummary:\n{summary}"
575
+
576
+ # Extract Year and Month for sharding
577
+ # date_str is expected to be YYYY-MM-DD
578
+ year = date_str[:4]
579
+ month = date_str[5:7]
580
+
581
+ s3_prefix = f"{self.config['s3']['prefix']}{category}/{year}/{month}/{base_key_name}"
582
+
583
+ uploads = [
584
+ {"key": f"{s3_prefix}.md", "body": full_markdown, "type": "text/markdown"},
585
+ {"key": f"{s3_prefix}.summary.txt", "body": full_summary_text, "type": "text/plain"},
586
+ {"key": f"{s3_prefix}.json", "body": json.dumps(parsed_content, indent=2), "type": "application/json"}
587
+ ]
588
+
589
+ if self.dry_run:
590
+ tqdm.write(f"[DRY-RUN] Would upload to s3://{self.config['s3']['bucket']}/{s3_prefix}.md")
591
+ self.total_bytes_saved += file_size
592
+ self.processed_count += 1
593
+ return
594
+
595
+ logger.info(f"Archiving {file_path} to S3 (3 files)...")
596
+
597
+ for upload in uploads:
598
+ self.s3_client.put_object(
599
+ Bucket=self.config['s3']['bucket'],
600
+ Key=upload['key'],
601
+ Body=upload['body'],
602
+ ContentType=upload['type']
603
+ )
604
+
605
+ os.remove(file_path)
606
+ self.total_bytes_saved += file_size
607
+ self.processed_count += 1
608
+ logger.info(f"Successfully archived (MD, Summary, JSON) and removed local file: {file_path}")
609
+
610
+ except Exception as e:
611
+ logger.error(f"Failed to archive {file_path}: {e}")
612
+
613
+ def run(self, cleanup_only=False):
614
+ self.process_gemini_cleanup()
615
+ mode_label = "[DRY-RUN] Estimated" if self.dry_run else "Total"
616
+
617
+ if cleanup_only:
618
+ self.console.print(
619
+ f"\n[bold]{mode_label} disk space reclaimed from cleanup:[/bold] "
620
+ f"[green]{self.format_size(self.total_bytes_saved)}[/green]"
621
+ )
622
+ return
623
+
624
+ self.process_gemini_archive()
625
+ self.process_roo()
626
+ self.process_aider()
627
+ self.process_claude()
628
+
629
+ s3_location = f"s3://{self.config['s3']['bucket']}/{self.config['s3']['prefix']}"
630
+
631
+ self.console.print(
632
+ f"\n[bold]{mode_label} disk space reclaimed:[/bold] "
633
+ f"[green]{self.format_size(self.total_bytes_saved)}[/green]"
634
+ )
635
+ self.console.print(f"[bold]Archive Destination:[/bold] [cyan]{s3_location}[/cyan]")
chatarch/cli.py ADDED
@@ -0,0 +1,73 @@
1
+ import argparse
2
+
3
+ import verkit # type: ignore[import-untyped]
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from chatarch.archiver import ChatArchiver
8
+ from chatarch.theme import DEFAULT as theme
9
+
10
+ COMMANDS = [
11
+ ("dry-run", "Preview what would be cleaned up and archived, without changing anything."),
12
+ ("archive", "Archive eligible chats to S3 and remove the local copies."),
13
+ ("cleanup", "Delete commit-only chats locally, without uploading anything."),
14
+ ]
15
+
16
+
17
+ def show_menu(console: Console):
18
+ table = Table(
19
+ title="[bold blue]multi-agent-archiver (chatarch / cax / maa)[/bold blue]",
20
+ box=theme.table_box,
21
+ header_style=theme.header_style,
22
+ padding=theme.table_padding,
23
+ )
24
+ table.add_column("Command", style="bold green")
25
+ table.add_column("Description")
26
+ for name, desc in COMMANDS:
27
+ table.add_row(name, desc)
28
+ console.print(table)
29
+ console.print(
30
+ "\nRun [bold]cax <command> --help[/bold] for options, or [bold]cax -V[/bold] for version info."
31
+ )
32
+
33
+
34
+ def main():
35
+ parser = argparse.ArgumentParser(description="Archive AI chats to S3.")
36
+ parser.add_argument("-V", "--version", action="store_true", help="Show version info and exit.")
37
+ parser.add_argument("--config", default="config.yaml", help="Path to config.yaml.")
38
+ subparsers = parser.add_subparsers(dest="command")
39
+
40
+ dry_run_p = subparsers.add_parser("dry-run", help="Preview without changing anything.")
41
+ dry_run_p.add_argument("--limit", type=int, help="Limit the number of files processed.")
42
+ dry_run_p.add_argument("--days", type=int, help="Override the retention period (days). Use 0 for no limit.")
43
+
44
+ archive_p = subparsers.add_parser("archive", help="Archive chats to S3 and remove local files.")
45
+ archive_p.add_argument("--limit", type=int, help="Limit the number of files archived.")
46
+ archive_p.add_argument("--days", type=int, help="Override the retention period (days). Use 0 for no limit.")
47
+
48
+ cleanup_p = subparsers.add_parser("cleanup", help="Delete commit-only chats locally (no S3 upload).")
49
+ cleanup_p.add_argument("--limit", type=int, help="Limit the number of files processed.")
50
+ cleanup_p.add_argument("--dry-run", action="store_true", help="Preview cleanup without deleting anything.")
51
+
52
+ args = parser.parse_args()
53
+ console = Console()
54
+
55
+ if args.version:
56
+ verkit.display_version_info(console, "multi-agent-archiver", upgrade_cmd="uv tool upgrade multi-agent-archiver")
57
+ return
58
+
59
+ if args.command is None:
60
+ show_menu(console)
61
+ return
62
+
63
+ archiver = ChatArchiver(
64
+ config_path=args.config,
65
+ dry_run=args.command == "dry-run" or getattr(args, "dry_run", False),
66
+ limit=getattr(args, "limit", None),
67
+ days_override=getattr(args, "days", None),
68
+ )
69
+ archiver.run(cleanup_only=args.command == "cleanup")
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
chatarch/theme.py ADDED
@@ -0,0 +1,17 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+ from rich import box as rich_box
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Theme:
9
+ """Visual theme for terminal output (tables, panels)."""
10
+
11
+ table_box: Optional[rich_box.Box] = None
12
+ panel_box: rich_box.Box = rich_box.MINIMAL
13
+ table_padding: tuple[int, int, int, int] = (0, 2, 0, 0)
14
+ header_style: str = "on grey23"
15
+
16
+
17
+ DEFAULT = Theme()
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.5
2
+ Name: multi-agent-archiver
3
+ Version: 0.1.5
4
+ Summary: Archive local AI chat histories to S3, freeing up disk space while keeping conversations searchable
5
+ Author-email: Mark Stouffer <1802850+InTEGr8or@users.noreply.github.com>
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: boto3>=1.42.17
9
+ Requires-Dist: google-genai>=1.56.0
10
+ Requires-Dist: multi-agent-registry>=0.2.0
11
+ Requires-Dist: pyyaml>=6.0.3
12
+ Requires-Dist: requests>=2.32.5
13
+ Requires-Dist: rich>=13.9.4
14
+ Requires-Dist: tqdm>=4.67.1
15
+ Requires-Dist: verkit>=0.1.4
16
+ Description-Content-Type: text/markdown
17
+
18
+ # ChatArch: AI Conversation Archiver
19
+
20
+ ChatArch is a utility designed to migrate local AI chat histories to AWS S3, freeing up local disk space while preserving conversations in a human-readable, summarized, and searchable format.
21
+
22
+ ## Features
23
+
24
+ - **Multi-Platform Support:** Automatically discovers chats from Gemini CLI, Roo Code (Cline), and Aider.
25
+ - **Smart Cleanup:** Identifies and deletes "commit-only" chats (short messages used solely for git commits) before archiving.
26
+ - **Markdown Conversion:** Converts complex JSON chat schemas into clean, readable Markdown files within the archive.
27
+ - **AI-Powered Summarization:** Uses Gemini 2.5 Flash to generate 1-2 sentence summaries for every archived conversation.
28
+ - **Safe Archival:**
29
+ - **Retention Policy:** Only archives chats older than 20 days.
30
+ - **Verification:** Local files are only deleted after a successful S3 upload.
31
+ - **Rate Limit Handling:** Includes configurable delays and exponential backoff for API calls.
32
+ - **Space Reporting:** Reports the total disk space reclaimed at the end of each run.
33
+
34
+ ## Prerequisites
35
+
36
+ - **Python & uv:** Managed via `uv`.
37
+ - **AWS CLI:** Must be configured with credentials that have `s3:PutObject` permissions.
38
+ - **1Password CLI (`op`):** Used to securely retrieve the Gemini API key.
39
+ - **Google Gemini API Key:** Stored in 1Password at `op://Private/GEMINI_API_KEY/credential`.
40
+
41
+ ## Usage
42
+
43
+ Commands are managed via the `Makefile` for convenience.
44
+
45
+ ### 1. Dry Run (Recommended)
46
+ Verify which files will be deleted or archived without actually performing any actions:
47
+ ```bash
48
+ make dry-run
49
+ ```
50
+
51
+ ### 2. Actual Archive
52
+ Perform the cleanup and migration:
53
+ ```bash
54
+ make archive
55
+ ```
56
+
57
+ ### 3. Using Limits
58
+ To test the full process on a small sample (e.g., 5 files):
59
+ ```bash
60
+ make archive ARGS="--limit 5"
61
+ ```
62
+
63
+ ### 4. Maintenance
64
+ Run linting and auto-fixes:
65
+ ```bash
66
+ make lint
67
+ ```
68
+
69
+ ## Configuration
70
+
71
+ Settings like S3 bucket names, local search paths, and API delays are managed in `config.yaml`.
72
+
73
+ ## Logging
74
+
75
+ All detailed operations and API responses are logged to `.logs/archiver.log`, rotated daily and kept for 180 days. Progress is displayed in the terminal via a status bar.
76
+
77
+ ## Development
78
+
79
+ The package publishes three equivalent commands: `chatarch`, and its short aliases `cax` and `maa` (`pip install multi-agent-archiver` / `uv tool install multi-agent-archiver`).
80
+
81
+ Inside this repo, `bin/` (added to `PATH` via `mise.toml`'s `[env]` when you `cd` in, since this repo's dev environment is managed with [mise](https://mise.jdx.dev/)) shadows the globally installed commands with a shim that runs this repo's live source via `uv run`. Outside the repo, `chatarch`/`cax`/`maa` resolve back to whatever version is installed globally. Run `mise trust` once after cloning to enable it.
@@ -0,0 +1,8 @@
1
+ chatarch/__init__.py,sha256=PjOWjC2SXHJDzaQz9iXWGB_s9k3cKr99dT_oIwAzgF0,127
2
+ chatarch/archiver.py,sha256=Lbf3clhxgzk8G7Kz_oNCQsKPTfQQPq98VZBZGXj13q4,26365
3
+ chatarch/cli.py,sha256=suGGLYJyD68_f-V_u2rC6kngnlud4HHSBUbV1ltjCYA,2918
4
+ chatarch/theme.py,sha256=rII66216KRYvMmjE4sdLs4CP0mR4D71Ft-7I9mBHAoI,405
5
+ multi_agent_archiver-0.1.5.dist-info/METADATA,sha256=azgINKrqvtWjDA4UWIEB91Yaurv8YMxQhTEKtxe1ajg,3356
6
+ multi_agent_archiver-0.1.5.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ multi_agent_archiver-0.1.5.dist-info/entry_points.txt,sha256=wK6vKE_1NXfv7pRcEvE5Zvaw0YwZRAMv1lYGVAl5Zgk,95
8
+ multi_agent_archiver-0.1.5.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ cax = chatarch.cli:main
3
+ chatarch = chatarch.cli:main
4
+ maa = chatarch.cli:main