main
py 887 lines 33.7 KB
Raw
1 from __future__ import annotations
2
3 import hashlib
4 import io
5 import json
6 import re
7 import sqlite3
8 import stat
9 import tarfile
10 import tempfile
11 import uuid
12 import zipfile
13 from dataclasses import dataclass, field
14 from datetime import datetime, timezone
15 from pathlib import Path, PurePosixPath
16 from typing import Any, Iterable
17
18
19 MAX_FILES = 5_000
20 MAX_FILE_BYTES = 100 * 1024 * 1024
21 MAX_TOTAL_BYTES = 256 * 1024 * 1024
22 TEXT_SUFFIXES = {
23 ".css",
24 ".html",
25 ".js",
26 ".json",
27 ".jsonl",
28 ".md",
29 ".py",
30 ".sh",
31 ".toml",
32 ".ts",
33 ".txt",
34 ".yaml",
35 ".yml",
36 }
37 MEMORY_NAMES = {
38 "memory.md",
39 }
40 INSTRUCTION_NAMES = {
41 "agents.md",
42 "claude.md",
43 "identity.md",
44 "soul.md",
45 "tools.md",
46 "user.md",
47 }
48 PROJECT_PATH_KEYS = ("cwd", "directory", "workspace")
49 SENSITIVE_NAMES = {
50 ".credentials.json",
51 ".env",
52 "auth.json",
53 "credentials.json",
54 "openclaw.json",
55 "settings.json",
56 }
57 SECRET_RE = re.compile(
58 r"(?i)(\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|passwd|secret)\b[\"']?\s*[:=]\s*[\"']?)([^\s\"']+)"
59 )
60 BEARER_RE = re.compile(r"(?i)(Authorization\s*:\s*Bearer\s+)([^\s\"']+)")
61 PRIVATE_KEY_RE = re.compile(
62 r"-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----"
63 )
64 DATA_URL_RE = re.compile(r"data:[^;,\s]+;base64,[A-Za-z0-9+/=_-]+")
65
66
67 @dataclass(slots=True)
68 class Upload:
69 name: str
70 data: bytes
71
72
73 @dataclass(slots=True)
74 class Event:
75 kind: str
76 text: str = ""
77 timestamp: float = 0.0
78 thoughts: list[str] = field(default_factory=list)
79 tool_name: str = ""
80 tool_args: dict[str, Any] = field(default_factory=dict)
81 tool_result: str = ""
82
83
84 @dataclass(slots=True)
85 class Conversation:
86 source_id: str
87 title: str
88 events: list[Event]
89 metadata: dict[str, Any] = field(default_factory=dict)
90
91
92 @dataclass(slots=True)
93 class Asset:
94 path: str
95 data: bytes
96
97
98 @dataclass(slots=True)
99 class Project:
100 source_id: str
101 title: str
102 path: str
103 conversation_ids: list[str] = field(default_factory=list)
104
105
106 @dataclass(slots=True)
107 class Bundle:
108 source: str
109 conversations: list[Conversation] = field(default_factory=list)
110 projects: list[Project] = field(default_factory=list)
111 memories: list[Asset] = field(default_factory=list)
112 instructions: list[Asset] = field(default_factory=list)
113 skills: dict[str, list[Asset]] = field(default_factory=dict)
114 excluded: list[str] = field(default_factory=list)
115 warnings: list[str] = field(default_factory=list)
116 redactions: int = 0
117
118 @property
119 def knowledge(self) -> list[Asset]:
120 return [*self.memories, *self.instructions]
121
122 def summary(self) -> dict[str, int]:
123 return {
124 "chats": len(self.conversations),
125 "projects": len(self.projects),
126 "messages": sum(len(item.events) for item in self.conversations),
127 "memories": len(self.memories),
128 "instructions": len(self.instructions),
129 "knowledge": len(self.memories) + len(self.instructions),
130 "skills": len(self.skills),
131 "excluded": len(self.excluded),
132 "redactions": self.redactions,
133 }
134
135
136 def _safe_name(name: str) -> str:
137 value = str(PurePosixPath(name.replace("\\", "/")))
138 path = PurePosixPath(value)
139 if path.is_absolute() or ".." in path.parts:
140 raise ValueError(f"Unsafe archive path: {name!r}")
141 return value.lstrip("./")
142
143
144 def expand_uploads(uploads: Iterable[Upload]) -> list[Upload]:
145 result: list[Upload] = []
146 total = 0
147
148 def add(name: str, data: bytes) -> None:
149 nonlocal total
150 clean = _safe_name(name)
151 if not clean or clean.endswith("/"):
152 return
153 if len(data) > MAX_FILE_BYTES:
154 raise ValueError(f"File exceeds the 100 MiB limit: {clean}")
155 total += len(data)
156 if total > MAX_TOTAL_BYTES:
157 raise ValueError("Expanded upload exceeds the 256 MiB limit")
158 result.append(Upload(clean, data))
159 if len(result) > MAX_FILES:
160 raise ValueError(f"Upload contains more than {MAX_FILES} files")
161
162 for upload in uploads:
163 lower = upload.name.lower()
164 if lower.endswith(".zip"):
165 with zipfile.ZipFile(io.BytesIO(upload.data)) as archive:
166 for member in archive.infolist():
167 mode = member.external_attr >> 16
168 if stat.S_ISLNK(mode):
169 raise ValueError(f"Archive symlinks are not accepted: {member.filename}")
170 if member.is_dir():
171 continue
172 add(member.filename, archive.read(member))
173 elif lower.endswith((".tar", ".tar.gz", ".tgz")):
174 with tarfile.open(fileobj=io.BytesIO(upload.data), mode="r:*") as archive:
175 for member in archive.getmembers():
176 if member.issym() or member.islnk() or member.isdev():
177 raise ValueError(f"Archive links and devices are not accepted: {member.name}")
178 if not member.isfile():
179 continue
180 source = archive.extractfile(member)
181 if source is not None:
182 add(member.name, source.read(MAX_FILE_BYTES + 1))
183 else:
184 add(upload.name, upload.data)
185 return result
186
187
188 def _text(asset: Upload | Asset) -> str:
189 return asset.data.decode("utf-8-sig", "replace")
190
191
192 def _redact(value: str) -> tuple[str, int]:
193 count = 0
194
195 def secret(match: re.Match[str]) -> str:
196 nonlocal count
197 if match.group(2).startswith("$"):
198 return match.group(0)
199 count += 1
200 return f"{match.group(1)}[REDACTED]"
201
202 value = SECRET_RE.sub(secret, value)
203 value, bearer_count = BEARER_RE.subn(r"\1[REDACTED]", value)
204 value, key_count = PRIVATE_KEY_RE.subn("[PRIVATE KEY REDACTED]", value)
205 value, data_count = DATA_URL_RE.subn("[EMBEDDED DATA OMITTED]", value)
206 return value, count + bearer_count + key_count + data_count
207
208
209 def _timestamp(value: Any, fallback: float = 0.0) -> float:
210 if isinstance(value, (int, float)):
211 number = float(value)
212 return number / 1000 if number > 10_000_000_000 else number
213 if isinstance(value, str) and value:
214 try:
215 return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
216 except ValueError:
217 pass
218 return fallback or datetime.now(timezone.utc).timestamp()
219
220
221 def _one_line(value: str, limit: int = 92) -> str:
222 text = re.sub(r"\s+", " ", value).strip()
223 return text if len(text) <= limit else text[: limit - 1].rstrip() + ""
224
225
226 def _content_text(content: Any) -> str:
227 if isinstance(content, str):
228 return content
229 if isinstance(content, dict):
230 for key in ("text", "content", "user_message"):
231 if isinstance(content.get(key), str):
232 return content[key]
233 return ""
234 if isinstance(content, list):
235 parts: list[str] = []
236 for item in content:
237 if isinstance(item, str):
238 parts.append(item)
239 elif isinstance(item, dict) and item.get("type") not in {"thinking", "reasoning"}:
240 value = item.get("text") or item.get("content")
241 if isinstance(value, str):
242 parts.append(value)
243 return "\n".join(part for part in parts if part)
244 return ""
245
246
247 def _tool_blocks(content: Any) -> list[dict[str, Any]]:
248 if not isinstance(content, list):
249 return []
250 return [
251 item
252 for item in content
253 if isinstance(item, dict) and item.get("type") in {"tool_use", "toolCall", "tool-call"}
254 ]
255
256
257 def _events_from_messages(messages: Iterable[dict[str, Any]]) -> list[Event]:
258 events: list[Event] = []
259 pending_tools: dict[str, Event] = {}
260 for index, message in enumerate(messages):
261 role = str(message.get("role") or message.get("type") or "").lower()
262 content = message.get("content")
263 when = _timestamp(message.get("timestamp") or message.get("created_at"), index + 1)
264 if role in {"user", "human"}:
265 text = _content_text(content)
266 if text:
267 events.append(Event("user", text=text, timestamp=when))
268 if isinstance(content, list):
269 for block in content:
270 if not isinstance(block, dict) or block.get("type") != "tool_result":
271 continue
272 tool_id = str(block.get("tool_use_id") or "")
273 result = _content_text(block.get("content"))
274 if tool_id in pending_tools:
275 pending_tools[tool_id].tool_result = result
276 continue
277 if role in {"assistant", "agent", "ai"}:
278 text = _content_text(content)
279 if text:
280 events.append(Event("assistant", text=text, timestamp=when))
281 for block in _tool_blocks(content):
282 tool_id = str(block.get("id") or block.get("tool_call_id") or "")
283 event = Event(
284 "tool",
285 timestamp=when,
286 tool_name=str(block.get("name") or "tool"),
287 tool_args=block.get("input") if isinstance(block.get("input"), dict) else {},
288 )
289 events.append(event)
290 if tool_id:
291 pending_tools[tool_id] = event
292 calls = message.get("tool_calls")
293 if isinstance(calls, str):
294 try:
295 calls = json.loads(calls)
296 except json.JSONDecodeError:
297 calls = []
298 for call in calls or []:
299 if not isinstance(call, dict):
300 continue
301 function = call.get("function") if isinstance(call.get("function"), dict) else call
302 raw_args = function.get("arguments") or {}
303 if isinstance(raw_args, str):
304 try:
305 raw_args = json.loads(raw_args)
306 except json.JSONDecodeError:
307 raw_args = {"raw_arguments": raw_args}
308 event = Event(
309 "tool",
310 timestamp=when,
311 tool_name=str(function.get("name") or "tool"),
312 tool_args=raw_args if isinstance(raw_args, dict) else {"value": raw_args},
313 )
314 events.append(event)
315 tool_id = str(call.get("id") or "")
316 if tool_id:
317 pending_tools[tool_id] = event
318 continue
319 if role in {"tool", "tool_result"}:
320 tool_id = str(message.get("tool_call_id") or message.get("id") or "")
321 result = _content_text(content)
322 if tool_id in pending_tools:
323 pending_tools[tool_id].tool_result = result
324 else:
325 events.append(
326 Event(
327 "tool",
328 timestamp=when,
329 tool_name=str(message.get("tool_name") or message.get("name") or "tool"),
330 tool_result=result,
331 )
332 )
333 return events
334
335
336 def _conversation(source_id: str, title: str, events: list[Event], **metadata: Any) -> Conversation | None:
337 visible = [event for event in events if event.kind == "tool" or event.text.strip()]
338 if not visible:
339 return None
340 first_user = next((event.text for event in visible if event.kind == "user"), source_id)
341 return Conversation(source_id, _one_line(title or first_user or source_id), visible, metadata)
342
343
344 def _json_lines(text: str) -> list[dict[str, Any]]:
345 rows: list[dict[str, Any]] = []
346 for line_no, line in enumerate(text.splitlines(), 1):
347 if not line.strip():
348 continue
349 try:
350 value = json.loads(line)
351 except json.JSONDecodeError as exc:
352 raise ValueError(f"Invalid JSONL at line {line_no}: {exc.msg}") from exc
353 if isinstance(value, dict):
354 rows.append(value)
355 return rows
356
357
358 def _parse_codex(asset: Upload) -> list[Conversation]:
359 rows = _json_lines(_text(asset))
360 metadata: dict[str, Any] = {}
361 events: list[Event] = []
362 pending: dict[str, Event] = {}
363 thoughts: list[str] = []
364 for index, row in enumerate(rows):
365 payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
366 if row.get("type") == "session_meta" and not metadata:
367 metadata = dict(payload)
368 continue
369 if row.get("type") == "event_msg":
370 event_type = payload.get("type")
371 when = _timestamp(row.get("timestamp"), index + 1)
372 text = payload.get("message")
373 if not isinstance(text, str) or not text.strip():
374 continue
375 if event_type == "user_message":
376 events.append(Event("user", text=text, timestamp=when))
377 elif event_type == "agent_message" and payload.get("phase") == "commentary":
378 thoughts.append(text)
379 elif event_type == "agent_message" and payload.get("phase") == "final_answer":
380 events.append(Event("assistant", text=text, timestamp=when, thoughts=thoughts.copy()))
381 thoughts.clear()
382 continue
383 if row.get("type") != "response_item":
384 continue
385 item_type = payload.get("type")
386 call_id = str(payload.get("call_id") or payload.get("id") or "")
387 when = _timestamp(row.get("timestamp"), index + 1)
388 if item_type in {"function_call", "custom_tool_call"}:
389 raw = payload.get("arguments") if item_type == "function_call" else payload.get("input")
390 if isinstance(raw, str):
391 try:
392 args = json.loads(raw)
393 except json.JSONDecodeError:
394 args = {"input": raw}
395 else:
396 args = raw if isinstance(raw, dict) else {}
397 event = Event(
398 "tool",
399 timestamp=when,
400 thoughts=thoughts.copy(),
401 tool_name=str(payload.get("name") or "tool"),
402 tool_args=args,
403 )
404 thoughts.clear()
405 events.append(event)
406 if call_id:
407 pending[call_id] = event
408 elif item_type in {"function_call_output", "custom_tool_call_output"} and call_id in pending:
409 pending[call_id].tool_result = _content_text(payload.get("output")) or str(payload.get("output") or "")
410 item = _conversation(
411 str(metadata.get("id") or metadata.get("session_id") or Path(asset.name).stem),
412 "",
413 events,
414 cwd=metadata.get("cwd"),
415 originator=metadata.get("originator"),
416 )
417 return [item] if item else []
418
419
420 def _parse_claude(asset: Upload) -> list[Conversation]:
421 rows = _json_lines(_text(asset))
422 messages: list[dict[str, Any]] = []
423 session_id = Path(asset.name).stem
424 cwd = ""
425 for row in rows:
426 if row.get("isSidechain") is True or row.get("type") not in {"user", "assistant"}:
427 continue
428 message = row.get("message")
429 if not isinstance(message, dict):
430 continue
431 messages.append({**message, "timestamp": row.get("timestamp")})
432 session_id = str(row.get("sessionId") or session_id)
433 cwd = str(row.get("cwd") or cwd)
434 item = _conversation(session_id, "", _events_from_messages(messages), cwd=cwd)
435 return [item] if item else []
436
437
438 def _parse_opencode(asset: Upload) -> list[Conversation]:
439 try:
440 data = json.loads(_text(asset))
441 except json.JSONDecodeError:
442 return []
443 if not isinstance(data, dict) or not isinstance(data.get("messages"), list):
444 return []
445 info = data.get("info") if isinstance(data.get("info"), dict) else {}
446 events: list[Event] = []
447 for index, wrapper in enumerate(data["messages"]):
448 if not isinstance(wrapper, dict):
449 continue
450 message = wrapper.get("info") if isinstance(wrapper.get("info"), dict) else wrapper
451 role = str(message.get("role") or "")
452 when = _timestamp((message.get("time") or {}).get("created") if isinstance(message.get("time"), dict) else None, index + 1)
453 parts = wrapper.get("parts") if isinstance(wrapper.get("parts"), list) else []
454 text = "\n".join(
455 str(part.get("text"))
456 for part in parts
457 if isinstance(part, dict) and part.get("type") == "text" and part.get("text")
458 )
459 if text and role in {"user", "assistant"}:
460 events.append(Event(role, text=text, timestamp=when))
461 for part in parts:
462 if not isinstance(part, dict) or part.get("type") != "tool":
463 continue
464 state = part.get("state") if isinstance(part.get("state"), dict) else {}
465 args = state.get("input") if isinstance(state.get("input"), dict) else {}
466 events.append(
467 Event(
468 "tool",
469 timestamp=when,
470 tool_name=str(part.get("tool") or state.get("title") or "tool"),
471 tool_args=args,
472 tool_result=_content_text(state.get("output")),
473 )
474 )
475 item = _conversation(
476 str(info.get("id") or Path(asset.name).stem),
477 str(info.get("title") or ""),
478 events,
479 directory=info.get("directory"),
480 project_id=info.get("projectID"),
481 )
482 return [item] if item else []
483
484
485 def _parse_hermes_jsonl(asset: Upload) -> list[Conversation]:
486 result: list[Conversation] = []
487 for row in _json_lines(_text(asset)):
488 messages = row.get("messages")
489 if not isinstance(messages, list):
490 continue
491 item = _conversation(
492 str(row.get("id") or row.get("session_id") or uuid.uuid4()),
493 str(row.get("title") or ""),
494 _events_from_messages(message for message in messages if isinstance(message, dict)),
495 source=row.get("source"),
496 cwd=row.get("cwd"),
497 model=row.get("model"),
498 )
499 if item:
500 result.append(item)
501 return result
502
503
504 def _parse_openclaw_jsonl(asset: Upload) -> list[Conversation]:
505 rows = _json_lines(_text(asset))
506 session_id = Path(asset.name).stem
507 title = ""
508 workspace = ""
509 messages: list[dict[str, Any]] = []
510 for row in rows:
511 if row.get("type") in {"session", "session_meta"}:
512 session_id = str(row.get("id") or row.get("sessionId") or session_id)
513 title = str(row.get("title") or title)
514 workspace = str(row.get("cwd") or row.get("workspace") or workspace)
515 continue
516 message = row.get("message") if isinstance(row.get("message"), dict) else None
517 if row.get("type") == "message" and message:
518 messages.append({**message, "timestamp": row.get("timestamp") or message.get("timestamp")})
519 elif row.get("role"):
520 messages.append(row)
521 item = _conversation(session_id, title, _events_from_messages(messages), workspace=workspace)
522 return [item] if item else []
523
524
525 def _sqlite(asset: Upload) -> tuple[sqlite3.Connection, str]:
526 handle = tempfile.NamedTemporaryFile(suffix=".sqlite", delete=False)
527 try:
528 handle.write(asset.data)
529 handle.close()
530 connection = sqlite3.connect(f"file:{handle.name}?mode=ro&immutable=1", uri=True)
531 connection.row_factory = sqlite3.Row
532 return connection, handle.name
533 except Exception:
534 Path(handle.name).unlink(missing_ok=True)
535 raise
536
537
538 def _close_sqlite(connection: sqlite3.Connection, path: str) -> None:
539 connection.close()
540 Path(path).unlink(missing_ok=True)
541
542
543 def _tables(connection: sqlite3.Connection) -> set[str]:
544 return {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
545
546
547 def _parse_hermes_db(asset: Upload) -> list[Conversation]:
548 connection, path = _sqlite(asset)
549 try:
550 if not {"sessions", "messages"}.issubset(_tables(connection)):
551 return []
552 result: list[Conversation] = []
553 for session in connection.execute("SELECT * FROM sessions ORDER BY started_at"):
554 rows = connection.execute(
555 "SELECT * FROM messages WHERE session_id = ? ORDER BY timestamp, id", (session["id"],)
556 ).fetchall()
557 messages = [dict(row) for row in rows]
558 item = _conversation(
559 str(session["id"]),
560 str(session["title"] or "") if "title" in session.keys() else "",
561 _events_from_messages(messages),
562 source=session["source"] if "source" in session.keys() else None,
563 cwd=session["cwd"] if "cwd" in session.keys() else None,
564 model=session["model"] if "model" in session.keys() else None,
565 )
566 if item:
567 result.append(item)
568 return result
569 finally:
570 _close_sqlite(connection, path)
571
572
573 def _parse_openclaw_db(asset: Upload) -> list[Conversation]:
574 connection, path = _sqlite(asset)
575 try:
576 if not {"session_windows", "transcript_events"}.issubset(_tables(connection)):
577 return []
578 result: list[Conversation] = []
579 for window in connection.execute("SELECT * FROM session_windows ORDER BY created_at"):
580 messages: list[dict[str, Any]] = []
581 for row in connection.execute(
582 "SELECT event_json, created_at FROM transcript_events WHERE session_id = ? ORDER BY seq",
583 (window["session_id"],),
584 ):
585 try:
586 event = json.loads(row["event_json"])
587 except (TypeError, json.JSONDecodeError):
588 continue
589 message = event.get("message") if isinstance(event.get("message"), dict) else None
590 if event.get("type") == "message" and message:
591 messages.append({**message, "timestamp": event.get("timestamp") or row["created_at"]})
592 elif event.get("role"):
593 messages.append({**event, "timestamp": event.get("timestamp") or row["created_at"]})
594 item = _conversation(
595 str(window["session_id"]),
596 str(window["display_name"] or "") if "display_name" in window.keys() else "",
597 _events_from_messages(messages),
598 session_key=window["session_key"],
599 channel=window["channel"] if "channel" in window.keys() else None,
600 model=window["model"] if "model" in window.keys() else None,
601 )
602 if item:
603 result.append(item)
604 return result
605 finally:
606 _close_sqlite(connection, path)
607
608
609 def _is_sqlite(asset: Upload) -> bool:
610 return asset.data.startswith(b"SQLite format 3\x00")
611
612
613 def _discover_assets(files: list[Upload], bundle: Bundle) -> None:
614 skill_roots: dict[str, str] = {}
615 for item in files:
616 path = PurePosixPath(item.name)
617 if path.name.lower() == "skill.md":
618 key = str(path.parent)
619 skill_roots[key] = re.sub(r"[^a-z0-9_-]+", "_", path.parent.name.lower()).strip("_") or "skill"
620 for item in files:
621 path = PurePosixPath(item.name)
622 suffix = path.suffix.lower()
623 sensitive = path.name.lower() in SENSITIVE_NAMES or suffix in {".key", ".pem"}
624 for root, slug in skill_roots.items():
625 root_path = PurePosixPath(root)
626 try:
627 relative = path.relative_to(root_path)
628 except ValueError:
629 continue
630 if sensitive:
631 bundle.excluded.append(f"{item.name}: credentials are never imported")
632 else:
633 bundle.skills.setdefault(slug, []).append(Asset(str(relative), item.data))
634 break
635 else:
636 lower_parts = {part.lower() for part in path.parts}
637 if suffix == ".md" and path.name.lower() in INSTRUCTION_NAMES:
638 bundle.instructions.append(Asset(item.name, item.data))
639 elif suffix == ".md" and (
640 path.name.lower() in MEMORY_NAMES or "memory" in lower_parts or "memories" in lower_parts
641 ):
642 bundle.memories.append(Asset(item.name, item.data))
643 elif sensitive:
644 bundle.excluded.append(f"{item.name}: settings or credentials are never imported")
645
646
647 def _discover_projects(bundle: Bundle) -> None:
648 by_path: dict[str, Project] = {}
649 for conversation in bundle.conversations:
650 project_path = next(
651 (
652 str(conversation.metadata.get(key) or "").strip()
653 for key in PROJECT_PATH_KEYS
654 if str(conversation.metadata.get(key) or "").strip()
655 ),
656 "",
657 )
658 if not project_path or project_path in {".", "/", "\\"}:
659 continue
660 normalized = project_path.replace("\\", "/").rstrip("/")
661 title = PurePosixPath(normalized).name or "Imported project"
662 source_id = hashlib.sha256(f"{bundle.source}:{project_path}".encode()).hexdigest()[:12]
663 project = by_path.setdefault(source_id, Project(source_id, title, project_path))
664 project.conversation_ids.append(conversation.source_id)
665 bundle.projects = list(by_path.values())
666
667
668 def parse_bundle(source: str, uploads: Iterable[Upload]) -> Bundle:
669 if source not in {"openclaw", "hermes", "opencode", "claude", "codex"}:
670 raise ValueError("Choose one of the five supported source harnesses")
671 files = expand_uploads(uploads)
672 bundle = Bundle(source)
673 _discover_assets(files, bundle)
674 seen: set[str] = set()
675 for asset in files:
676 lower = asset.name.lower()
677 conversations: list[Conversation] = []
678 try:
679 if _is_sqlite(asset):
680 conversations = _parse_hermes_db(asset) if source == "hermes" else _parse_openclaw_db(asset) if source == "openclaw" else []
681 elif source == "codex" and lower.endswith(".jsonl"):
682 conversations = _parse_codex(asset)
683 elif source == "claude" and lower.endswith(".jsonl") and "/history.jsonl" not in f"/{lower}":
684 conversations = _parse_claude(asset)
685 elif source == "opencode" and lower.endswith(".json"):
686 conversations = _parse_opencode(asset)
687 elif source == "hermes" and lower.endswith(".jsonl"):
688 conversations = _parse_hermes_jsonl(asset)
689 elif source == "openclaw" and lower.endswith(".jsonl"):
690 conversations = _parse_openclaw_jsonl(asset)
691 except (ValueError, sqlite3.DatabaseError) as exc:
692 bundle.warnings.append(f"{asset.name}: {exc}")
693 continue
694 for conversation in conversations:
695 identity = f"{source}:{conversation.source_id}"
696 if identity not in seen:
697 seen.add(identity)
698 bundle.conversations.append(conversation)
699
700 _discover_projects(bundle)
701
702 for conversation in bundle.conversations:
703 for event in conversation.events:
704 event.text, count = _redact(event.text)
705 bundle.redactions += count
706 event.tool_result, count = _redact(event.tool_result)
707 bundle.redactions += count
708 raw_args, count = _redact(json.dumps(event.tool_args, ensure_ascii=False))
709 bundle.redactions += count
710 try:
711 event.tool_args = json.loads(raw_args)
712 except json.JSONDecodeError:
713 event.tool_args = {"redacted": raw_args}
714 for asset in bundle.knowledge:
715 text, count = _redact(_text(asset))
716 bundle.redactions += count
717 asset.data = text.encode()
718 for assets in bundle.skills.values():
719 for asset in assets:
720 if PurePosixPath(asset.path).suffix.lower() in TEXT_SUFFIXES:
721 text, count = _redact(_text(asset))
722 bundle.redactions += count
723 asset.data = text.encode()
724 if not bundle.conversations and not bundle.knowledge and not bundle.skills:
725 bundle.warnings.append("No supported chats, memories, instructions, or skills were found in this upload")
726 return bundle
727
728
729 def _iso(timestamp: float) -> str:
730 return datetime.fromtimestamp(timestamp, timezone.utc).isoformat().replace("+00:00", "Z")
731
732
733 def build_a0_chat(conversation: Conversation, source: str) -> dict[str, Any]:
734 events = conversation.events
735 if not events:
736 raise ValueError("Conversation contains no importable events")
737 messages: list[dict[str, Any]] = []
738 logs: list[dict[str, Any]] = []
739 sequence = 0
740 last_timestamp = 0.0
741
742 def message(ai: bool, content: Any, when: float, kind: str) -> dict[str, Any]:
743 nonlocal sequence
744 sequence += 1
745 item = {
746 "_cls": "Message",
747 "id": str(uuid.uuid4()),
748 "ai": ai,
749 "content": content,
750 "metadata": {"imported_from": source, "source_event": kind, "source_timestamp": _iso(when)},
751 "sequence": sequence,
752 "summary": "",
753 "tokens": 0,
754 }
755 messages.append(item)
756 return item
757
758 def log(kind: str, item: dict[str, Any], heading: str, content: str, kvps: dict[str, Any], when: float) -> None:
759 nonlocal last_timestamp
760 last_timestamp = max(when, last_timestamp + 0.000001)
761 logs.append(
762 {
763 "no": len(logs),
764 "id": item["id"],
765 "type": kind,
766 "heading": heading,
767 "content": content,
768 "kvps": kvps,
769 "timestamp": last_timestamp,
770 "agentno": 0,
771 }
772 )
773
774 for index, event in enumerate(events):
775 when = event.timestamp or index + 1.0
776 if event.kind == "user":
777 item = message(False, {"user_message": event.text}, when, "user")
778 log("user", item, "", event.text, {"attachments": []}, when)
779 elif event.kind == "assistant":
780 envelope = {
781 "thoughts": event.thoughts or [f"Imported public response from {source}."],
782 "headline": _one_line(event.text),
783 "tool_name": "response",
784 "tool_args": {"text": event.text},
785 }
786 raw = json.dumps(envelope, ensure_ascii=False)
787 item = message(True, raw, when, "assistant")
788 log("agent", item, envelope["headline"], raw, envelope, when)
789 log("response", item, "icon://chat Responding", event.text, {"finished": True}, when + 0.000001)
790 elif event.kind == "tool":
791 name = event.tool_name or "tool"
792 envelope = {
793 "thoughts": event.thoughts or [f"Imported historical {source} tool activity."],
794 "headline": f"Using imported {name} record",
795 "tool_name": name,
796 "tool_args": event.tool_args,
797 }
798 raw = json.dumps(envelope, ensure_ascii=False)
799 call = message(True, raw, when, "tool_call")
800 log("agent", call, envelope["headline"], raw, envelope, when)
801 result_text = event.tool_result or "No retained tool result was available."
802 result = message(
803 False,
804 {"tool_name": name, "tool_result": result_text, "file": ""},
805 when + 0.000001,
806 "tool_result",
807 )
808 log(
809 "tool",
810 result,
811 f"icon://construction Using tool '{name}'",
812 result_text,
813 {**event.tool_args, "_tool_name": name},
814 when + 0.000001,
815 )
816
817 history = {
818 "_cls": "History",
819 "counter": sequence,
820 "bulks": [],
821 "topics": [],
822 "current": {"_cls": "Topic", "summary": "", "messages": messages},
823 }
824 first = min((event.timestamp for event in events if event.timestamp), default=1.0)
825 last = max((event.timestamp for event in events if event.timestamp), default=first)
826 chat_id = hashlib.sha256(f"{source}:{conversation.source_id}".encode()).hexdigest()[:8]
827 return {
828 "id": chat_id,
829 "name": conversation.title,
830 "created_at": _iso(first),
831 "type": "user",
832 "last_message": _iso(last),
833 "agents": [
834 {
835 "number": 0,
836 "agent_profile": "default",
837 "data": {},
838 "history": json.dumps(history, ensure_ascii=False),
839 }
840 ],
841 "streaming_agent": 0,
842 "agent_profile": "default",
843 "log": {"guid": str(uuid.uuid4()), "logs": logs, "progress": "", "progress_no": 0},
844 "data": {
845 "_migrate_agents": {
846 "format_version": 1,
847 "source": source,
848 "source_id": conversation.source_id,
849 "source_metadata": conversation.metadata,
850 "hidden_reasoning_included": False,
851 "historical_tools_are_replayable": False,
852 }
853 },
854 "output_data": {},
855 }
856
857
858 def preview(bundle: Bundle) -> dict[str, Any]:
859 return {
860 "ok": True,
861 "source": bundle.source,
862 "summary": bundle.summary(),
863 "chats": [
864 {
865 "id": item.source_id,
866 "title": item.title,
867 "messages": len(item.events),
868 "metadata": item.metadata,
869 }
870 for item in bundle.conversations[:200]
871 ],
872 "projects": [
873 {
874 "id": item.source_id,
875 "title": item.title,
876 "path": item.path,
877 "chats": len(item.conversation_ids),
878 }
879 for item in bundle.projects[:200]
880 ],
881 "memories": [item.path for item in bundle.memories[:200]],
882 "instructions": [item.path for item in bundle.instructions[:200]],
883 "knowledge": [item.path for item in bundle.knowledge[:200]],
884 "skills": sorted(bundle.skills)[:200],
885 "excluded": bundle.excluded[:200],
886 "warnings": bundle.warnings[:200],
887 }