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 +![Migrate Agents plugin thumbnail](webui/thumbnail.webp)
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