Add built-in Migrate Agents plugin
Bundle the five-harness migration workflow under the _migrate_agents namespace, including preview/import safeguards, tests, documentation, WebUI assets, and the corrected upload modal behavior.
Alessandro committed
Aug 11, 2026 at 19:54 UTC
7129e287c3b92013e2d82d53c91a9c07d4a82cf0
22 files changed
+2033
plugins/AGENTS.md
+1
@@ -86,6 +86,7 @@ Direct child DOX files:
86
| [_infection_check/AGENTS.md](_infection_check/AGENTS.md) | Prompt-injection safety analysis before tool execution. |
87
| [_kokoro_tts/AGENTS.md](_kokoro_tts/AGENTS.md) | Kokoro text-to-speech integration. |
88
| [_memory/AGENTS.md](_memory/AGENTS.md) | Optional persistent recall plugin, knowledge import, tools, and dashboard; do not assume it is enabled outside this plugin. |
89
+| [_migrate_agents/AGENTS.md](_migrate_agents/AGENTS.md) | Built-in migration of chats, projects, memories, instructions, and skills from supported agent harnesses. |
90
| [_model_config/AGENTS.md](_model_config/AGENTS.md) | Model selection, presets, API-key checks, and scoped overrides. |
91
| [_oauth/AGENTS.md](_oauth/AGENTS.md) | OAuth-backed model-provider connections and local proxy routes. |
92
| [_office/AGENTS.md](_office/AGENTS.md) | LibreOffice office artifacts and office canvas sessions. |
plugins/_migrate_agents/AGENTS.md
new
+35
@@ -0,0 +1,35 @@
1
+# Migrate Agents DOX
2
+
3
+## Purpose
4
+
5
+- Own the bundled workflow for previewing and importing retained work from OpenClaw, Hermes Agent, OpenCode, Claude Code, and Codex.
6
+
7
+## Ownership
8
+
9
+- `helpers/migration.py` owns source parsing, redaction, limits, preview data, and native chat conversion.
10
+- `api/migration_preview.py` owns read-only upload inspection; `api/migration_import.py` owns confirmed writes.
11
+- `webui/` and the sidebar extension own source selection, upload, review, consent, and progress UI.
12
+- `tests/` owns parser, privacy, archive-safety, native-chat, project, and import-helper regression coverage.
13
+
14
+## Local Contracts
15
+
16
+- The bundled folder, manifest name, Python imports, and HTTP routes use `_migrate_agents`.
17
+- Preview must not write Agent Zero state. Import starts only after explicit category selection and review consent.
18
+- Credentials, authentication state, hidden reasoning, schedules, live services, and replayable tool execution stay excluded.
19
+- Imported knowledge and skills remain namespaced below `usr/knowledge/_migrate_agents/` and `usr/skills/_migrate_agents/`.
20
+- Preserve source provenance and do not claim transcript metadata can reconstruct unavailable project files.
21
+
22
+## Work Guidance
23
+
24
+- Extend the shared parser and redaction path instead of adding source-specific import side channels.
25
+- Keep source commands, supported artifacts, exclusions, and mapping limits accurate in both WebUI copy and README.
26
+- Keep bundled harness assets local and update `webui/assets/ATTRIBUTION.md` when their provenance changes.
27
+
28
+## Verification
29
+
30
+- Run `conda run -n a0 python -m pytest plugins/_migrate_agents/tests -q`.
31
+- For WebUI changes, verify the mounted modal and upload flow against the named live Agent Zero runtime.
32
+
33
+## Child DOX Index
34
+
35
+No child DOX files.
plugins/_migrate_agents/README.md
new
+78
@@ -0,0 +1,78 @@
1
+# Migrate Agents
2
+
3
+
4
+
5
+Migrate Agents is a bundled Agent Zero plugin for bringing retained work
6
+home from the five most-used open agent harnesses:
7
+
8
+- OpenClaw
9
+- Hermes Agent
10
+- OpenCode
11
+- Claude Code
12
+- Codex
13
+
14
+The plugin checks an export before importing it. You choose which chats,
15
+projects, memories, instructions, and skills to bring into Agent Zero.
16
+Credentials, authentication files, and hidden reasoning are excluded.
17
+
18
+## Enable
19
+
20
+Enable **Migrate Agents** in Plugins, then use the inbox button beside Dashboard.
21
+
22
+## Prepare an export
23
+
24
+| Source | Recommended input |
25
+| --- | --- |
26
+| OpenClaw | `openclaw backup create --verify`, an agent SQLite snapshot, or legacy transcript JSONL |
27
+| Hermes Agent | `hermes sessions export backup.jsonl --redact`, `~/.hermes/state.db`, or a folder/archive containing memories and skills |
28
+| OpenCode | `opencode export <session-id> > session.json`, repeated for each session you want |
29
+| Claude Code | A folder/archive from `~/.claude/projects`, plus any `CLAUDE.md` and skills you want |
30
+| Codex | The `sessions/` and `archived_sessions/` folders beneath `CODEX_HOME` (normally `~/.codex`) |
31
+
32
+You can choose individual files, a whole directory, ZIP, TAR, or TAR.GZ. The
33
+preview is read-only. Import does not start until you review the manifest and
34
+confirm it.
35
+
36
+## What maps cleanly
37
+
38
+| Source material | Agent Zero destination | Notes |
39
+| --- | --- | --- |
40
+| User and assistant messages | Native chats | Source ID, timestamps, and workspace metadata are retained when available. |
41
+| Retained workspace paths | Native projects | Chats from the same retained workspace are attached to the same imported project. Project files are not reconstructed from transcript metadata alone. |
42
+| Tool activity | Historical tool records | Sanitized and inert; never replayed as commands. |
43
+| Memory Markdown | `usr/knowledge/_migrate_agents/<source>/memories/` | Each document receives a provenance header. |
44
+| `SOUL.md`, `USER.md`, `CLAUDE.md`, `AGENTS.md`, and related instructions | `usr/knowledge/_migrate_agents/<source>/instructions/` | Instructions remain separately selectable from memories. |
45
+| Agent Skills containing `SKILL.md` | `usr/skills/_migrate_agents/<source>/` | Complete skill folders are copied under unique names. |
46
+| Attachment references | Chat metadata or text | Binary attachment copying is not automatic. |
47
+
48
+## Deliberate privacy boundary
49
+
50
+Migrate Agents never imports provider keys, credentials, authentication
51
+state, channel bindings, schedules, hidden reasoning, or live services. It
52
+redacts obvious secret assignments, bearer tokens, private keys, and embedded
53
+data URLs from imported text and tool records. This is defense in depth, not a
54
+guarantee; review migrated material before sharing it.
55
+
56
+The Codex adapter follows the preservation and privacy rules of the
57
+`convert-codex-chats` utility skill: public user/commentary/final events are
58
+retained, tool calls and results are paired when available, encrypted or hidden
59
+reasoning is excluded, and historical actions are not presented as replayable.
60
+
61
+## Limits
62
+
63
+- 5,000 expanded files per migration
64
+- 100 MiB per file
65
+- 256 MiB expanded total
66
+- Preview lists are capped at 200 rows, while import still processes the full accepted bundle
67
+- Modern OpenClaw conversation imports require its per-agent database or transcript artifacts; the global control-plane database does not contain chats
68
+
69
+## Development
70
+
71
+No third-party Python or JavaScript packages are required.
72
+
73
+```bash
74
+python -m pytest plugins/_migrate_agents/tests -q
75
+```
76
+
77
+Harness logo SVGs are bundled locally under `webui/assets/`; provenance and
78
+license details are recorded in `webui/assets/ATTRIBUTION.md`.
plugins/_migrate_agents/api/migration_import.py
new
+167
@@ -0,0 +1,167 @@
1
+from __future__ import annotations
2
+
3
+import json
4
+import os
5
+import re
6
+import time
7
+import uuid
8
+from pathlib import Path, PurePosixPath
9
+
10
+from agent import AgentContext
11
+from helpers import files, persist_chat, projects as a0_projects
12
+from helpers.api import ApiHandler, Input, Output, Request, Response
13
+from plugins._migrate_agents.api.migration_preview import uploaded_files
14
+from plugins._migrate_agents.helpers.migration import Asset, Project, build_a0_chat, parse_bundle
15
+
16
+
17
+def _slug(value: str, fallback: str) -> str:
18
+ result = re.sub(r"[^a-z0-9_-]+", "_", value.lower()).strip("_")
19
+ return (result or fallback)[:80]
20
+
21
+
22
+def _unique_dir(parent: Path, name: str) -> Path:
23
+ candidate = parent / name
24
+ index = 2
25
+ while candidate.exists():
26
+ candidate = parent / f"{name}_{index}"
27
+ index += 1
28
+ return candidate
29
+
30
+
31
+def _atomic_write(path: Path, data: bytes) -> None:
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
34
+ try:
35
+ with temporary.open("wb") as handle:
36
+ handle.write(data)
37
+ handle.flush()
38
+ os.fsync(handle.fileno())
39
+ temporary.replace(path)
40
+ finally:
41
+ temporary.unlink(missing_ok=True)
42
+
43
+
44
+def _knowledge_path(run_root: Path, asset: Asset) -> Path:
45
+ source = PurePosixPath(asset.path)
46
+ name = _slug("_".join(source.parts[-3:]), "knowledge")
47
+ if not name.endswith(".md"):
48
+ name += ".md"
49
+ return run_root / name
50
+
51
+
52
+def import_knowledge(source: str, category: str, assets: list[Asset]) -> list[str]:
53
+ if not assets:
54
+ return []
55
+ base = Path(files.get_abs_path("usr", "knowledge", "_migrate_agents", source, category))
56
+ run = _unique_dir(base, time.strftime("%Y%m%d_%H%M%S"))
57
+ written: list[str] = []
58
+ for asset in assets:
59
+ destination = _knowledge_path(run, asset)
60
+ header = (
61
+ f"# Imported from {source}\n\n"
62
+ f"> Original path: `{asset.path}` \n"
63
+ "> Imported by Migrate Agents. Review before sharing.\n\n"
64
+ ).encode()
65
+ _atomic_write(destination, header + asset.data)
66
+ written.append(files.deabsolute_path(str(destination)))
67
+ return written
68
+
69
+
70
+def import_skills(source: str, skills: dict[str, list[Asset]]) -> list[str]:
71
+ base = Path(files.get_abs_path("usr", "skills", "_migrate_agents", source))
72
+ written: list[str] = []
73
+ for name, assets in skills.items():
74
+ destination = _unique_dir(base, _slug(name, "skill"))
75
+ for asset in assets:
76
+ relative = PurePosixPath(asset.path)
77
+ if relative.is_absolute() or ".." in relative.parts:
78
+ raise ValueError(f"Unsafe skill path: {asset.path}")
79
+ _atomic_write(destination.joinpath(*relative.parts), asset.data)
80
+ written.append(files.deabsolute_path(str(destination)))
81
+ return written
82
+
83
+
84
+def import_projects(source: str, items: list[Project]) -> tuple[list[str], dict[str, str]]:
85
+ parent = Path(a0_projects.get_projects_parent_folder())
86
+ written: list[str] = []
87
+ chat_projects: dict[str, str] = {}
88
+ for item in items:
89
+ base = _slug(f"{source}_{item.title}", f"{source}_project")
90
+ name = base
91
+ index = 2
92
+ while (parent / name).exists():
93
+ name = f"{base}_{index}"
94
+ index += 1
95
+ a0_projects.create_project(
96
+ name,
97
+ {
98
+ "title": item.title,
99
+ "description": f"Imported from {source}. Original workspace: {item.path}",
100
+ "instructions": "",
101
+ "include_agents_md": True,
102
+ "color": "#6366f1",
103
+ "git_url": "",
104
+ },
105
+ )
106
+ written.append(name)
107
+ chat_projects.update({chat_id: name for chat_id in item.conversation_ids})
108
+ return written, chat_projects
109
+
110
+
111
+def import_chats(source: str, conversations, chat_projects: dict[str, str] | None = None) -> list[str]:
112
+ payloads = [json.dumps(build_a0_chat(item, source), ensure_ascii=False) for item in conversations]
113
+ if not payloads:
114
+ return []
115
+ ctxids = persist_chat.load_json_chats(payloads)
116
+ if len(ctxids) != len(conversations):
117
+ raise RuntimeError("Imported chat count does not match the migration preview")
118
+ chat_projects = chat_projects or {}
119
+ for ctxid, conversation in zip(ctxids, conversations):
120
+ context = AgentContext.get(ctxid)
121
+ if context is None:
122
+ raise RuntimeError(f"Imported chat was not loaded: {ctxid}")
123
+ project_name = chat_projects.get(conversation.source_id)
124
+ if project_name:
125
+ a0_projects.activate_project(ctxid, project_name)
126
+ else:
127
+ persist_chat.save_tmp_chat(context)
128
+ return ctxids
129
+
130
+
131
+class MigrationImport(ApiHandler):
132
+ async def process(self, input: Input, request: Request) -> Output:
133
+ try:
134
+ source = str(request.form.get("source") or "").strip().lower()
135
+ include_chats = request.form.get("include_chats", "true").lower() == "true"
136
+ include_projects = request.form.get("include_projects", "true").lower() == "true"
137
+ legacy_knowledge = request.form.get("include_knowledge", "true")
138
+ include_memories = request.form.get("include_memories", legacy_knowledge).lower() == "true"
139
+ include_instructions = request.form.get("include_instructions", legacy_knowledge).lower() == "true"
140
+ include_skills = request.form.get("include_skills", "true").lower() == "true"
141
+ bundle = parse_bundle(source, uploaded_files(request))
142
+ project_names, chat_projects = import_projects(source, bundle.projects) if include_projects else ([], {})
143
+ ctxids = import_chats(source, bundle.conversations, chat_projects) if include_chats else []
144
+ memories = import_knowledge(source, "memories", bundle.memories) if include_memories else []
145
+ instructions = import_knowledge(source, "instructions", bundle.instructions) if include_instructions else []
146
+ skills = import_skills(source, bundle.skills) if include_skills else []
147
+ return {
148
+ "ok": True,
149
+ "ctxids": ctxids,
150
+ "projects": project_names,
151
+ "memories": memories,
152
+ "instructions": instructions,
153
+ "knowledge": [*memories, *instructions],
154
+ "skills": skills,
155
+ "summary": {
156
+ "chats": len(ctxids),
157
+ "projects": len(project_names),
158
+ "memories": len(memories),
159
+ "instructions": len(instructions),
160
+ "knowledge": len(memories) + len(instructions),
161
+ "skills": len(skills),
162
+ "redactions": bundle.redactions,
163
+ },
164
+ "warnings": bundle.warnings,
165
+ }
166
+ except ValueError as exc:
167
+ return Response(str(exc), 400)
plugins/_migrate_agents/api/migration_import.py.dox.md
new
+17
@@ -0,0 +1,17 @@
1
+# migration_import.py DOX
2
+
3
+## Purpose
4
+
5
+Commit a previously reviewed harness export into native Agent Zero storage.
6
+
7
+## Contract
8
+
9
+- Auth and CSRF use the default `ApiHandler` protections.
10
+- Accepts the same multipart export as the preview endpoint plus category toggles.
11
+- Creates new chat IDs; it never overwrites an existing chat.
12
+- Writes imported knowledge below `usr/knowledge/_migrate_agents/` and skills below `usr/skills/_migrate_agents/` using unique directories and atomic files.
13
+- Does not import credentials, provider keys, authentication state, schedules, channel bindings, or executable runtime services.
14
+
15
+## Verification
16
+
17
+Run `python -m pytest plugins/_migrate_agents/tests -q`, then exercise preview and import against the live framework runtime.
plugins/_migrate_agents/api/migration_preview.py
new
+27
@@ -0,0 +1,27 @@
1
+from __future__ import annotations
2
+
3
+from helpers.api import ApiHandler, Input, Output, Request, Response
4
+from plugins._migrate_agents.helpers.migration import MAX_TOTAL_BYTES, Upload, parse_bundle, preview
5
+
6
+
7
+def uploaded_files(request: Request) -> list[Upload]:
8
+ result: list[Upload] = []
9
+ total = 0
10
+ for item in request.files.getlist("files[]"):
11
+ data = item.stream.read(MAX_TOTAL_BYTES + 1)
12
+ total += len(data)
13
+ if total > MAX_TOTAL_BYTES:
14
+ raise ValueError("Upload exceeds the 256 MiB limit")
15
+ result.append(Upload(item.filename or "upload", data))
16
+ if not result:
17
+ raise ValueError("Choose at least one export file or folder")
18
+ return result
19
+
20
+
21
+class MigrationPreview(ApiHandler):
22
+ async def process(self, input: Input, request: Request) -> Output:
23
+ try:
24
+ source = str(request.form.get("source") or "").strip().lower()
25
+ return preview(parse_bundle(source, uploaded_files(request)))
26
+ except ValueError as exc:
27
+ return Response(str(exc), 400)
plugins/_migrate_agents/api/migration_preview.py.dox.md
new
+17
@@ -0,0 +1,17 @@
1
+# migration_preview.py DOX
2
+
3
+## Purpose
4
+
5
+Preview a user-selected harness export without changing Agent Zero state.
6
+
7
+## Contract
8
+
9
+- Auth and CSRF use the default `ApiHandler` protections.
10
+- Accepts multipart `source` plus one or more `files[]` values.
11
+- Rejects unsafe archives and uploads larger than 256 MiB.
12
+- Returns counts, chat titles, knowledge files, skills, exclusions, and warnings.
13
+- Never returns source message bodies or credential values.
14
+
15
+## Verification
16
+
17
+Run `python -m pytest plugins/_migrate_agents/tests -q` from the Agent Zero root.
plugins/_migrate_agents/extensions/webui/sidebar-quick-actions-main-start/migrate-agents.html
new
+10
@@ -0,0 +1,10 @@
1
+<div x-data>
2
+ <button x-move-after=".config-button#dashboard"
3
+ class="config-button"
4
+ id="migrate-agents"
5
+ title="Migrate Agents"
6
+ aria-label="Open Migrate Agents"
7
+ @click="openModal('/plugins/_migrate_agents/webui/main.html')">
8
+ <x-icon name="move_to_inbox"></x-icon>
9
+ </button>
10
+</div>
plugins/_migrate_agents/helpers/__init__.py
new
+1
@@ -0,0 +1 @@
1
+"""Migrate Agents conversion helpers."""
plugins/_migrate_agents/helpers/migration.py
new
+887
@@ -0,0 +1,887 @@
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
+ }
plugins/_migrate_agents/plugin.yaml
new
+8
@@ -0,0 +1,8 @@
1
+name: _migrate_agents
2
+title: Migrate Agents
3
+description: Review and import chats, projects, memories, instructions, and skills from OpenClaw, Hermes Agent, OpenCode, Claude Code, and Codex.
4
+version: 0.1.0
5
+settings_sections: []
6
+always_enabled: false
7
+per_project_config: false
8
+per_agent_config: false
plugins/_migrate_agents/tests/test_import_helpers.py
new
+32
@@ -0,0 +1,32 @@
1
+from __future__ import annotations
2
+
3
+import tempfile
4
+import unittest
5
+from pathlib import Path
6
+from unittest.mock import patch
7
+
8
+from plugins._migrate_agents.api import migration_import
9
+from plugins._migrate_agents.helpers.migration import Project
10
+
11
+
12
+class ImportHelperTests(unittest.TestCase):
13
+ def test_import_projects_uses_unique_native_names_and_maps_chats(self):
14
+ project = Project("project-1", "Acme App", "/work/acme", ["chat-1", "chat-2"])
15
+ with tempfile.TemporaryDirectory() as root:
16
+ Path(root, "codex_acme_app").mkdir()
17
+ with (
18
+ patch.object(migration_import.a0_projects, "get_projects_parent_folder", return_value=root),
19
+ patch.object(migration_import.a0_projects, "create_project", return_value="codex_acme_app_2") as create,
20
+ ):
21
+ names, mapping = migration_import.import_projects("codex", [project])
22
+
23
+ self.assertEqual(names, ["codex_acme_app_2"])
24
+ self.assertEqual(mapping, {"chat-1": "codex_acme_app_2", "chat-2": "codex_acme_app_2"})
25
+ payload = create.call_args.args[1]
26
+ self.assertEqual(payload["title"], "Acme App")
27
+ self.assertIn("/work/acme", payload["description"])
28
+ self.assertNotIn("secret", payload)
29
+
30
+
31
+if __name__ == "__main__":
32
+ unittest.main()
plugins/_migrate_agents/tests/test_migration.py
new
+179
@@ -0,0 +1,179 @@
1
+from __future__ import annotations
2
+
3
+import importlib.util
4
+import io
5
+import json
6
+import sqlite3
7
+import sys
8
+import tempfile
9
+import unittest
10
+import zipfile
11
+from pathlib import Path
12
+
13
+
14
+ROOT = Path(__file__).resolve().parents[1]
15
+SPEC = importlib.util.spec_from_file_location("_migrate_agents_core", ROOT / "helpers" / "migration.py")
16
+assert SPEC and SPEC.loader
17
+migration = importlib.util.module_from_spec(SPEC)
18
+sys.modules[SPEC.name] = migration
19
+SPEC.loader.exec_module(migration)
20
+
21
+
22
+def jsonl(*rows) -> bytes:
23
+ return ("\n".join(json.dumps(row) for row in rows) + "\n").encode()
24
+
25
+
26
+class MigrationTests(unittest.TestCase):
27
+ def test_codex_uses_public_events_tools_and_excludes_reasoning(self):
28
+ upload = migration.Upload(
29
+ "rollout-test.jsonl",
30
+ jsonl(
31
+ {"type": "session_meta", "payload": {"id": "codex-1", "cwd": "/work"}},
32
+ {"type": "event_msg", "timestamp": "2026-01-01T00:00:00Z", "payload": {"type": "user_message", "message": "Fix it"}},
33
+ {"type": "event_msg", "timestamp": "2026-01-01T00:00:01Z", "payload": {"type": "agent_message", "phase": "commentary", "message": "Checking"}},
34
+ {"type": "response_item", "timestamp": "2026-01-01T00:00:02Z", "payload": {"type": "function_call", "call_id": "c1", "name": "shell", "arguments": "{\"api_key\":\"secret-value\"}"}},
35
+ {"type": "response_item", "timestamp": "2026-01-01T00:00:03Z", "payload": {"type": "function_call_output", "call_id": "c1", "output": "done"}},
36
+ {"type": "event_msg", "timestamp": "2026-01-01T00:00:04Z", "payload": {"type": "agent_message", "phase": "final_answer", "message": "Fixed"}},
37
+ {"type": "response_item", "payload": {"type": "reasoning", "text": "hidden"}},
38
+ ),
39
+ )
40
+ bundle = migration.parse_bundle("codex", [upload])
41
+ self.assertEqual(bundle.summary()["chats"], 1)
42
+ self.assertEqual([event.kind for event in bundle.conversations[0].events], ["user", "tool", "assistant"])
43
+ self.assertEqual(bundle.conversations[0].events[1].tool_result, "done")
44
+ self.assertEqual(bundle.conversations[0].events[1].tool_args["api_key"], "[REDACTED]")
45
+ self.assertNotIn('"text": "hidden"', json.dumps(migration.build_a0_chat(bundle.conversations[0], "codex")))
46
+
47
+ def test_claude_ignores_thinking_and_sidechains(self):
48
+ upload = migration.Upload(
49
+ "project/session.jsonl",
50
+ jsonl(
51
+ {"type": "user", "sessionId": "claude-1", "timestamp": "2026-01-01T00:00:00Z", "message": {"role": "user", "content": [{"type": "text", "text": "Hello"}]}},
52
+ {"type": "assistant", "sessionId": "claude-1", "timestamp": "2026-01-01T00:00:01Z", "message": {"role": "assistant", "content": [{"type": "thinking", "thinking": "private"}, {"type": "text", "text": "Hi"}]}},
53
+ {"type": "assistant", "isSidechain": True, "sessionId": "claude-1", "message": {"role": "assistant", "content": [{"type": "text", "text": "branch"}]}},
54
+ ),
55
+ )
56
+ bundle = migration.parse_bundle("claude", [upload])
57
+ text = json.dumps(migration.build_a0_chat(bundle.conversations[0], "claude"))
58
+ self.assertIn("Hello", text)
59
+ self.assertIn("Hi", text)
60
+ self.assertNotIn("private", text)
61
+ self.assertNotIn("branch", text)
62
+
63
+ def test_opencode_export_keeps_text_and_completed_tool(self):
64
+ data = {
65
+ "info": {"id": "ses_1", "title": "Ship it", "directory": "/repo"},
66
+ "messages": [
67
+ {"info": {"role": "user", "time": {"created": 1000}}, "parts": [{"type": "text", "text": "Build"}]},
68
+ {"info": {"role": "assistant", "time": {"created": 2000}}, "parts": [{"type": "text", "text": "Done"}, {"type": "tool", "tool": "bash", "state": {"status": "completed", "input": {"command": "true"}, "output": "ok"}}]},
69
+ ],
70
+ }
71
+ bundle = migration.parse_bundle("opencode", [migration.Upload("session.json", json.dumps(data).encode())])
72
+ self.assertEqual(bundle.conversations[0].title, "Ship it")
73
+ self.assertEqual([event.kind for event in bundle.conversations[0].events], ["user", "assistant", "tool"])
74
+
75
+ def test_hermes_jsonl_and_openclaw_jsonl(self):
76
+ hermes = migration.parse_bundle(
77
+ "hermes",
78
+ [migration.Upload("backup.jsonl", jsonl({"id": "h1", "title": "Hermes", "messages": [{"role": "user", "content": "One"}, {"role": "assistant", "content": "Two"}]}))],
79
+ )
80
+ claw = migration.parse_bundle(
81
+ "openclaw",
82
+ [migration.Upload("trace.jsonl", jsonl({"type": "session", "id": "o1"}, {"type": "message", "message": {"role": "user", "content": "One"}}, {"type": "message", "message": {"role": "assistant", "content": "Two"}}))],
83
+ )
84
+ self.assertEqual(hermes.summary()["chats"], 1)
85
+ self.assertEqual(claw.summary()["chats"], 1)
86
+
87
+ def test_hermes_sqlite(self):
88
+ with tempfile.NamedTemporaryFile(suffix=".db") as handle:
89
+ db = sqlite3.connect(handle.name)
90
+ db.executescript(
91
+ "CREATE TABLE sessions (id TEXT, source TEXT, model TEXT, title TEXT, cwd TEXT, started_at REAL);"
92
+ "CREATE TABLE messages (id INTEGER, session_id TEXT, role TEXT, content TEXT, timestamp REAL, tool_calls TEXT, tool_name TEXT, tool_call_id TEXT);"
93
+ "INSERT INTO sessions VALUES ('h1','cli','model','SQLite chat','/repo',1);"
94
+ "INSERT INTO messages VALUES (1,'h1','user','Hello',1,NULL,NULL,NULL);"
95
+ "INSERT INTO messages VALUES (2,'h1','assistant','Hi',2,NULL,NULL,NULL);"
96
+ )
97
+ db.commit()
98
+ data = Path(handle.name).read_bytes()
99
+ db.close()
100
+ bundle = migration.parse_bundle("hermes", [migration.Upload("state.db", data)])
101
+ self.assertEqual(bundle.conversations[0].title, "SQLite chat")
102
+
103
+ def test_openclaw_sqlite(self):
104
+ with tempfile.NamedTemporaryFile(suffix=".sqlite") as handle:
105
+ db = sqlite3.connect(handle.name)
106
+ db.executescript(
107
+ "CREATE TABLE session_windows (session_id TEXT, session_key TEXT, created_at INTEGER, display_name TEXT, channel TEXT, model TEXT);"
108
+ "CREATE TABLE transcript_events (session_id TEXT, seq INTEGER, event_json TEXT, created_at INTEGER);"
109
+ "INSERT INTO session_windows VALUES ('o1','agent:main:main',1,'Claw chat','web','model');"
110
+ )
111
+ event = json.dumps({"type": "message", "message": {"role": "user", "content": "Hello"}})
112
+ db.execute("INSERT INTO transcript_events VALUES ('o1',1,?,1)", (event,))
113
+ db.commit()
114
+ data = Path(handle.name).read_bytes()
115
+ db.close()
116
+ bundle = migration.parse_bundle("openclaw", [migration.Upload("openclaw-agent.sqlite", data)])
117
+ self.assertEqual(bundle.conversations[0].title, "Claw chat")
118
+
119
+ def test_discovers_knowledge_and_complete_skill_folder(self):
120
+ bundle = migration.parse_bundle(
121
+ "claude",
122
+ [
123
+ migration.Upload("project/memory/MEMORY.md", b"Remember api_key=secret"),
124
+ migration.Upload("project/CLAUDE.md", b"Remember password=secret"),
125
+ migration.Upload("project/skills/release/SKILL.md", b"---\nname: release\n---"),
126
+ migration.Upload("project/skills/release/scripts/run.py", b"API_KEY=secret"),
127
+ migration.Upload("project/skills/release/.env", b"API_KEY=never"),
128
+ ],
129
+ )
130
+ self.assertEqual(bundle.summary()["memories"], 1)
131
+ self.assertEqual(bundle.summary()["instructions"], 1)
132
+ self.assertEqual(bundle.summary()["knowledge"], 2)
133
+ self.assertEqual(bundle.summary()["skills"], 1)
134
+ self.assertIn(b"[REDACTED]", bundle.memories[0].data)
135
+ self.assertIn(b"[REDACTED]", bundle.instructions[0].data)
136
+ self.assertEqual(len(next(iter(bundle.skills.values()))), 2)
137
+ self.assertIn(b"[REDACTED]", bundle.skills["release"][1].data)
138
+ self.assertEqual(bundle.summary()["excluded"], 1)
139
+
140
+ def test_discovers_projects_from_retained_workspace_context(self):
141
+ data = {
142
+ "info": {"id": "ses_1", "title": "Ship it", "directory": "/work/acme"},
143
+ "messages": [
144
+ {"info": {"role": "user"}, "parts": [{"type": "text", "text": "Build"}]},
145
+ ],
146
+ }
147
+ bundle = migration.parse_bundle(
148
+ "opencode",
149
+ [migration.Upload("session.json", json.dumps(data).encode())],
150
+ )
151
+ self.assertEqual(bundle.summary()["projects"], 1)
152
+ self.assertEqual(bundle.projects[0].title, "acme")
153
+ self.assertEqual(bundle.projects[0].path, "/work/acme")
154
+ self.assertEqual(bundle.projects[0].conversation_ids, ["ses_1"])
155
+
156
+ def test_rejects_archive_traversal(self):
157
+ stream = io.BytesIO()
158
+ with zipfile.ZipFile(stream, "w") as archive:
159
+ archive.writestr("../escape.json", "{}")
160
+ with self.assertRaisesRegex(ValueError, "Unsafe archive path"):
161
+ migration.parse_bundle("opencode", [migration.Upload("bad.zip", stream.getvalue())])
162
+
163
+ def test_a0_chat_log_and_history_sequences_are_valid(self):
164
+ conversation = migration.Conversation(
165
+ "id",
166
+ "Title",
167
+ [migration.Event("user", "Hi", 1), migration.Event("assistant", "Hello", 2)],
168
+ )
169
+ chat = migration.build_a0_chat(conversation, "test")
170
+ history = json.loads(chat["agents"][0]["history"])
171
+ messages = history["current"]["messages"]
172
+ self.assertEqual(history["counter"], len(messages))
173
+ self.assertEqual([item["sequence"] for item in messages], list(range(1, len(messages) + 1)))
174
+ self.assertEqual([item["no"] for item in chat["log"]["logs"]], list(range(len(chat["log"]["logs"]))))
175
+ self.assertEqual([item["type"] for item in chat["log"]["logs"]], ["user", "agent", "response"])
176
+
177
+
178
+if __name__ == "__main__":
179
+ unittest.main()
plugins/_migrate_agents/webui/assets/ATTRIBUTION.md
new
+7
@@ -0,0 +1,7 @@
1
+# Harness logo attribution
2
+
3
+The SVG marks in this folder are bundled from
4
+[Lobe Icons](https://github.com/lobehub/lobe-icons) at commit
5
+`f07e9be35aef452ce735f95ea8204a14ecc513f7` under its MIT license.
6
+They identify OpenClaw, Hermes Agent, OpenCode, Claude Code, and Codex.
7
+All product names and marks remain the property of their respective owners.
plugins/_migrate_agents/webui/assets/claude.svg
new
+1
@@ -0,0 +1 @@
1
+<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>
plugins/_migrate_agents/webui/assets/codex.svg
new
+1
@@ -0,0 +1 @@
1
+<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-_R_0_" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>
plugins/_migrate_agents/webui/assets/hermes.svg
new
+1
@@ -0,0 +1 @@
1
+<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Hermes Agent</title><path d="M5.938 12.835c.127-.039.285.02.373.143.028.038.036.092.046.14.003.014-.02.033-.04.05-.124-.098-.24-.194-.354-.291-.011-.01-.016-.027-.025-.042zM8.396 9.412c.195-.032.39-.06.588-.05a.54.54 0 01.148.026c.202.071.402.147.601.224.028.01.05.036.075.055l-.013.027a9.203 9.203 0 01-.26-.089c-.115-.038-.213-.077-.315-.098-.25-.05-.25-.046-.292-.014l.574.144c.275.139.55.276.823.417.042.022.09.057.107.098.026.06.063.076.117.072.066-.006.132-.017.213-.027l-.04.086c.051.08.142.02.216.064-.074.13-.247.09-.334.199l.061.074-.12.087c0 .106-.038.168-.306.243l.026.085-.196.042.07.124h-.25l-.007.137c-.081-.01-.161-.018-.244-.027l-.053.123c-.027-.008-.052-.011-.073-.023-.067-.038-.128-.056-.195.006-.019.017-.063.014-.093.008-.026-.006-.05-.029-.07-.042-.11.095-.11.095-.208.003-.057.046-.12.074-.186.011-.063.027-.123-.02-.178-.014-.07.007-.097-.035-.133-.07l-.13.033c-.013-.236-.194-.19-.34-.203.005-.072.05-.092.095-.094a.474.474 0 01.159.022c.164.05.32.12.496.138.203.021.405.029.601-.015.265-.059.52-.149.707-.365.049-.056.083-.127.117-.195.019-.038.02-.084-.02-.116a1.397 1.397 0 00-.382-.217c.024.12-.031.182-.115.221 0 .014-.004.025 0 .03.08.115.084.16-.007.267a1.39 1.39 0 01-.218.211.477.477 0 01-.641-.05 1.36 1.36 0 01-.133-.152c-.078-.107-.076-.108-.033-.236-.165-.08-.128-.226-.104-.364.008-.05.028-.096.049-.163-.04.014-.067.017-.087.032a.897.897 0 00-.316.357c-.007.016-.01.034-.02.047-.012.015-.034.038-.045.035-.02-.006-.037-.027-.05-.045-.008-.012-.007-.032-.012-.057h-.126l.053-.172a14.82 14.82 0 00-.039-.049l.11-.284c-.06.026-.091.044-.124.051-.03.007-.064 0-.095 0 0-.031-.01-.07.004-.092.149-.22.305-.428.593-.476z"></path><path d="M8.06 10.788c-.003-.038-.004-.075.037-.062.016.006.034.048.028.067-.01.04-.038.032-.064-.005z"></path><path clip-rule="evenodd" d="M11.981.009c.226-.012.453-.011.679 0 .247.01.495.024.74.062.401.064.798.157 1.19.273.463.138.92.299 1.356.511a7.31 7.31 0 012.948 2.642c.292.469.536.963.739 1.479.219.556.446 1.11.623 1.683.204.654.329 1.326.458 1.997.097.504.182 1.01.29 1.511.156.722.329 1.44.494 2.16.186.812.4 1.615.63 2.415.102.355.193.713.282 1.072.11.436.202.876.254 1.323.031.278.066.557.073.837a7.56 7.56 0 01-.017.88c-.037.413-.1.818-.226 1.212a5.017 5.017 0 01-.915 1.649l-.13.156.018.023c.043-.023.088-.041.127-.068.2-.138.373-.307.531-.49.4-.46.721-.973.975-1.529a3.59 3.59 0 00.325-1.72c-.024-.424-.097-.834-.3-1.213-.013-.027-.015-.06-.03-.121.05.035.082.048.101.072.107.13.22.258.315.398.33.494.46 1.052.486 1.64a3.75 3.75 0 01-.47 1.97c-.36.655-.887 1.14-1.526 1.506-.193.111-.394.21-.595.308-.157.078-.248.211-.318.365a.522.522 0 00-.033.406.359.359 0 01.013.139c-.005.077-.077.155-.14.162-.054.006-.125-.043-.15-.116a1.206 1.206 0 01-.06-.233c-.04-.314-.155-.6-.308-.87a3.906 3.906 0 00-.73-.91 2.129 2.129 0 00-.897-.524 4.093 4.093 0 00-.692-.131c-.075-.008-.15-.04-.22.01.18.06.363.11.538.18.434.173.82.43 1.18.728.308.255.58.543.794.884.098.155.186.315.227.496.027.123.042.25.067.375.013.062-.002.109-.053.144-.047.033-.122.034-.163-.01a.455.455 0 01-.08-.14c-.03-.073-.038-.159-.078-.225a7.314 7.314 0 00-1.423-1.664c-.16-.137-.329-.26-.537-.323-.376-.114-.753-.203-1.15-.154-.213.025-.427.032-.64.053a1.6 1.6 0 00-.736.278 5.14 5.14 0 00-.834.72c-.329.342-.642.699-.955 1.055-.136.155-.264.319-.314.531a5.227 5.227 0 00-.012.051.096.096 0 01-.09.076h-.31c-.046 0-.082-.048-.072-.094.023-.108.045-.216.07-.324.075-.325.19-.635.368-.917.024-.039.04-.088.104-.08l.01.049.027.077c.28-.435.571-.834.996-1.135.283-.204.584-.378.89-.55a.196.196 0 00-.098-.002c-.162.043-.325.084-.485.134-.402.124-.764.33-1.11.566-.147.1-.298.193-.414.333a7.314 7.314 0 00-1.07 1.767.845.845 0 00-.04.12.075.075 0 01-.072.056h-.494c-.04 0-.062-.051-.036-.082.123-.14.246-.282.377-.415.275-.281.58-.532.777-.884.027-.048.063-.09.095-.135.238-.333.54-.607.818-.902.082-.086.175-.16.26-.24.029-.027.053-.057.079-.085l-.018-.025-.135.041c-.034.017-.07.031-.102.05-.248.144-.494.292-.743.433-.408.23-.825.439-1.209.711-.281.2-.591.358-.889.533-.02.012-.044.015-.08.028-.015-.135.143-.201.108-.336-.033.014-.064.02-.085.038-.111.096-.227.19-.328.296-.148.157-.284.325-.425.488-.125.143-.25.286-.373.431A.153.153 0 019.89 24H8.762a.316.316 0 00.016-.042c.028-.09.085-.172.083-.28-.091-.018-.162.001-.212.077a4.45 4.45 0 00-.136.215c-.01.016-.024.03-.042.03h-.093c-.019 0-.029-.022-.017-.037.071-.088.14-.178.209-.268.001-.002-.006-.012-.012-.024-.014.004-.03.006-.045.013-.176.09-.352.181-.527.274a.363.363 0 01-.168.042H5.202c-.026 0-.039-.036-.019-.053.21-.178.402-.374.558-.605.335-.496.538-1.047.667-1.629.004-.02-.003-.043-.006-.091-.037.048-.059.072-.076.1a1.943 1.943 0 01-.334.415c-.28.258-.59.448-.983.464-.297.012-.588 0-.865-.127-.46-.21-.722-.57-.794-1.072-.025-.17-.017-.171-.182-.219A3.513 3.513 0 011.97 20.6a2.286 2.286 0 01-.808-1.13 3.569 3.569 0 01-.16-1.245c.002-.034.016-.067.024-.1.032.023.046.043.05.066.033.153.059.308.096.46.086.355.257.664.516.92.258.256.571.419.91.532.358.118.717.138 1.07-.016a1.89 1.89 0 00.621-.452c.328-.348.533-.76.648-1.223.009-.034.005-.071.007-.11-.015.006-.026.006-.03.011-.031.05-.064.1-.093.152-.284.502-.679.887-1.196 1.135-.351.17-.718.255-1.11.159a1.607 1.607 0 01-.971-.64 2.006 2.006 0 01-.368-.924 2.903 2.903 0 01.02-.886c.05-.439.466-1.17.742-1.271-.02.063-.035.112-.053.16-.043.116-.097.227-.13.345a1.901 1.901 0 00-.05.82c.033.212.09.416.204.6.147.236.346.407.62.465.11.023.225.014.338.018a.576.576 0 00.386-.131c.164-.128.282-.292.366-.481.168-.375.24-.777.309-1.179.05-.296.093-.594.133-.893.039-.281.071-.563.104-.845.026-.232.048-.464.074-.696.024-.228.052-.455.076-.683.024-.227.047-.455.069-.683.013-.14.022-.28.034-.42l.037-.417c.022-.25.041-.5.065-.748.008-.082-.02-.132-.09-.177a2.46 2.46 0 01-.492-.418c-.1-.109-.188-.228-.282-.342-.035-.042-.056-.097-.116-.118a2.084 2.084 0 00.275.597c.06.092.131.176.196.265.063.086.182.115.234.226-.028.003-.046.01-.06.006a4.74 4.74 0 01-.22-.057 2.71 2.71 0 01-1.287-.819c-.435-.487-.656-1.076-.71-1.723a5.206 5.206 0 01.014-1.06c.072-.602.22-1.186.45-1.745.155-.376.338-.741.526-1.102.205-.393.466-.75.765-1.076.512-.559 1.104-1.024 1.726-1.448.717-.49 1.478-.898 2.277-1.233C8.244.828 8.767.632 9.31.494c.655-.166 1.31-.33 1.982-.415.229-.03.458-.058.688-.07zm-1.847 22.82c-.07.06-.147.111-.207.18-.238.27-.464.549-.668.869l-.044.108a.177.177 0 00.093-.057c.174-.19.351-.378.519-.574.104-.122.195-.255.288-.386.024-.034.03-.08.046-.12l-.027-.02zm1.65-3.695a5.51 5.51 0 00-.653.593l-.37.386a.963.963 0 01-.377.25 1.372 1.372 0 01-.467.09c-.044 0-.087.006-.151.012.028.058.043.097.064.131.15.242.301.482.45.724.136.22.276.438.399.666.068.125.105.267.156.404.077.027.14-.018.202-.048.29-.135.579-.274.867-.412.213-.101.437-.186.636-.31.347-.215.68-.455 1.018-.685.015-.01.026-.028.042-.046-.023-.019-.038-.037-.056-.044-.287-.111-.527-.3-.77-.482a5.319 5.319 0 01-.506-.42 1.757 1.757 0 01-.41-.653c-.019-.049-.045-.095-.075-.156zm-5.847.264c-.06.096-.097.194-.132.293a3.38 3.38 0 01-.555 1.01c-.2.25-.455.412-.762.493-.23.06-.464.076-.7.07-.048-.002-.097.002-.158.005.016.04.021.066.035.085.1.145.23.246.4.295.157.046.316.034.498.023.181-.037.343-.115.485-.234.238-.199.402-.454.536-.732.175-.363.264-.751.342-1.144.01-.053.008-.11.011-.164zm14.945-4.586c.008.029.016.057.027.107.024.155.051.31.072.464.03.219.067.437.078.657.017.344.027.689-.014 1.033-.037.315-.063.633-.116.946a6.153 6.153 0 01-.46 1.518c-.008.018-.01.039-.02.082.047-.03.077-.042.098-.064.085-.083.17-.167.248-.255.271-.305.458-.66.596-1.043.18-.498.228-1.011.145-1.531-.103-.65-.33-1.263-.597-1.881a9.055 9.055 0 00-.024-.055l-.033.022zM5.797 8.29a.26.26 0 00.018.153c.124.251.25.501.379.75.025.049.066.09.03.163-.284.06-.578.119-.88.255.059.038.097.06.132.087.042.032.112.058.09.12-.01.033-.075.048-.117.072.017.01.043.021.067.036.166.102.33.207.447.368.138.192.229.404.188.644-.079.469-.306.85-.69 1.132-.054.04-.106.083-.161.122a.243.243 0 00-.103.245.77.77 0 00.055.195c.083.196.22.35.375.492.083.076.159.164.222.257a.37.37 0 01.025.377c-.023.05-.05.099-.076.148-.03.06-.028.111.022.162.041.042.08.089.112.138.038.058.078.079.147.05a.486.486 0 01.333-.006c.16.046.302.126.444.21.13.077.264.149.4.219.067.035.14.05.219.026.071-.022.124.01.145.076.02.064-.003.108-.074.139-.07.03-.137.063-.209.088-.1.035-.201.073-.314.077-.013-.107.11-.088.127-.159-.206-.126-.643-.145-.801-.034.063.112.035.21-.096.313-.13-.1-.025-.202.002-.3a.209.209 0 00-.249.17c-.015.101.067.216.178.224.108.007.218-.005.326-.012.06-.005.12-.027.199 0-.103.123-.248.127-.357.19.002.05.07.086.019.131-.053.048-.095-.001-.132-.03-.08-.063-.16-.126-.231-.197a.474.474 0 01-.157-.311.52.52 0 00-.043-.172c-.032-.074-.032-.137.033-.19-.018-.03-.028-.053-.045-.072a1.222 1.222 0 01-.196-.369c-.053-.137-.046-.264.048-.381.024-.03.05-.06.064-.095a.664.664 0 00.047-.168c.017-.165-.064-.287-.182-.387-.186-.156-.36-.322-.46-.551-.005-.011-.024-.017-.037-.026-.011.017-.024.027-.025.038-.019.185-.045.37-.052.557-.014.377.058.743.162 1.104.118.41.289.798.488 1.173.267.502.537 1.002.812 1.5.055.098.13.189.208.27.198.202.452.272.724.273.202 0 .404-.006.605-.026.295-.03.59-.073.884-.113.183-.025.365-.057.548-.08.21-.026.38.073.522.21.16.156.305.327.447.5.22.265.397.56.554.867.05.098.07.1.147.03.13-.121.26-.242.394-.36.067-.059.088-.12.067-.213a3.535 3.535 0 01-.085-.796c.002-.157.006-.314.018-.471.015-.224.03-.45.06-.672a59.114 59.114 0 01.362-2.298c.087-.493.182-.984.268-1.477.06-.347.118-.694.162-1.043.034-.273.055-.55.063-.825.011-.332.003-.665.002-.998 0-.077.004-.155-.01-.23-.028-.142-.01-.155-.162-.19a5.826 5.826 0 00-.607-.107c-.146-.018-.207-.053-.221-.19-.006-.049-.025-.098-.041-.146-.009-.025-.024-.048-.046-.09l-.025.264c-.009.096-.029.116-.127.115-.055 0-.11-.008-.164-.008-.476 0-.952-.008-1.426.032-.095.008-.173-.015-.226-.103-.04-.066-.088-.126-.134-.186-.063-.084-.086-.093-.182-.06-.195.068-.388.138-.582.21a2.71 2.71 0 00-.675.394.986.986 0 01-.323.168c-.033.01-.07.008-.127.013.02-.066.024-.114.047-.15.064-.105.135-.205.205-.306.023-.033.049-.063.073-.095l-.015-.023-.201.037c-.146.04-.296.07-.437.122-.148.053-.266.023-.386-.072a3.623 3.623 0 01-.733-.786l-.093-.132zm8.592 8.963l-.147.09c-.22.134-.44.266-.659.402-.093.058-.184.12-.27.188-.085.07-.124.161-.072.272.047.1.093.2.147.294.047.08.124.138.213.147.11.01.228.012.336-.012.217-.05.372-.205.528-.357a.291.291 0 00.087-.308c-.046-.18-.079-.365-.118-.547-.011-.052-.027-.103-.045-.169zm-.257-2.409c-.12.291-.205.597-.325.91-.151.433-.294.87-.435 1.323.036-.01.054-.01.067-.018.261-.16.522-.324.785-.484.054-.033.071-.078.065-.138-.012-.13-.024-.262-.034-.393l-.068-.886c-.008-.103-.02-.206-.029-.31-.009 0-.017-.002-.026-.004zm3.081-8.13l.099.285c.08.231.159.463.24.714l.58 1.952c.187.63.372 1.262.558 1.893.114.382.235.762.343 1.146.072.257.126.519.186.799.044.206.087.413.127.64.034.106.023.226.077.325l.025-.006-.068-.362c-.038-.206-.077-.412-.113-.638-.015-.07-.029-.141-.046-.211-.095-.396-.177-.796-.29-1.187-.196-.685-.413-1.364-.618-2.046-.165-.549-.322-1.1-.488-1.648-.069-.227-.15-.45-.226-.695l-.117-.336c-.037-.107-.075-.216-.115-.322-.04-.106-.084-.21-.127-.314a7.558 7.558 0 01-.027.01zM6.225 14.304c-.063-.001-.115.014-.134.083a.35.35 0 00.41.012 4.533 4.533 0 00-.276-.095zM5.23 11.98c-.026-.027-.057-.048-.075.002-.012.032-.007.07-.01.113.082-.037.082-.037.085-.115zm.062-1.189a.135.135 0 00-.088.056.197.197 0 00-.025.11c.005.152.01.306.026.457a.751.751 0 00.066.218c.061.136.157.167.288.101.055-.027.06-.054.025-.11a4.52 4.52 0 01-.129-.211c-.015-.068-.066-.131-.033-.207.04-.09-.076-.116-.074-.19V10.874c-.003-.038-.006-.087-.056-.083zm-.017-.968a.867.867 0 00-.467.127c-.076.045-.084.07-.05.158.034.087.07.173.115.254.064.117.09.125.21.077a.657.657 0 01.336-.053c.202.022.357.136.504.264l.092.077c.007-.006.014-.013.022-.018-.019-.105-.035-.226-.149-.264-.157-.053-.324-.075-.508-.117l-.24-.005c.24-.169.452-.044.687.009-.063-.115-.153-.147-.23-.193-.082-.05-.17-.092-.25-.144-.06-.037-.12-.08-.072-.172zm10.233.325c-.23-.01-.427.08-.608.211-.034.026-.06.065-.105.117.087.026.15.046.232.065.044-.015.088-.03.13-.046.306-.114.61-.115.904.031.126.063.237.04.366-.005-.02-.031-.03-.054-.045-.071a.986.986 0 00-.448-.273c-.14-.044-.284-.024-.426-.03zM7.99 6.483a.308.308 0 00.002.133c.08.321.156.643.242.962.104.387.27.75.456 1.103.02.037.061.08.098.087a.404.404 0 00.253-.051l-.472-.84c-.23-.448-.405-.92-.579-1.394zM10.397.497c-.2-.008-.405.004-.603.034-.236.035-.47.087-.7.152-.287.08-.569.18-.852.273-.04.013-.074.038-.11.058.028.014.05.018.07.014.287-.068.58-.085.873-.09.134-.002.269.009.402.025.19.024.382.048.57.09.456.104.874.3 1.265.556.464.306.888.66 1.257 1.078.205.232.395.475.56.739.17.274.315.561.449.856.273.601.456 1.232.6 1.876.04.173.07.348.1.524.017.104.065.167.17.19.122.028.2.105.22.251-.003.102-.06.174-.129.24a1.065 1.065 0 00-.268.358.164.164 0 00.083-.039c.08-.086.162-.172.235-.265a.56.56 0 00.13-.333c.009-.05.022-.1.024-.15.007-.124-.017-.15-.143-.168-.025-.004-.049-.014-.073-.015-.082-.007-.125-.063-.137-.131-.033-.198-.004-.355.247-.408.086-.018.174-.03.26-.042.158-.023.315-.053.473-.067.14-.012.19.033.226.167.008.029.018.057.021.087.019.179-.008.225-.141.288-.027.013-.055.024-.078.042a.148.148 0 00-.051.067c-.039.144.073.382.206.445l.673.32c.023.011.05.015.075.023l.018-.026c-.015-.008-.032-.013-.044-.024a2.27 2.27 0 00-.544-.32 4.898 4.898 0 00-.173-.075.203.203 0 01-.126-.191c-.003-.085.045-.154.128-.187l.059-.025c.099-.044.118-.076.112-.187a.384.384 0 00-.008-.063c-.067-.294-.123-.59-.205-.88a9.478 9.478 0 00-.826-2.036 7.465 7.465 0 00-1.39-1.805 4.536 4.536 0 00-1.177-.824 3.656 3.656 0 00-1.016-.328 6.155 6.155 0 00-.712-.074zm6.719 5.955c.01.014.018.028.038.034l-.022-.044-.016.01zM4.103 3.917a.062.062 0 01-.03.012.455.455 0 01-.04.039c-.01.01-.02.02-.045.04l-.363.354c-.088.085-.17.178-.266.253-.284.22-.425.53-.544.855a.132.132 0 00-.007.071c.013.055.033.108.052.168l.074.026c-.017.056-.03.105-.047.152-.058.164-.118.327-.175.491-.005.015.008.036.019.077.08-.175.158-.33.225-.489.228-.544.484-1.074.819-1.561.09-.133.182-.266.283-.401.004-.006.007-.013.022-.03.001-.016.003-.032.015-.04l.008-.017zm12.976 2.408a.023.023 0 01.009.019.073.073 0 00-.006.01.188.188 0 00.007.02l.018.022c.002-.007.007-.016.005-.021-.003-.01-.012-.018-.02-.038a1.331 1.331 0 01-.013-.012zM4.199 4.48c-.003.004-.008.008-.027.014-.005.013-.011.025-.031.047a2.085 2.085 0 01-.124.167c-.048.07-.116.055-.181.041-.134-.028-.228.016-.287.143-.089.187-.187.37-.273.56-.049.108-.11.216-.118.36.081.003.154.007.228.008h.228a2.563 2.563 0 01-.079.264c-.01.052-.022.103-.033.155l.02.004c.018-.046.037-.092.067-.153.066-.142.13-.285.2-.426.02-.04.034-.1.116-.092 0 .043.004.084 0 .124-.005.045-.017.09-.028.143.141.043.086.174.115.269.102-.022.104-.195.248-.144v.205l.017.002.439-1.059c-.13 0-.246-.02-.358.033-.024.011-.058-.001-.108-.004.075-.15.139-.278.211-.417a.128.128 0 01.025-.036c0-.015-.001-.03.008-.038l.006-.02c-.005.006-.01.011-.028.017-.004.012-.009.024-.026.045a.085.085 0 01-.032.033c-.123.157-.09.164-.258.106-.079-.027-.078-.028-.047-.144.028-.046.056-.093.098-.15 0-.016-.001-.032.007-.042L4.2 4.48zm2.073-.67c-.003.006-.007.011-.027.016-.094.125-.194.246-.28.377-.155.238-.301.481-.451.723-.14.224-.345.368-.575.481-.017.008-.04.006-.079.011.012-.059.016-.109.033-.153a6.076 6.076 0 01.229-.518l-.007-.02a.138.138 0 01-.035.025c-.028.05-.055.1-.093.164-.26.424-.443.817-.442.95.024.004.048.011.073.013.177.013.188.007.26-.165.03-.07.077-.12.147-.15l.175-.07c.044-.018.085-.057.146-.032.003.05-.01.11.014.145.042.062.044.125.047.193.002.049.017.098.026.147.029-.034.039-.065.05-.097.142-.39.277-.782.428-1.17.1-.256.22-.504.33-.756.013-.03.013-.067.03-.092V3.81zm3.987-.34c0 .045.01.084.021.123.042.16.094.318.124.48.024.133.023.27.028.406 0 .033-.019.067-.032.11-.094-.058-.047-.158-.106-.215h-.125c-.015.072-.01.152-.046.2-.066.085-.155.154-.236.227-.043.038-.078.018-.103-.025l-.046-.087c-.065.035-.117.069-.172.093-.116.051-.235.095-.35.147-.085.038-.09.053-.07.147.014.075.034.148.047.223.013.072.05.109.123.124.233.05.462.115.657.265.058-.102.058-.102.168-.151.03-.014.06-.03.092-.042.08-.03.115-.017.15.06.023.048.041.098.066.158.06-.14-.042-.267.017-.416.157.18.24.39.375.567a.235.235 0 00.022-.098c.002-.124 0-.247.002-.371 0-.034.013-.067.02-.1l.032-.003c.11.155.13.354.226.52a3.036 3.036 0 00-.01-.392c-.004-.045 0-.074.05-.088.08.036.116.14.215.158-.03-.275-.423-1.137-.798-1.635-.114-.127-.2-.28-.34-.386zm-2.667.696c-.019.034-.03.05-.037.067-.061.185-.125.37-.18.556-.031.105-.087.169-.195.19-.09.019-.178.052-.268.073-.038.009-.089.015-.118-.003-.024-.016-.025-.069-.036-.106-.064.076-.082.087-.17.047-.133-.062-.262-.135-.393-.201-.048-.025-.093-.063-.17-.03-.043.12-.091.25-.137.382-.099.28-.087.242.095.453.046.048.102.03.154.023.054-.009.106-.03.16-.036.13-.013.26-.08.367-.015.204-.064.387-.122.571-.178.05-.015.089.005.114.054.022.042.034.093.082.121.038-.056-.013-.128.063-.178l.14.241-.042-1.46zm.278.358c-.096-.01-.107.01-.11.108-.002.038-.003.078.002.115.03.2.099.386.174.57.002.006.012.01.022.015l.078-.05c.052.036.081.088.153.088.205-.002.41.014.616.012.099-.001.158.042.205.12.018.03.024.077.088.066l-.08-.394c-.05-.195-.085-.395-.172-.589-.057.057-.114.068-.18.046a.72.72 0 00-.135-.028c-.22-.028-.44-.059-.66-.08zm10.254-1.727c.089.163.155.316.139.491-.016.168.026.342-.044.516-.047-.033-.088-.082-.112-.075-.117.035-.164-.057-.227-.115a4.772 4.772 0 01-.286-.29l-.104-.113a4.856 4.856 0 01-.023.019c.035.046.07.093.11.156.04.064.084.127.122.193.034.058.065.118.031.205-.082-.01-.164-.019-.246-.032-.06-.01-.101 0-.124.07-.031.098-.037.096-.15.09.02.042.036.08.057.116.041.074.03.138-.03.196-.06.06-.118.122-.178.181a.175.175 0 01-.185.046c-.222-.061-.447-.113-.67-.174-.032-.009-.063-.04-.086-.068-.03-.04-.052-.087-.08-.13-.044-.07-.09-.138-.136-.207a.18.18 0 00-.014.105c.012.127.03.253.035.38.005.1-.024.12-.121.104-.104-.017-.206-.04-.31-.058-.064-.012-.131-.028-.202.03l.081.208c.09 0 .166-.01.237.002a.819.819 0 01.458.251c.078.083.154.168.241.26l.018-.005c-.004-.006-.008-.013-.01-.04.014-.056-.062-.118.018-.178.031.03.064.057.088.09.058.078.111.159.169.257l.089.141.024-.013a2093.819 2093.819 0 01-.427-.934c.055.007.083.007.108.016.193.07.385.142.577.216.074.028.147.06.219.094.062.028.112.018.157-.033.05-.056.102-.112.154-.167.05-.051.095-.046.132.014.016.025.026.053.04.08.071.138.143.277.217.433l.159.308.025-.011c-.044-.106-.07-.218-.138-.334-.057-.182-.168-.346-.206-.545.136.034.362.326.567.732l.057.074.018-.011a1.563 1.563 0 01-.052-.127c-.046-.145-.097-.29-.136-.436-.022-.083-.036-.173.022-.26l.109.058-.026-.207.027-.016c.022.02.05.036.065.06.073.108.143.22.215.33.01.016.029.029.043.043-.036-.217-.2-.38-.229-.626l.155.112c.014-.166.012-.319.042-.465.032-.158-.023-.297-.063-.445.024.004.036.006.055.025.092.124.183.249.277.371.02.027.05.047.069.087l.04.063.019-.015a.293.293 0 01-.053-.082 27.922 27.922 0 01-.332-.49c-.221-.311-.363-.467-.485-.521zm-6.57.327c-.003.161.092.275.069.415l-.368.087c.09.139.032.237-.052.331-.05.057-.092.122-.143.178-.037.04-.046.078-.018.126l.16.275c.029.048.072.066.128.064.076-.003.152 0 .228-.001.116-.003.216.022.275.137.006.014.02.024.044.052.004-.059-.003-.098.01-.13.016-.04.04-.099.072-.108.084-.023.173-.024.26-.03.013-.001.027.018.04.029l.071.065c.019-.11-.082-.198-.024-.31l.126.04c-.026-.123-.07-.245-.071-.366 0-.123.051-.243.115-.36.107.062.16.156.234.253.183.265.36.533.494.834.165-.078.27.068.407.088-.003-.106-.133-.441-.197-.492a.142.142 0 00-.102-.028c-.06.011-.119.039-.191.063-.025-.039-.056-.078-.077-.122a3.936 3.936 0 00-.473-.783c-.076-.094-.16-.182-.228-.26l-.391.285c-.049.035-.094.03-.132-.017l-.169-.207c-.025-.03-.053-.059-.097-.108z"></path></svg>
plugins/_migrate_agents/webui/assets/openclaw.svg
new
+1
@@ -0,0 +1 @@
1
+<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenClaw</title><path d="M12 2.568c-6.33 0-9.495 5.275-9.495 9.495 0 4.22 3.165 8.44 6.33 9.494v2.11h2.11v-2.11s1.055.422 2.11 0v2.11h2.11v-2.11c3.165-1.055 6.33-5.274 6.33-9.494S18.33 2.568 12 2.568z" fill="url(#lobe-icons-open-claw-0-_R_0_)"></path><path d="M3.56 9.953C.396 8.898-.66 11.008.396 13.118c1.055 2.11 3.164 1.055 4.22-1.055.632-1.477 0-2.11-1.056-2.11z" fill="url(#lobe-icons-open-claw-1-_R_0_)"></path><path d="M20.44 9.953c3.164-1.055 4.22 1.055 3.164 3.165-1.055 2.11-3.164 1.055-4.22-1.055-.632-1.477 0-2.11 1.056-2.11z" fill="url(#lobe-icons-open-claw-2-_R_0_)"></path><path d="M5.507 1.875c.476-.285 1.036-.233 1.615.037.577.27 1.223.774 1.937 1.488a.316.316 0 01-.447.447c-.693-.693-1.279-1.138-1.757-1.361-.475-.222-.795-.205-1.022-.069a.317.317 0 01-.326-.542zM16.877 1.913c.58-.27 1.14-.323 1.616-.038a.317.317 0 01-.326.542c-.227-.136-.547-.153-1.022.069-.478.223-1.064.668-1.756 1.361a.316.316 0 11-.448-.447c.714-.714 1.36-1.218 1.936-1.487z" fill="#FF4D4D"></path><path d="M8.835 9.109a1.266 1.266 0 100-2.532 1.266 1.266 0 000 2.532zM15.165 9.109a1.266 1.266 0 100-2.532 1.266 1.266 0 000 2.532z" fill="#050810"></path><path d="M9.046 8.16a.527.527 0 100-1.056.527.527 0 000 1.055zM15.376 8.16a.527.527 0 100-1.055.527.527 0 000 1.054z" fill="#00E5CC"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-open-claw-0-_R_0_" x1="-.659" x2="27.023" y1=".458" y2="22.855"><stop stop-color="#FF4D4D"></stop><stop offset="1" stop-color="#991B1B"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-open-claw-1-_R_0_" x1="0" x2="4.311" y1="9.672" y2="14.949"><stop stop-color="#FF4D4D"></stop><stop offset="1" stop-color="#991B1B"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-open-claw-2-_R_0_" x1="19.385" x2="24.399" y1="9.953" y2="14.462"><stop stop-color="#FF4D4D"></stop><stop offset="1" stop-color="#991B1B"></stop></linearGradient></defs></svg>
plugins/_migrate_agents/webui/assets/opencode.svg
new
+1
@@ -0,0 +1 @@
1
+<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>opencode</title><path d="M16 6H8v12h8V6zm4 16H4V2h16v20z"></path></svg>
plugins/_migrate_agents/webui/main.html
new
+261
@@ -0,0 +1,261 @@
1
+<!doctype html>
2
+<html lang="en">
3
+<head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Migrate Agents</title>
7
+ <script type="module" src="/plugins/_migrate_agents/webui/migrate-agents-store.js"></script>
8
+</head>
9
+<body>
10
+ <div x-data>
11
+ <template x-if="$store.migrationParty">
12
+ <main class="mp-shell"
13
+ x-create="$store.migrationParty.onOpen()"
14
+ x-destroy="$store.migrationParty.cleanup()">
15
+ <header class="mp-intro">
16
+ <p class="mp-kicker">Import from another agent</p>
17
+ <h1>Move your work into Agent Zero</h1>
18
+ <p class="mp-lede">Bring over chats, projects, memories, instructions, and skills from OpenClaw, Hermes Agent, OpenCode, Claude Code, or Codex. You will review what was found before anything is copied.</p>
19
+ <div class="mp-safety"><x-icon name="shield_lock"></x-icon><span>Credentials, authentication files, and hidden reasoning are excluded automatically.</span></div>
20
+ </header>
21
+
22
+ <section class="mp-section" aria-labelledby="mp-source-title">
23
+ <div class="mp-section-head">
24
+ <div><span class="mp-step">01</span><h2 id="mp-source-title">Choose the app you are importing from</h2></div>
25
+ </div>
26
+ <div class="mp-sources" role="radiogroup" aria-labelledby="mp-source-title">
27
+ <template x-for="source in $store.migrationParty.sources" :key="source.id">
28
+ <button type="button"
29
+ class="mp-source"
30
+ :class="[$store.migrationParty.source === source.id && 'is-selected', `is-${source.accent}`]"
31
+ @click="$store.migrationParty.chooseSource(source.id)"
32
+ role="radio"
33
+ :aria-checked="$store.migrationParty.source === source.id">
34
+ <span class="mp-source-icon"><img :src="source.logo" alt="" aria-hidden="true"></span>
35
+ <span class="mp-source-copy"><strong x-text="source.name"></strong><small x-text="source.hint"></small></span>
36
+ <span class="mp-radio"><span></span></span>
37
+ </button>
38
+ </template>
39
+ </div>
40
+ </section>
41
+
42
+ <section class="mp-section" aria-labelledby="mp-prepare-title">
43
+ <div class="mp-section-head">
44
+ <div><span class="mp-step">02</span><h2 id="mp-prepare-title">Prepare your export</h2></div>
45
+ </div>
46
+
47
+ <div class="mp-export-guide">
48
+ <div class="mp-guide-head">
49
+ <img :src="$store.migrationParty.selectedSource.logo" alt="" aria-hidden="true">
50
+ <div><strong x-text="$store.migrationParty.selectedSource.guideTitle"></strong><span x-text="$store.migrationParty.selectedSource.guideLabel"></span></div>
51
+ </div>
52
+ <div class="mp-command">
53
+ <code x-text="$store.migrationParty.selectedSource.command"></code>
54
+ <button type="button" class="mp-copy" @click="$store.migrationParty.copyExportValue()" :aria-label="`Copy ${$store.migrationParty.selectedSource.guideLabel.toLowerCase()}`">
55
+ <x-icon :name="$store.migrationParty.copied ? 'check' : 'content_copy'"></x-icon>
56
+ <span x-text="$store.migrationParty.copied ? 'Copied' : 'Copy'"></span>
57
+ </button>
58
+ </div>
59
+ <p x-text="$store.migrationParty.selectedSource.guideNote"></p>
60
+ </div>
61
+ </section>
62
+
63
+ <section class="mp-section" aria-labelledby="mp-export-title">
64
+ <div class="mp-section-head">
65
+ <div><span class="mp-step">03</span><h2 id="mp-export-title">Select the export</h2></div>
66
+ <span class="mp-muted">Files stay on this Agent Zero instance</span>
67
+ </div>
68
+
69
+ <div class="mp-drop"
70
+ :class="$store.migrationParty.dragActive && 'is-dragging'"
71
+ @dragenter.prevent="$store.migrationParty.dragActive = true"
72
+ @dragover.prevent="$store.migrationParty.dragActive = true"
73
+ @dragleave.prevent="$store.migrationParty.dragActive = false"
74
+ @drop.prevent="$store.migrationParty.onDrop($event)">
75
+ <div class="mp-drop-icon"><x-icon name="move_to_inbox"></x-icon></div>
76
+ <strong>Drag the export here, or choose it from your computer</strong>
77
+ <span>Files, ZIP or TAR archives, and complete folders are supported · 256 MiB expanded limit</span>
78
+ <div class="mp-drop-actions">
79
+ <label class="btn btn-ok mp-file-button">
80
+ <x-icon name="attach_file"></x-icon> Choose files
81
+ <input type="file" multiple accept=".json,.jsonl,.db,.sqlite,.sqlite3,.zip,.tar,.gz,.tgz,.md" @change="$store.migrationParty.handleFiles($event)">
82
+ </label>
83
+ <label class="btn btn-cancel mp-file-button">
84
+ <x-icon name="folder_open"></x-icon> Choose folder
85
+ <input type="file" multiple webkitdirectory @change="$store.migrationParty.handleFiles($event)">
86
+ </label>
87
+ </div>
88
+ </div>
89
+
90
+ <template x-if="$store.migrationParty.files.length">
91
+ <div class="mp-files">
92
+ <div class="mp-files-head">
93
+ <span><strong x-text="$store.migrationParty.files.length"></strong> selected · <span x-text="$store.migrationParty.formatBytes($store.migrationParty.totalBytes)"></span></span>
94
+ <button class="text-button" type="button" @click="$store.migrationParty.clearFiles()">Clear</button>
95
+ </div>
96
+ <div class="mp-file-list">
97
+ <template x-for="(file, index) in $store.migrationParty.files.slice(0, 8)" :key="`${file.webkitRelativePath || file.name}:${file.size}`">
98
+ <div class="mp-file-row">
99
+ <x-icon name="draft"></x-icon>
100
+ <span x-text="file.webkitRelativePath || file.name"></span>
101
+ <small x-text="$store.migrationParty.formatBytes(file.size)"></small>
102
+ <button type="button" @click="$store.migrationParty.removeFile(index)" :aria-label="`Remove ${file.name}`"><x-icon name="close"></x-icon></button>
103
+ </div>
104
+ </template>
105
+ <div class="mp-more" x-show="$store.migrationParty.files.length > 8" x-text="`+ ${$store.migrationParty.files.length - 8} more files`"></div>
106
+ </div>
107
+ </div>
108
+ </template>
109
+ </section>
110
+
111
+ <template x-if="$store.migrationParty.preview">
112
+ <section class="mp-section mp-review" aria-labelledby="mp-review-title">
113
+ <div class="mp-section-head">
114
+ <div><span class="mp-step">04</span><h2 id="mp-review-title">Review what will be imported</h2></div>
115
+ <span class="mp-ready"><x-icon name="check_circle"></x-icon> Export checked</span>
116
+ </div>
117
+
118
+ <div class="mp-stats">
119
+ <div><strong x-text="$store.migrationParty.preview.summary.chats"></strong><span>Chats</span></div>
120
+ <div><strong x-text="$store.migrationParty.preview.summary.projects"></strong><span>Projects</span></div>
121
+ <div><strong x-text="$store.migrationParty.preview.summary.memories"></strong><span>Memories</span></div>
122
+ <div><strong x-text="$store.migrationParty.preview.summary.instructions"></strong><span>Instructions</span></div>
123
+ <div><strong x-text="$store.migrationParty.preview.summary.skills"></strong><span>Skills</span></div>
124
+ </div>
125
+
126
+ <div class="mp-review-grid">
127
+ <div class="mp-manifest">
128
+ <h3>Found in this export</h3>
129
+ <template x-if="$store.migrationParty.preview.projects.length">
130
+ <div class="mp-project-list">
131
+ <template x-for="project in $store.migrationParty.preview.projects.slice(0, 8)" :key="project.id">
132
+ <div><x-icon name="folder_open"></x-icon><span><strong x-text="project.title"></strong><small x-text="project.path"></small></span><em x-text="`${project.chats} chats`"></em></div>
133
+ </template>
134
+ </div>
135
+ </template>
136
+ <template x-if="$store.migrationParty.preview.chats.length">
137
+ <div class="mp-chat-list">
138
+ <template x-for="chat in $store.migrationParty.preview.chats.slice(0, 12)" :key="chat.id">
139
+ <div><x-icon name="forum"></x-icon><span x-text="chat.title"></span><small x-text="`${chat.messages} events`"></small></div>
140
+ </template>
141
+ </div>
142
+ </template>
143
+ <p class="mp-empty" x-show="!$store.migrationParty.preview.projects.length && !$store.migrationParty.preview.chats.length">No projects or chats were found. Other selected data can still be imported.</p>
144
+ </div>
145
+
146
+ <div class="mp-options">
147
+ <h3>Choose what to import</h3>
148
+ <label :class="!$store.migrationParty.preview.summary.chats && 'is-empty'"><input type="checkbox" x-model="$store.migrationParty.includeChats" :disabled="!$store.migrationParty.preview.summary.chats"><span><strong>Chats</strong><small>Messages and inert historical tool records</small></span><em x-text="$store.migrationParty.preview.summary.chats"></em></label>
149
+ <label :class="!$store.migrationParty.preview.summary.projects && 'is-empty'"><input type="checkbox" x-model="$store.migrationParty.includeProjects" :disabled="!$store.migrationParty.preview.summary.projects"><span><strong>Projects</strong><small>Native projects from retained workspace context</small></span><em x-text="$store.migrationParty.preview.summary.projects"></em></label>
150
+ <label :class="!$store.migrationParty.preview.summary.memories && 'is-empty'"><input type="checkbox" x-model="$store.migrationParty.includeMemories" :disabled="!$store.migrationParty.preview.summary.memories"><span><strong>Memories</strong><small>Markdown memory with source provenance</small></span><em x-text="$store.migrationParty.preview.summary.memories"></em></label>
151
+ <label :class="!$store.migrationParty.preview.summary.instructions && 'is-empty'"><input type="checkbox" x-model="$store.migrationParty.includeInstructions" :disabled="!$store.migrationParty.preview.summary.instructions"><span><strong>Instructions</strong><small>AGENTS.md, CLAUDE.md, and related files</small></span><em x-text="$store.migrationParty.preview.summary.instructions"></em></label>
152
+ <label :class="!$store.migrationParty.preview.summary.skills && 'is-empty'"><input type="checkbox" x-model="$store.migrationParty.includeSkills" :disabled="!$store.migrationParty.preview.summary.skills"><span><strong>Skills</strong><small>Complete skill folders under a source namespace</small></span><em x-text="$store.migrationParty.preview.summary.skills"></em></label>
153
+ </div>
154
+ </div>
155
+
156
+ <div class="mp-boundary">
157
+ <x-icon name="shield_lock"></x-icon>
158
+ <div><strong>Always excluded</strong><p>Provider keys, credentials, authentication state, channel connections, schedules, hidden reasoning, and running services are not imported. Historical tool records are saved for reference and never replayed.</p><small x-show="$store.migrationParty.preview.summary.redactions" x-text="`${$store.migrationParty.preview.summary.redactions} sensitive value(s) were removed from imported text.`"></small></div>
159
+ </div>
160
+
161
+ <details class="mp-details" x-show="$store.migrationParty.preview.excluded.length || $store.migrationParty.preview.warnings.length">
162
+ <summary>Review exclusions and warnings <span x-text="`(${$store.migrationParty.preview.excluded.length + $store.migrationParty.preview.warnings.length})`"></span></summary>
163
+ <ul>
164
+ <template x-for="item in [...$store.migrationParty.preview.excluded, ...$store.migrationParty.preview.warnings]" :key="item"><li x-text="item"></li></template>
165
+ </ul>
166
+ </details>
167
+
168
+ <label class="mp-consent">
169
+ <input type="checkbox" x-model="$store.migrationParty.reviewed">
170
+ <span>I reviewed the preview and want to import the selected items.</span>
171
+ </label>
172
+ </section>
173
+ </template>
174
+
175
+ <footer class="modal-footer mp-footer" data-modal-footer>
176
+ <button class="btn btn-ok" type="button" @click="$store.migrationParty.primaryAction()" :disabled="$store.migrationParty.primaryDisabled">
177
+ <x-icon :name="$store.migrationParty.busy ? 'progress_activity' : ($store.migrationParty.preview ? 'move_to_inbox' : 'manage_search')" :class="$store.migrationParty.busy && 'mp-spin'"></x-icon>
178
+ <span x-text="$store.migrationParty.primaryLabel"></span>
179
+ </button>
180
+ <button class="btn btn-cancel" type="button" @click="$store.migrationParty.close()" :disabled="$store.migrationParty.busy">Cancel</button>
181
+ </footer>
182
+ </main>
183
+ </template>
184
+ </div>
185
+
186
+ <style>
187
+ .mp-shell { color: var(--color-text); padding: 0 var(--spacing-lg) var(--spacing-lg); }
188
+ .mp-intro { padding: 1.4rem 0 1.2rem; border-bottom: 1px solid var(--color-border); }
189
+ .mp-kicker { margin: 0 0 .35rem; color: var(--color-primary); font-size: .72rem; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; }
190
+ .mp-intro h1 { margin: 0; font-size: clamp(1.55rem, 3vw, 2rem); line-height: 1.12; letter-spacing: -.025em; }
191
+ .mp-lede { max-width: 52rem; margin: .65rem 0 0; color: var(--color-text-secondary); font-size: .9rem; line-height: 1.55; }
192
+ .mp-safety { display: flex; align-items: flex-start; gap: .5rem; max-width: 52rem; margin-top: .9rem; color: var(--color-text-secondary); font-size: .76rem; line-height: 1.4; }
193
+ .mp-safety x-icon { flex: 0 0 auto; color: var(--color-success); }
194
+ .mp-section { margin-top: 1.45rem; }
195
+ .mp-section-head, .mp-section-head > div { display: flex; align-items: center; gap: .7rem; }
196
+ .mp-section-head { justify-content: space-between; margin-bottom: .75rem; }
197
+ .mp-section-head h2 { margin: 0; font-size: 1rem; letter-spacing: -.01em; }
198
+ .mp-step { display: grid; place-items: center; width: 1.75rem; height: 1.75rem; flex: 0 0 auto; border: 1px solid var(--color-border); border-radius: .48rem; color: var(--color-text-secondary); font-size: .66rem; font-weight: 750; }
199
+ .mp-muted { color: var(--color-text-secondary); font-size: .72rem; }
200
+ .mp-sources { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; }
201
+ .mp-source { --mp-accent: var(--color-primary); min-width: 0; min-height: 4.4rem; padding: .8rem; border: 1px solid var(--color-border); border-radius: .8rem; display: grid; grid-template-columns: auto 1fr auto; gap: .7rem; align-items: center; color: inherit; background: var(--color-bg); text-align: left; cursor: pointer; transition: border-color var(--transition-speed), background var(--transition-speed); }
202
+ .mp-source:hover { background: var(--color-bg-secondary); }
203
+ .mp-source:focus-visible, .mp-copy:focus-visible, .mp-file-row button:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
204
+ .mp-source.is-selected { border-color: color-mix(in srgb, var(--mp-accent) 72%, var(--color-border)); background: color-mix(in srgb, var(--mp-accent) 7%, var(--color-bg)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mp-accent) 16%, transparent); }
205
+ .mp-source.is-coral { --mp-accent: #ff5a57; } .mp-source.is-gold { --mp-accent: #d8a62e; } .mp-source.is-mint { --mp-accent: #42b997; } .mp-source.is-violet { --mp-accent: #d97757; } .mp-source.is-sky { --mp-accent: #7a9dff; }
206
+ .mp-source-icon, .mp-guide-head > img { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; box-sizing: border-box; padding: .48rem; border: 1px solid color-mix(in srgb, var(--mp-accent) 20%, var(--color-border)); border-radius: .68rem; background: #fff; object-fit: contain; }
207
+ .mp-source-icon img { width: 100%; height: 100%; object-fit: contain; }
208
+ .mp-source-copy { min-width: 0; display: flex; flex-direction: column; gap: .22rem; }
209
+ .mp-source-copy strong { font-size: .84rem; }
210
+ .mp-source-copy small { color: var(--color-text-secondary); font-size: .69rem; line-height: 1.35; }
211
+ .mp-radio { width: .95rem; height: .95rem; border: 1px solid var(--color-border); border-radius: 50%; display: grid; place-items: center; }
212
+ .is-selected .mp-radio { border-color: var(--mp-accent); } .is-selected .mp-radio span { width: .5rem; height: .5rem; border-radius: 50%; background: var(--mp-accent); }
213
+ .mp-export-guide { padding: .85rem; border: 1px solid var(--color-border); border-radius: .85rem; background: var(--color-bg-secondary); }
214
+ .mp-guide-head { display: flex; align-items: center; gap: .65rem; margin-bottom: .7rem; }
215
+ .mp-guide-head > img { width: 2.2rem; height: 2.2rem; padding: .42rem; }
216
+ .mp-guide-head > div { display: flex; flex-direction: column; gap: .1rem; }
217
+ .mp-guide-head strong { font-size: .82rem; } .mp-guide-head span { color: var(--color-text-secondary); font-size: .69rem; }
218
+ .mp-command { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .45rem; align-items: stretch; }
219
+ .mp-command code { min-width: 0; padding: .72rem .8rem; border: 1px solid var(--color-border); border-radius: .58rem; color: var(--color-text); background: var(--color-bg); overflow-wrap: anywhere; white-space: normal; font-size: .75rem; line-height: 1.45; }
220
+ .mp-copy { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; min-width: 5.4rem; padding: 0 .75rem; border: 1px solid var(--color-border); border-radius: .58rem; color: var(--color-text); background: var(--color-bg); cursor: pointer; font-size: .72rem; font-weight: 650; }
221
+ .mp-copy:hover { border-color: var(--color-primary); }
222
+ .mp-export-guide > p { margin: .6rem .1rem 0; color: var(--color-text-secondary); font-size: .71rem; line-height: 1.45; }
223
+ .mp-drop { padding: 1.35rem; border: 1px dashed color-mix(in srgb, var(--color-text-secondary) 45%, transparent); border-radius: .85rem; display: flex; flex-direction: column; align-items: center; gap: .45rem; background: color-mix(in srgb, var(--color-bg-secondary) 70%, transparent); text-align: center; transition: border-color var(--transition-speed), background var(--transition-speed); }
224
+ .mp-drop.is-dragging { border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg)); }
225
+ .mp-drop-icon { width: 3rem; height: 3rem; display: grid; place-items: center; border-radius: .8rem; color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
226
+ .mp-drop-icon x-icon { font-size: 1.5rem; } .mp-drop > strong { font-size: .84rem; } .mp-drop > span { max-width: 38rem; color: var(--color-text-secondary); font-size: .71rem; line-height: 1.4; }
227
+ .mp-drop-actions { display: flex; gap: .55rem; margin-top: .45rem; }
228
+ .mp-file-button { position: relative; display: inline-flex; align-items: center; justify-content: center; gap: .35rem; cursor: pointer; } .mp-file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
229
+ .mp-files { margin-top: .65rem; padding: .7rem; border: 1px solid var(--color-border); border-radius: .75rem; background: var(--color-bg); }
230
+ .mp-files-head { display: flex; justify-content: space-between; align-items: center; padding: 0 .2rem .55rem; color: var(--color-text-secondary); font-size: .72rem; }
231
+ .mp-file-list { display: grid; gap: .25rem; max-height: 12rem; overflow: auto; }
232
+ .mp-file-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: .5rem; align-items: center; padding: .42rem .5rem; border-radius: .42rem; background: var(--color-bg-secondary); font-size: .72rem; }
233
+ .mp-file-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mp-file-row small { color: var(--color-text-secondary); } .mp-file-row button { border: 0; padding: 0; color: var(--color-text-secondary); background: none; cursor: pointer; }
234
+ .mp-more { padding: .4rem; color: var(--color-text-secondary); text-align: center; font-size: .7rem; }
235
+ .mp-review { padding: var(--spacing-lg); border: 1px solid var(--color-border); border-radius: .9rem; background: color-mix(in srgb, var(--color-success) 3%, var(--color-bg)); }
236
+ .mp-ready { display: inline-flex; align-items: center; gap: .35rem; color: var(--color-success); font-size: .72rem; font-weight: 650; }
237
+ .mp-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: .5rem; margin-bottom: .75rem; }
238
+ .mp-stats > div { padding: .68rem; border: 1px solid var(--color-border); border-radius: .6rem; display: flex; flex-direction: column; background: var(--color-bg); } .mp-stats strong { font-size: 1.2rem; letter-spacing: -.035em; } .mp-stats span { color: var(--color-text-secondary); font-size: .62rem; text-transform: uppercase; letter-spacing: .07em; }
239
+ .mp-review-grid { display: grid; grid-template-columns: 1.05fr 1fr; gap: .7rem; }
240
+ .mp-manifest, .mp-options { padding: .8rem; border: 1px solid var(--color-border); border-radius: .68rem; background: var(--color-bg); }
241
+ .mp-manifest h3, .mp-options h3 { margin: 0 0 .6rem; font-size: .76rem; text-transform: uppercase; letter-spacing: .075em; }
242
+ .mp-project-list, .mp-chat-list { max-height: 12rem; overflow: auto; display: grid; gap: .26rem; }
243
+ .mp-project-list { margin-bottom: .35rem; }
244
+ .mp-project-list > div, .mp-chat-list > div { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: .48rem; align-items: center; padding: .42rem .46rem; border-radius: .4rem; background: var(--color-bg-secondary); font-size: .71rem; }
245
+ .mp-project-list > div > span { min-width: 0; display: flex; flex-direction: column; } .mp-project-list strong, .mp-project-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mp-project-list small, .mp-project-list em, .mp-chat-list small { color: var(--color-text-secondary); font-size: .64rem; font-style: normal; }
246
+ .mp-chat-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
247
+ .mp-options { display: grid; align-content: start; gap: .25rem; }
248
+ .mp-options label { display: grid; grid-template-columns: auto 1fr auto; gap: .5rem; align-items: center; padding: .45rem; border-radius: .42rem; cursor: pointer; } .mp-options label:hover { background: var(--color-bg-secondary); } .mp-options label.is-empty { opacity: .48; cursor: default; } .mp-options label span { display: flex; flex-direction: column; } .mp-options label strong { font-size: .73rem; } .mp-options label small { color: var(--color-text-secondary); font-size: .65rem; line-height: 1.35; } .mp-options em { min-width: 1.5rem; padding: .14rem .32rem; border-radius: 99px; color: var(--color-text-secondary); background: var(--color-bg-secondary); text-align: center; font-size: .64rem; font-style: normal; }
249
+ .mp-boundary { display: grid; grid-template-columns: auto 1fr; gap: .65rem; margin-top: .7rem; padding: .7rem; border: 1px solid color-mix(in srgb, var(--color-warning) 25%, var(--color-border)); border-radius: .62rem; background: color-mix(in srgb, var(--color-warning) 4%, var(--color-bg)); } .mp-boundary x-icon { color: var(--color-warning); } .mp-boundary strong { font-size: .73rem; } .mp-boundary p { margin: .12rem 0 0; color: var(--color-text-secondary); font-size: .68rem; line-height: 1.45; } .mp-boundary small { display: block; margin-top: .35rem; color: var(--color-warning); font-size: .66rem; }
250
+ .mp-details { margin-top: .6rem; color: var(--color-text-secondary); font-size: .7rem; } .mp-details summary { cursor: pointer; } .mp-details ul { max-height: 9rem; overflow: auto; padding-left: 1.2rem; }
251
+ .mp-consent { display: flex; gap: .5rem; align-items: center; margin-top: .7rem; font-size: .73rem; cursor: pointer; }
252
+ .mp-footer .btn { display: inline-flex; align-items: center; gap: .4rem; }
253
+ .mp-footer .btn:disabled { opacity: .48; cursor: not-allowed; filter: saturate(.35); }
254
+ .mp-spin { animation: mp-spin .8s linear infinite; } @keyframes mp-spin { to { transform: rotate(360deg); } }
255
+ .mp-empty { margin: .5rem 0; color: var(--color-text-secondary); font-size: .71rem; line-height: 1.4; }
256
+ @media (max-width: 800px) { .mp-stats { grid-template-columns: repeat(3, 1fr); } .mp-review-grid { grid-template-columns: 1fr; } }
257
+ @media (max-width: 560px) { .mp-shell { padding-inline: var(--spacing-sm); } .mp-intro { padding-top: 1rem; } .mp-intro h1 { font-size: 1.45rem; } .mp-lede { font-size: .82rem; } .mp-sources { grid-template-columns: 1fr; } .mp-section-head { align-items: flex-start; } .mp-muted { display: none; } .mp-command { grid-template-columns: 1fr; } .mp-copy { min-height: 2.35rem; } .mp-stats { grid-template-columns: repeat(2, 1fr); } .mp-drop { padding: 1rem; } .mp-drop-actions { flex-direction: column; width: 100%; } .mp-file-button { width: 100%; } .mp-file-row { grid-template-columns: auto minmax(0, 1fr) auto; } .mp-file-row small { display: none; } }
258
+ @media (prefers-reduced-motion: reduce) { .mp-source { transition: none; } .mp-spin { animation: none; } }
259
+ </style>
260
+</body>
261
+</html>
plugins/_migrate_agents/webui/migrate-agents-store.js
new
+301
@@ -0,0 +1,301 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+import { fetchApi } from "/js/api.js";
3
+import { closeModal } from "/js/modals.js";
4
+import { setContext } from "/index.js";
5
+import {
6
+ toastFrontendError,
7
+ toastFrontendInfo,
8
+ toastFrontendSuccess,
9
+ toastFrontendWarning,
10
+} from "/components/notifications/notification-store.js";
11
+
12
+const PREVIEW_API = "/api/plugins/_migrate_agents/migration_preview";
13
+const IMPORT_API = "/api/plugins/_migrate_agents/migration_import";
14
+
15
+export const sources = [
16
+ {
17
+ id: "openclaw",
18
+ name: "OpenClaw",
19
+ logo: "/plugins/_migrate_agents/webui/assets/openclaw.svg",
20
+ accent: "coral",
21
+ hint: "Chats, projects, memory, instructions and skills",
22
+ guideTitle: "Create an OpenClaw backup",
23
+ guideLabel: "Run in a terminal",
24
+ command: "openclaw backup create --verify",
25
+ guideNote: "Choose the generated .tar.gz file below. Credentials inside the backup are detected and excluded.",
26
+ },
27
+ {
28
+ id: "hermes",
29
+ name: "Hermes Agent",
30
+ logo: "/plugins/_migrate_agents/webui/assets/hermes.svg",
31
+ accent: "gold",
32
+ hint: "Chats, projects, memory, instructions and skills",
33
+ guideTitle: "Export your Hermes sessions",
34
+ guideLabel: "Run in a terminal",
35
+ command: "hermes sessions export backup.jsonl --redact",
36
+ guideNote: "To include memory and skills, also select the relevant files or folders from your Hermes data directory.",
37
+ },
38
+ {
39
+ id: "opencode",
40
+ name: "OpenCode",
41
+ logo: "/plugins/_migrate_agents/webui/assets/opencode.svg",
42
+ accent: "mint",
43
+ hint: "Chats, projects, AGENTS.md and skills",
44
+ guideTitle: "Export an OpenCode session",
45
+ guideLabel: "Run in a terminal",
46
+ command: "opencode export <session-id> > session.json",
47
+ guideNote: "Repeat for other sessions. You can also add AGENTS.md and skill folders when selecting files.",
48
+ },
49
+ {
50
+ id: "claude",
51
+ name: "Claude Code",
52
+ logo: "/plugins/_migrate_agents/webui/assets/claude.svg",
53
+ accent: "violet",
54
+ hint: "Chats, projects, CLAUDE.md, memory and skills",
55
+ guideTitle: "Find your Claude Code data",
56
+ guideLabel: "Folder to select",
57
+ command: "~/.claude/projects",
58
+ guideNote: "Add any CLAUDE.md, memory files, and skill folders you want to bring over.",
59
+ },
60
+ {
61
+ id: "codex",
62
+ name: "Codex",
63
+ logo: "/plugins/_migrate_agents/webui/assets/codex.svg",
64
+ accent: "sky",
65
+ hint: "Chats, projects, AGENTS.md, memory and skills",
66
+ guideTitle: "Find your Codex data",
67
+ guideLabel: "Folders to select",
68
+ command: "$CODEX_HOME/sessions and $CODEX_HOME/archived_sessions",
69
+ guideNote: "CODEX_HOME is usually ~/.codex. Add AGENTS.md, memory files, and skill folders separately if needed.",
70
+ },
71
+];
72
+
73
+async function responseJson(response) {
74
+ const text = await response.text();
75
+ if (!response.ok) throw new Error(text || `Request failed (${response.status})`);
76
+ return text ? JSON.parse(text) : {};
77
+}
78
+
79
+export const store = createStore("migrationParty", {
80
+ sources,
81
+ source: "openclaw",
82
+ files: [],
83
+ preview: null,
84
+ busy: false,
85
+ includeChats: true,
86
+ includeProjects: true,
87
+ includeMemories: true,
88
+ includeInstructions: true,
89
+ includeSkills: true,
90
+ reviewed: false,
91
+ dragActive: false,
92
+ copied: false,
93
+ copyTimer: null,
94
+
95
+ get selectedSource() {
96
+ return sources.find((item) => item.id === this.source) || sources[0];
97
+ },
98
+
99
+ get totalBytes() {
100
+ return this.files.reduce((total, file) => total + (file.size || 0), 0);
101
+ },
102
+
103
+ get canImport() {
104
+ return Boolean(
105
+ this.preview &&
106
+ this.reviewed &&
107
+ !this.busy &&
108
+ this.selectedItemCount > 0,
109
+ );
110
+ },
111
+
112
+ get selectedItemCount() {
113
+ const summary = this.preview?.summary || {};
114
+ return (
115
+ (this.includeChats ? summary.chats || 0 : 0) +
116
+ (this.includeProjects ? summary.projects || 0 : 0) +
117
+ (this.includeMemories ? summary.memories || 0 : 0) +
118
+ (this.includeInstructions ? summary.instructions || 0 : 0) +
119
+ (this.includeSkills ? summary.skills || 0 : 0)
120
+ );
121
+ },
122
+
123
+ get primaryDisabled() {
124
+ return this.busy || (this.preview ? !this.canImport : !this.files.length);
125
+ },
126
+
127
+ get primaryLabel() {
128
+ if (this.busy) return this.preview ? "Importing…" : "Checking export…";
129
+ if (this.preview) return "Import selected data";
130
+ return this.files.length ? "Review selected data" : "Select an export to continue";
131
+ },
132
+
133
+ onOpen() {
134
+ this.reset();
135
+ },
136
+
137
+ cleanup() {
138
+ this.dragActive = false;
139
+ if (this.copyTimer) clearTimeout(this.copyTimer);
140
+ },
141
+
142
+ reset() {
143
+ this.files = [];
144
+ this.preview = null;
145
+ this.reviewed = false;
146
+ this.busy = false;
147
+ this.dragActive = false;
148
+ this.copied = false;
149
+ this.includeChats = true;
150
+ this.includeProjects = true;
151
+ this.includeMemories = true;
152
+ this.includeInstructions = true;
153
+ this.includeSkills = true;
154
+ },
155
+
156
+ chooseSource(source) {
157
+ if (source === this.source) return;
158
+ this.source = source;
159
+ this.files = [];
160
+ this.preview = null;
161
+ this.reviewed = false;
162
+ this.copied = false;
163
+ },
164
+
165
+ acceptFiles(fileList) {
166
+ const incoming = Array.from(fileList || []);
167
+ if (!incoming.length) return;
168
+ const byKey = new Map(this.files.map((file) => [`${file.webkitRelativePath || file.name}:${file.size}`, file]));
169
+ for (const file of incoming) byKey.set(`${file.webkitRelativePath || file.name}:${file.size}`, file);
170
+ this.files = [...byKey.values()];
171
+ this.preview = null;
172
+ this.reviewed = false;
173
+ },
174
+
175
+ handleFiles(event) {
176
+ this.acceptFiles(event?.target?.files);
177
+ if (event?.target) event.target.value = "";
178
+ },
179
+
180
+ onDrop(event) {
181
+ this.dragActive = false;
182
+ this.acceptFiles(event?.dataTransfer?.files);
183
+ },
184
+
185
+ removeFile(index) {
186
+ this.files = this.files.filter((_, position) => position !== index);
187
+ this.preview = null;
188
+ this.reviewed = false;
189
+ },
190
+
191
+ clearFiles() {
192
+ this.files = [];
193
+ this.preview = null;
194
+ this.reviewed = false;
195
+ },
196
+
197
+ formatBytes(value) {
198
+ if (!value) return "0 B";
199
+ const units = ["B", "KiB", "MiB", "GiB"];
200
+ const power = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
201
+ return `${(value / 1024 ** power).toFixed(power ? 1 : 0)} ${units[power]}`;
202
+ },
203
+
204
+ async copyExportValue() {
205
+ try {
206
+ await navigator.clipboard.writeText(this.selectedSource.command);
207
+ this.copied = true;
208
+ if (this.copyTimer) clearTimeout(this.copyTimer);
209
+ this.copyTimer = setTimeout(() => { this.copied = false; }, 1600);
210
+ void toastFrontendInfo("Copied to the clipboard.", "Migrate Agents");
211
+ } catch {
212
+ void toastFrontendError("Could not copy. Select the text and copy it manually.", "Migrate Agents");
213
+ }
214
+ },
215
+
216
+ formData(includeOptions = false) {
217
+ const data = new FormData();
218
+ data.append("source", this.source);
219
+ for (const file of this.files) {
220
+ data.append("files[]", file, file.webkitRelativePath || file.name);
221
+ }
222
+ if (includeOptions) {
223
+ data.append("include_chats", String(this.includeChats));
224
+ data.append("include_projects", String(this.includeProjects));
225
+ data.append("include_memories", String(this.includeMemories));
226
+ data.append("include_instructions", String(this.includeInstructions));
227
+ data.append("include_skills", String(this.includeSkills));
228
+ }
229
+ return data;
230
+ },
231
+
232
+ async inspect() {
233
+ if (!this.files.length) {
234
+ void toastFrontendWarning("Choose an export file, archive, or folder first.", "Migrate Agents");
235
+ return;
236
+ }
237
+ this.busy = true;
238
+ this.preview = null;
239
+ this.reviewed = false;
240
+ try {
241
+ const response = await fetchApi(PREVIEW_API, {
242
+ method: "POST",
243
+ credentials: "same-origin",
244
+ body: this.formData(),
245
+ });
246
+ this.preview = await responseJson(response);
247
+ const found = this.preview?.summary || {};
248
+ this.includeChats = Boolean(found.chats);
249
+ this.includeProjects = Boolean(found.projects);
250
+ this.includeMemories = Boolean(found.memories);
251
+ this.includeInstructions = Boolean(found.instructions);
252
+ this.includeSkills = Boolean(found.skills);
253
+ void toastFrontendInfo(
254
+ `Found ${found.chats || 0} chats, ${found.projects || 0} projects, ${found.memories || 0} memories, ${found.instructions || 0} instructions, and ${found.skills || 0} skills.`,
255
+ "Migrate Agents",
256
+ );
257
+ if (this.preview?.warnings?.length) {
258
+ void toastFrontendWarning(`${this.preview.warnings.length} item(s) need review.`, "Migrate Agents");
259
+ }
260
+ } catch (error) {
261
+ void toastFrontendError(error instanceof Error ? error.message : String(error), "Migrate Agents");
262
+ } finally {
263
+ this.busy = false;
264
+ }
265
+ },
266
+
267
+ async migrate() {
268
+ if (!this.canImport) return;
269
+ this.busy = true;
270
+ try {
271
+ const response = await fetchApi(IMPORT_API, {
272
+ method: "POST",
273
+ credentials: "same-origin",
274
+ body: this.formData(true),
275
+ });
276
+ const result = await responseJson(response);
277
+ const summary = result.summary || {};
278
+ void toastFrontendSuccess(
279
+ `Imported ${summary.chats || 0} chats, ${summary.projects || 0} projects, ${summary.memories || 0} memories, ${summary.instructions || 0} instructions, and ${summary.skills || 0} skills.`,
280
+ "Migrate Agents",
281
+ );
282
+ if (result.warnings?.length) {
283
+ void toastFrontendWarning(`${result.warnings.length} item(s) were skipped with warnings.`, "Migrate Agents");
284
+ }
285
+ if (result.ctxids?.[0]) setContext(result.ctxids[0]);
286
+ closeModal("/plugins/_migrate_agents/webui/main.html");
287
+ } catch (error) {
288
+ void toastFrontendError(error instanceof Error ? error.message : String(error), "Migrate Agents");
289
+ } finally {
290
+ this.busy = false;
291
+ }
292
+ },
293
+
294
+ primaryAction() {
295
+ return this.preview ? this.migrate() : this.inspect();
296
+ },
297
+
298
+ close() {
299
+ closeModal("/plugins/_migrate_agents/webui/main.html");
300
+ },
301
+});
plugins/_migrate_agents/webui/thumbnail.webp
Binary files /dev/null and b/plugins/_migrate_agents/webui/thumbnail.webp differ