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 +5 -0
- chatarch/archiver.py +635 -0
- chatarch/cli.py +73 -0
- chatarch/theme.py +17 -0
- multi_agent_archiver-0.1.5.dist-info/METADATA +81 -0
- multi_agent_archiver-0.1.5.dist-info/RECORD +8 -0
- multi_agent_archiver-0.1.5.dist-info/WHEEL +4 -0
- multi_agent_archiver-0.1.5.dist-info/entry_points.txt +4 -0
chatarch/__init__.py
ADDED
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,,
|