sophhub 0.4.30 → 0.4.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sophhub",
3
- "version": "0.4.30",
3
+ "version": "0.4.31",
4
4
  "description": "SophHub CLI - Manage and download AI Agent skills and agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "restore-state",
3
+ "version": "1.0.0",
4
+ "types": ["store"],
5
+ "displayName": "状态恢复",
6
+ "description": "从 ZIP 备份文件恢复 SophClaw 完整状态。当用户要求恢复数据、还原备份、恢复账号或上传了备份 ZIP 文件时触发。支持恢复 agent 配置、会话历史、workspace 文件、语义记忆等全部状态。",
7
+ "changelog": [
8
+ {
9
+ "version": "1.0.0",
10
+ "date": "2026-05-27",
11
+ "changes": ["从 moltbot 迁移:ZIP 备份解析、openclaw.json 合并、会话恢复、workspace 恢复"]
12
+ }
13
+ ],
14
+ "createdAt": "2026-05-27",
15
+ "updatedAt": "2026-05-27"
16
+ }
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: restore-state
3
+ description: 从 ZIP 备份文件恢复 SophClaw 完整状态。当用户说要"恢复数据"、"还原备份"、"恢复我的账号"、"restore"并上传了 ZIP 文件时触发。支持恢复 agent 配置、会话历史、workspace 文件、语义记忆等全部状态。
4
+ ---
5
+
6
+ # Restore State
7
+
8
+ 从用户上传的 ZIP 备份文件恢复 SophClaw 完整状态到当前空白容器。
9
+
10
+ ## 前置检查
11
+
12
+ 在开始恢复之前,先确认以下几点:
13
+
14
+ 1. 用户已通过聊天上传了一个 `.zip` 文件(从 SophClaw 前端下载的备份包)
15
+ 2. 当前容器是空白状态(新创建的容器,不包含用户之前的数据)
16
+ 3. 如果当前容器已有重要数据,警告用户会被部分覆盖
17
+
18
+ 向用户简短确认:
19
+
20
+ - "我将用你上传的备份文件恢复你的 SophClaw 状态,包括 agent 配置、会话历史和 workspace 文件。当前容器的某些数据会被覆盖。确认继续吗?"
21
+
22
+ 用户确认后继续。
23
+
24
+ ## 定位上传的 ZIP 文件
25
+
26
+ 用户通过 SophClaw Web UI 上传的文件会被保存在 `~/.openclaw/media/inbound/` 目录下,文件名格式为 `<原始名称>---<uuid>.zip`。
27
+
28
+ **第一步:查找最新上传的 ZIP 文件**
29
+
30
+ ```bash
31
+ ls -lt ~/.openclaw/media/inbound/*.zip 2>/dev/null | head -5
32
+ ```
33
+
34
+ 如果用户刚上传了一个恢复用的 ZIP,最新的文件就是它(文件名包含恢复相关的关键词如 `restore`、`backup`、`openclaw` 等)。
35
+
36
+ 如果找到明确的文件,直接使用它的完整路径。如果有多个 ZIP,选择最新的那个。
37
+
38
+ **第二步:如果 media/inbound 目录没有 ZIP**
39
+
40
+ 可能用户是通过其他方式提供的文件路径。请用户确认文件位置,然后用 `ls` 验证。
41
+
42
+ ## 执行恢复
43
+
44
+ 找到 ZIP 文件路径后,运行:
45
+
46
+ ```bash
47
+ python3 skills/restore-state/src/scripts/restore.py "<找到的zip文件完整路径>"
48
+ ```
49
+
50
+ 也可以不加参数直接运行,脚本会自动搜索 `~/.openclaw/media/inbound/` 中最新的 ZIP:
51
+
52
+ ```bash
53
+ python3 skills/restore-state/src/scripts/restore.py
54
+ ```
55
+
56
+ ## 解释恢复结果
57
+
58
+ 脚本会输出恢复摘要,包括:
59
+ - 恢复了哪些 agent(main、assistant、beauty、vip-qa 等)
60
+ - 恢复了多少条会话记录
61
+ - 恢复了哪些 workspace
62
+ - 是否有跳过的文件及原因
63
+
64
+ 向用户报告摘要。
65
+
66
+ ## 下一步提示
67
+
68
+ 提醒用户:
69
+
70
+ - "由于更新了配置,SophClaw 会自动重新加载。等待约 10 秒后刷新前端页面即可看到恢复的会话和 agent。"
71
+
72
+ ## 异常处理
73
+
74
+ | 场景 | 处理方式 |
75
+ |------|----------|
76
+ | ZIP 文件路径不存在 | 用 `ls ~/.openclaw/media/inbound/` 查看可用文件,选最新的 ZIP |
77
+ | ZIP 文件格式损坏 | 请用户重新下载备份文件后再试 |
78
+ | 未找到任何 ZIP 文件 | 请用户重新上传备份文件到聊天中 |
79
+ | Python 或依赖缺失 | 运行 `which python3` 确认解释器存在 |
80
+ | 权限被拒绝写入 ~/.openclaw | 检查当前用户和目录权限 |
81
+ | openclaw.json 解析失败 | 检查备份文件是否完整,尝试用 `python3 -m json.tool` 验证 JSON |
82
+ | Gateway 重载失败 | 手动运行 `openclaw daemon restart` |
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env python3
2
+ """SophClaw state restore tool.
3
+
4
+ Extracts a SophClaw backup ZIP and merges its contents into the current
5
+ state directory (~/.openclaw by default).
6
+
7
+ Usage:
8
+ python3 restore.py [backup.zip]
9
+
10
+ If no path is given, auto-discovers the latest ZIP in ~/.openclaw/media/inbound/.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import shutil
16
+ import sys
17
+ import tempfile
18
+ import zipfile
19
+ from pathlib import Path
20
+
21
+
22
+ def resolve_state_dir() -> Path:
23
+ """Resolve the SophClaw state directory, same logic as SophClaw's resolveStateDir."""
24
+ env_override = os.environ.get("OPENCLAW_STATE_DIR")
25
+ if env_override:
26
+ return Path(env_override).expanduser().resolve()
27
+
28
+ home = Path.home()
29
+ candidates = [
30
+ home / ".openclaw",
31
+ home / ".clawdbot",
32
+ home / ".moltbot",
33
+ ]
34
+ for candidate in candidates:
35
+ if candidate.exists():
36
+ return candidate
37
+
38
+ return home / ".openclaw"
39
+
40
+
41
+ def auto_discover_zip(state_dir: Path) -> Path | None:
42
+ """Find the most recent ZIP file in the media/inbound directory."""
43
+ inbound = state_dir / "media" / "inbound"
44
+ if not inbound.is_dir():
45
+ return None
46
+
47
+ zip_files = sorted(
48
+ inbound.glob("*.zip"),
49
+ key=lambda p: p.stat().st_mtime,
50
+ reverse=True,
51
+ )
52
+ if not zip_files:
53
+ return None
54
+ return zip_files[0]
55
+
56
+
57
+ # Config keys to RESTORE from the backup (user-created content and preferences).
58
+ RESTORE_BACKUP_KEYS = {
59
+ "agents",
60
+ "models",
61
+ "skills",
62
+ "tools",
63
+ "commands",
64
+ "messages",
65
+ "session",
66
+ "identity",
67
+ "cron",
68
+ }
69
+
70
+
71
+ def deep_merge(base: dict, overlay: dict) -> dict:
72
+ """Recursively merge overlay into base. overlay values win on conflict."""
73
+ result = dict(base)
74
+ for key, value in overlay.items():
75
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
76
+ result[key] = deep_merge(result[key], value)
77
+ else:
78
+ result[key] = value
79
+ return result
80
+
81
+
82
+ def merge_openclaw_json(backup_path: Path, state_dir: Path) -> dict:
83
+ """Merge backup openclaw.json with the current one.
84
+
85
+ Backup values take precedence for user-config keys (agents, models, etc.).
86
+ Container runtime keys (gateway, channels, plugins) are kept from current.
87
+ """
88
+ current_path = state_dir / "openclaw.json"
89
+ if not current_path.exists():
90
+ with open(backup_path, "r", encoding="utf-8") as f:
91
+ return json.load(f)
92
+
93
+ with open(current_path, "r", encoding="utf-8") as f:
94
+ current = json.load(f)
95
+ with open(backup_path, "r", encoding="utf-8") as f:
96
+ backup = json.load(f)
97
+
98
+ merged = dict(current)
99
+
100
+ for key in RESTORE_BACKUP_KEYS:
101
+ if key in backup:
102
+ if key in merged and isinstance(merged[key], dict) and isinstance(backup[key], dict):
103
+ merged[key] = deep_merge(merged[key], backup[key])
104
+ else:
105
+ merged[key] = backup[key]
106
+
107
+ return merged
108
+
109
+
110
+ def merge_sessions_json(backup_path: Path, current_path: Path) -> tuple[int, int]:
111
+ """Merge backup sessions.json into the current one.
112
+
113
+ Backup entries win on session ID conflict.
114
+ Returns (backup_session_count, merged_total_count).
115
+ """
116
+ current_sessions: dict = {}
117
+ if current_path.exists():
118
+ with open(current_path, "r", encoding="utf-8") as f:
119
+ current_sessions = json.load(f)
120
+
121
+ with open(backup_path, "r", encoding="utf-8") as f:
122
+ backup_sessions = json.load(f)
123
+
124
+ merged = dict(current_sessions)
125
+ for session_id, entry in backup_sessions.items():
126
+ merged[session_id] = entry
127
+
128
+ current_path.parent.mkdir(parents=True, exist_ok=True)
129
+ with open(current_path, "w", encoding="utf-8") as f:
130
+ json.dump(merged, f, ensure_ascii=False, indent=2)
131
+ f.write("\n")
132
+
133
+ return len(backup_sessions), len(merged)
134
+
135
+
136
+ def copy_file(src: Path, dst: Path) -> bool:
137
+ """Copy a file from src to dst, creating parent dirs as needed."""
138
+ try:
139
+ dst.parent.mkdir(parents=True, exist_ok=True)
140
+ shutil.copy2(src, dst)
141
+ return True
142
+ except OSError as e:
143
+ print(f" [WARN] Failed to copy {src} -> {dst}: {e}", file=sys.stderr)
144
+ return False
145
+
146
+
147
+ def copy_directory(src: Path, dst: Path) -> tuple[int, int]:
148
+ """Recursively copy directory contents from src to dst."""
149
+ copied = 0
150
+ skipped = 0
151
+ dst.mkdir(parents=True, exist_ok=True)
152
+ for item in src.rglob("*"):
153
+ rel = item.relative_to(src)
154
+ target = dst / rel
155
+ if item.is_dir():
156
+ target.mkdir(parents=True, exist_ok=True)
157
+ continue
158
+ if copy_file(item, target):
159
+ copied += 1
160
+ else:
161
+ skipped += 1
162
+ return copied, skipped
163
+
164
+
165
+ def resolve_backup_path(args: list[str], state_dir: Path) -> tuple[Path, str]:
166
+ """Resolve the backup ZIP path from CLI args or auto-discovery.
167
+
168
+ Returns (path, source_description).
169
+ """
170
+ if len(args) >= 2:
171
+ user_path = Path(args[1]).resolve()
172
+ if not user_path.exists():
173
+ print(f"Error: backup file not found: {user_path}", file=sys.stderr)
174
+ sys.exit(1)
175
+ if not zipfile.is_zipfile(user_path):
176
+ print(f"Error: not a valid ZIP file: {user_path}", file=sys.stderr)
177
+ sys.exit(1)
178
+ return user_path, "user-specified"
179
+
180
+ # Auto-discover from media/inbound
181
+ discovered = auto_discover_zip(state_dir)
182
+ if discovered is None:
183
+ print(
184
+ "Error: no backup ZIP path provided and no ZIP files found in "
185
+ f"{state_dir / 'media' / 'inbound'}",
186
+ file=sys.stderr,
187
+ )
188
+ print(
189
+ "Usage: python3 restore.py <backup.zip>\n"
190
+ " or upload a ZIP via SophClaw chat and run without arguments.",
191
+ file=sys.stderr,
192
+ )
193
+ sys.exit(1)
194
+
195
+ return discovered, "auto-discovered"
196
+
197
+
198
+ def main():
199
+ state_dir = resolve_state_dir()
200
+ state_dir.mkdir(parents=True, exist_ok=True)
201
+
202
+ zip_path, source = resolve_backup_path(sys.argv, state_dir)
203
+
204
+ print(f"State directory: {state_dir}")
205
+ print(f"Backup ZIP ({source}): {zip_path}")
206
+ print(f" Size: {zip_path.stat().st_size / 1024:.1f} KB")
207
+ print()
208
+
209
+ summary = {
210
+ "agents_restored": [],
211
+ "sessions_total": 0,
212
+ "sessions_merged": 0,
213
+ "files_copied": 0,
214
+ "files_skipped": 0,
215
+ "workspaces_restored": [],
216
+ }
217
+
218
+ with tempfile.TemporaryDirectory(prefix="sophclaw-restore-") as tmpdir:
219
+ tmp = Path(tmpdir)
220
+ print("Extracting ZIP...")
221
+ with zipfile.ZipFile(zip_path, "r") as zf:
222
+ zf.extractall(tmp)
223
+
224
+ top_items = list(tmp.iterdir())
225
+ print(f" Extracted {len(top_items)} top-level items")
226
+
227
+ # === 1. openclaw.json ===
228
+ backup_config = tmp / "openclaw.json"
229
+ if backup_config.exists():
230
+ print("\n--- Config ---")
231
+ print(" Merging openclaw.json...")
232
+ merged = merge_openclaw_json(backup_config, state_dir)
233
+
234
+ config_target = state_dir / "openclaw.json"
235
+ with open(config_target, "w", encoding="utf-8") as f:
236
+ json.dump(merged, f, ensure_ascii=False, indent=2)
237
+ f.write("\n")
238
+ print(f" Written: {config_target}")
239
+
240
+ agents_list = merged.get("agents", {}).get("list", [])
241
+ for agent in agents_list:
242
+ summary["agents_restored"].append(agent.get("id", "unknown"))
243
+ print(f" Agents restored: {', '.join(summary['agents_restored']) or 'none'}")
244
+ else:
245
+ print(" [WARN] No openclaw.json found in backup")
246
+
247
+ # === 2. Agents (sessions, models, runtime-stats) ===
248
+ backup_agents = tmp / "agents"
249
+ if backup_agents.is_dir():
250
+ print("\n--- Agents ---")
251
+ current_agents = state_dir / "agents"
252
+
253
+ for agent_dir in sorted(backup_agents.iterdir()):
254
+ if not agent_dir.is_dir():
255
+ continue
256
+ agent_name = agent_dir.name
257
+
258
+ # Merge sessions.json
259
+ backup_sessions_json = agent_dir / "sessions" / "sessions.json"
260
+ current_sessions_json = current_agents / agent_name / "sessions" / "sessions.json"
261
+ if backup_sessions_json.exists():
262
+ backup_count, merged_count = merge_sessions_json(
263
+ backup_sessions_json, current_sessions_json
264
+ )
265
+ summary["sessions_merged"] += backup_count
266
+ summary["sessions_total"] += merged_count
267
+ print(
268
+ f" [{agent_name}] sessions: {backup_count} from backup, "
269
+ f"{merged_count} total after merge"
270
+ )
271
+
272
+ # Copy session transcript .jsonl files
273
+ backup_sessions_dir = agent_dir / "sessions"
274
+ if backup_sessions_dir.is_dir():
275
+ jsonl_count = 0
276
+ for session_file in backup_sessions_dir.iterdir():
277
+ if session_file.suffix == ".jsonl" and session_file.name != "sessions.json":
278
+ target = current_agents / agent_name / "sessions" / session_file.name
279
+ if copy_file(session_file, target):
280
+ jsonl_count += 1
281
+ if jsonl_count > 0:
282
+ print(f" [{agent_name}] transcripts: {jsonl_count} files copied")
283
+
284
+ # Copy non-session agent files (models.json, runtime-stats, etc.)
285
+ for item in agent_dir.rglob("*"):
286
+ if item.is_dir():
287
+ continue
288
+ rel = item.relative_to(agent_dir)
289
+ if rel.parts[0] == "sessions":
290
+ continue
291
+ target = current_agents / agent_name / rel
292
+ if copy_file(item, target):
293
+ summary["files_copied"] += 1
294
+ else:
295
+ print("\n [INFO] No agents directory in backup")
296
+
297
+ # === 3. Workspace directories ===
298
+ print("\n--- Workspaces ---")
299
+ for item in sorted(tmp.iterdir()):
300
+ if not item.is_dir():
301
+ continue
302
+ if item.name.startswith("workspace"):
303
+ ws_name = item.name
304
+ current_ws = state_dir / ws_name
305
+ copied, skipped = copy_directory(item, current_ws)
306
+ summary["files_copied"] += copied
307
+ summary["files_skipped"] += skipped
308
+ summary["workspaces_restored"].append(ws_name)
309
+ print(f" [{ws_name}] {copied} files, {skipped} skipped")
310
+
311
+ # === 4. Memory SQLite files ===
312
+ backup_memory = tmp / "memory"
313
+ if backup_memory.is_dir():
314
+ print("\n--- Memory ---")
315
+ current_memory = state_dir / "memory"
316
+ for db_file in sorted(backup_memory.iterdir()):
317
+ if db_file.suffix == ".sqlite":
318
+ target = current_memory / db_file.name
319
+ if copy_file(db_file, target):
320
+ summary["files_copied"] += 1
321
+ print(f" [{db_file.name}] restored")
322
+
323
+ # === 5. Loose files (identity, canvas, logs, media, config backups, etc.) ===
324
+ LOOSE_PATTERNS = [
325
+ "identity",
326
+ "canvas",
327
+ "logs",
328
+ "media",
329
+ "openclaw.json.bak",
330
+ "openclaw.json.bak.1",
331
+ "openclaw.json.bak.2",
332
+ "openclaw.json.bak.3",
333
+ "openclaw.json.bak.4",
334
+ "openclaw.json.last-good",
335
+ "jwt.json",
336
+ "update-check.json",
337
+ ]
338
+
339
+ print("\n--- Other Files ---")
340
+ for pattern in LOOSE_PATTERNS:
341
+ src = tmp / pattern
342
+ if src.exists():
343
+ dst = state_dir / pattern
344
+ if src.is_dir():
345
+ copied, skipped = copy_directory(src, dst)
346
+ summary["files_copied"] += copied
347
+ summary["files_skipped"] += skipped
348
+ print(f" [{pattern}/] {copied} files")
349
+ else:
350
+ if copy_file(src, dst):
351
+ summary["files_copied"] += 1
352
+ print(f" [{pattern}] copied")
353
+
354
+ # Handle any remaining items not explicitly covered
355
+ handled = set(LOOSE_PATTERNS) | {"agents", "memory", "openclaw.json"}
356
+ for ws_name in summary["workspaces_restored"]:
357
+ handled.add(ws_name)
358
+
359
+ for item in sorted(tmp.iterdir()):
360
+ if item.name in handled:
361
+ continue
362
+ dst = state_dir / item.name
363
+ if item.is_dir():
364
+ copied, skipped = copy_directory(item, dst)
365
+ summary["files_copied"] += copied
366
+ summary["files_skipped"] += skipped
367
+ if copied > 0:
368
+ print(f" [{item.name}/] {copied} files")
369
+ else:
370
+ if copy_file(item, dst):
371
+ summary["files_copied"] += 1
372
+ print(f" [{item.name}] copied")
373
+
374
+ # === Summary ===
375
+ print()
376
+ print("=" * 50)
377
+ print("Restore Complete")
378
+ print("=" * 50)
379
+ print(f" Agents: {', '.join(summary['agents_restored']) or 'none'}")
380
+ print(f" Sessions: {summary['sessions_merged']} from backup ({summary['sessions_total']} total)")
381
+ print(f" Workspaces: {', '.join(summary['workspaces_restored']) or 'none'}")
382
+ print(f" Files: {summary['files_copied']} copied, {summary['files_skipped']} skipped")
383
+ print()
384
+
385
+ # Touch config to trigger chokidar-based hot reload
386
+ config_target = state_dir / "openclaw.json"
387
+ if config_target.exists():
388
+ os.utime(config_target, None)
389
+ print("Config updated. SophClaw will auto-reload within a few seconds.")
390
+ print("Refresh your browser page to see the restored state.")
391
+
392
+ return 0
393
+
394
+
395
+ if __name__ == "__main__":
396
+ sys.exit(main())