sophhub 0.4.29 → 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 +1 -1
- package/skills/agent-install/skill.json +9 -2
- package/skills/agent-install/src/scripts/update_openclaw.py +62 -0
- package/skills/online-bug-report/skill.json +27 -3
- package/skills/online-bug-report/src/SKILL.md +26 -102
- package/skills/online-bug-report/src/pyproject.toml +10 -2
- package/skills/online-bug-report/src/references/config.example.json +1 -4
- package/skills/online-bug-report/src/scripts/report_bug.py +202 -103
- package/skills/online-bug-report/src/secrets/bug-report.json +1 -4
- package/skills/restore-state/skill.json +16 -0
- package/skills/restore-state/src/SKILL.md +82 -0
- package/skills/restore-state/src/scripts/restore.py +396 -0
package/package.json
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-install",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"types": [
|
|
5
5
|
"store"
|
|
6
6
|
],
|
|
7
7
|
"displayName": "Agent安装",
|
|
8
8
|
"description": "安装或升级 Sophclaw Agent(含占位替换、备份、post_install 脚本与自动安装 skill)。",
|
|
9
9
|
"changelog": [
|
|
10
|
+
{
|
|
11
|
+
"changes": [
|
|
12
|
+
"新增 _ensure_main_agent_in_list:单 Agent 模式下安装 Agent 时自动迁移 main Agent 至 agents.list,防止 session 丢失"
|
|
13
|
+
],
|
|
14
|
+
"date": "2026-05-27",
|
|
15
|
+
"version": "0.1.8"
|
|
16
|
+
},
|
|
10
17
|
{
|
|
11
18
|
"changes": [
|
|
12
19
|
"新增 run_post_install.py:安装时执行 .config.json 中 post_install 脚本",
|
|
@@ -47,5 +54,5 @@
|
|
|
47
54
|
}
|
|
48
55
|
],
|
|
49
56
|
"createdAt": "2026-04-21",
|
|
50
|
-
"updatedAt": "2026-05-
|
|
57
|
+
"updatedAt": "2026-05-27"
|
|
51
58
|
}
|
|
@@ -6,6 +6,8 @@ import json
|
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
|
|
9
11
|
from common import (
|
|
10
12
|
build_agent_entry,
|
|
11
13
|
build_bot_api_account,
|
|
@@ -33,6 +35,64 @@ def normalized_override(value: str | None) -> str | None:
|
|
|
33
35
|
return None
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
def _ensure_main_agent_in_list(config: dict, config_path: Path) -> dict | None:
|
|
39
|
+
"""如果 openclaw.json 处于单 Agent 模式(agents.list 为空但默认 workspace 存在),
|
|
40
|
+
将 main Agent 迁移至 list 中,防止 install 后 session 丢失。
|
|
41
|
+
|
|
42
|
+
返回迁移信息 dict(含 migrated/agent_id/workspace),无需迁移时返回 None。
|
|
43
|
+
"""
|
|
44
|
+
agents = config.setdefault("agents", {})
|
|
45
|
+
agents_list = agents.setdefault("list", [])
|
|
46
|
+
|
|
47
|
+
# 已有 Agent 列表条目 → 已是多 Agent 模式,无需迁移
|
|
48
|
+
if agents_list:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
root_dir = config_path.parent
|
|
52
|
+
# 默认 workspace 路径为 openclaw.json 同级目录下的 workspace
|
|
53
|
+
default_workspace = root_dir / "workspace"
|
|
54
|
+
if not default_workspace.is_dir():
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
main_id = config.get("id", "main")
|
|
58
|
+
if not isinstance(main_id, str) or not main_id.strip():
|
|
59
|
+
main_id = "main"
|
|
60
|
+
main_id = main_id.strip()
|
|
61
|
+
|
|
62
|
+
# workspace:优先顶层 config.workspace,否则用默认路径
|
|
63
|
+
top_workspace = config.get("workspace")
|
|
64
|
+
workspace = str(top_workspace) if isinstance(top_workspace, str) and top_workspace.strip() else str(default_workspace)
|
|
65
|
+
entry: dict = {"id": main_id, "workspace": workspace}
|
|
66
|
+
|
|
67
|
+
# model:顶层 config.model 优先于 defaults.model(单 Agent 模式顶层可能含 fallbacks 等)
|
|
68
|
+
defaults = agents.get("defaults", {})
|
|
69
|
+
top_model = config.get("model")
|
|
70
|
+
if isinstance(top_model, dict):
|
|
71
|
+
entry["model"] = deepcopy(top_model)
|
|
72
|
+
elif isinstance(defaults.get("model"), dict):
|
|
73
|
+
entry["model"] = deepcopy(defaults["model"])
|
|
74
|
+
if isinstance(defaults.get("heartbeat"), dict):
|
|
75
|
+
entry["heartbeat"] = deepcopy(defaults["heartbeat"])
|
|
76
|
+
|
|
77
|
+
# identity:优先从顶层 config 取,否则用 main_id 构造默认值
|
|
78
|
+
if isinstance(config.get("identity"), dict):
|
|
79
|
+
entry["identity"] = deepcopy(config["identity"])
|
|
80
|
+
else:
|
|
81
|
+
entry["identity"] = {"name": main_id, "theme": main_id, "emoji": "🤖"}
|
|
82
|
+
|
|
83
|
+
if isinstance(config.get("name"), str) and config["name"].strip():
|
|
84
|
+
entry["name"] = config["name"].strip()
|
|
85
|
+
if isinstance(config.get("agentDir"), str) and config["agentDir"].strip():
|
|
86
|
+
entry["agentDir"] = config["agentDir"].strip()
|
|
87
|
+
if isinstance(config.get("tools"), dict):
|
|
88
|
+
entry["tools"] = deepcopy(config["tools"])
|
|
89
|
+
if isinstance(config.get("skills"), list):
|
|
90
|
+
entry["skills"] = deepcopy(config["skills"])
|
|
91
|
+
|
|
92
|
+
agents_list.insert(0, entry)
|
|
93
|
+
return {"migrated": True, "agent_id": main_id, "workspace": workspace}
|
|
94
|
+
|
|
95
|
+
|
|
36
96
|
def upsert_agent(config: dict, entry: dict) -> str:
|
|
37
97
|
config.setdefault("agents", {})
|
|
38
98
|
agents = config["agents"].setdefault("list", [])
|
|
@@ -103,6 +163,7 @@ def update_openclaw(
|
|
|
103
163
|
resolved_name = normalized_override(name_override)
|
|
104
164
|
agent_def = load_agent_definition(agent_id, source_path)
|
|
105
165
|
config = load_openclaw_config(config_path)
|
|
166
|
+
main_migration = _ensure_main_agent_in_list(config, config_path)
|
|
106
167
|
desired_workspace = resolved_workspace or agent_def.get("install", {}).get("workspace") or agent_def.get("workspace")
|
|
107
168
|
previous = resolve_existing_agent_entry(config_path, config, agent_id, desired_workspace)
|
|
108
169
|
if previous is None and resolved_openclaw_id:
|
|
@@ -164,6 +225,7 @@ def update_openclaw(
|
|
|
164
225
|
"auto_generate_image_description": auto_img_desc,
|
|
165
226
|
"heartbeat_configured": heartbeat_cfg is not None,
|
|
166
227
|
"heartbeat": heartbeat_cfg,
|
|
228
|
+
"main_migration": main_migration,
|
|
167
229
|
}
|
|
168
230
|
|
|
169
231
|
|
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "online-bug-report",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"types": [
|
|
5
5
|
"store"
|
|
6
6
|
],
|
|
7
7
|
"displayName": "在线提Bug",
|
|
8
|
-
"description": "
|
|
8
|
+
"description": "当用户说「在线提交bug」时,收集问题描述并通过虾友 DM 提交工单。",
|
|
9
9
|
"changelog": [
|
|
10
|
+
{
|
|
11
|
+
"changes": [
|
|
12
|
+
"新增 ensure-friend 子命令,add_friend.py 删除逻辑内置",
|
|
13
|
+
"新增 --agent-id 参数,sessions 路径改为 /agents/{agentId}/sessions/",
|
|
14
|
+
"消息格式重构为 flat ▎结构",
|
|
15
|
+
"支持 JWT 自动解析 sender(userId/name)",
|
|
16
|
+
"错误消息改为显示实际失败原因",
|
|
17
|
+
"pyproject.toml 对齐 sophnet-image-ocr(aliyun mirror、依赖声明)",
|
|
18
|
+
"SKILL.md 精简至 55 行",
|
|
19
|
+
"清理死代码、提取 resolve_api_base 去重"
|
|
20
|
+
],
|
|
21
|
+
"date": "2026-05-28",
|
|
22
|
+
"version": "1.0.7"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"changes": [
|
|
26
|
+
"移除对 sophnet-oss skill 的引用,上传脚本自包含",
|
|
27
|
+
"路径改为运行时格式({baseDir}/scripts/),去除 src/ 和 workspace",
|
|
28
|
+
"新增模板变量说明节,定义 sender_id、sender_name 等变量",
|
|
29
|
+
"Python 版本要求从 3.10+ 降为 3.8+"
|
|
30
|
+
],
|
|
31
|
+
"date": "2026-05-27",
|
|
32
|
+
"version": "1.0.6"
|
|
33
|
+
},
|
|
10
34
|
{
|
|
11
35
|
"changes": [
|
|
12
36
|
"凭证移至 src/secrets/bug-report.json,与 npm 打包仅含 src/ 对齐"
|
|
@@ -51,5 +75,5 @@
|
|
|
51
75
|
}
|
|
52
76
|
],
|
|
53
77
|
"createdAt": "2026-05-18",
|
|
54
|
-
"updatedAt": "2026-05-
|
|
78
|
+
"updatedAt": "2026-05-28"
|
|
55
79
|
}
|
|
@@ -1,131 +1,55 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: online-bug-report
|
|
3
|
-
description:
|
|
4
|
-
仅当用户说「在线提交bug」(大小写不敏感)时触发。收集 Bug 描述、上传 session .jsonl 到 OSS,
|
|
5
|
-
通过虾友 DM 发送工单。勿因「提 Bug」「报障」「系统坏了」等其它说法触发。
|
|
3
|
+
description: 当用户说「在线提交bug」时,收集问题描述并通过虾友 DM 提交工单。
|
|
6
4
|
---
|
|
7
5
|
|
|
8
6
|
# 在线提 Bug
|
|
9
7
|
|
|
10
|
-
用户说「**在线提交 Bug
|
|
8
|
+
用户说「**在线提交 Bug**」(`bug` 不区分大小写,允许中间空格)后执行。
|
|
11
9
|
|
|
12
|
-
##
|
|
10
|
+
## Step 1:校验好友
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- Python 3.10+、`uv`
|
|
19
|
-
- 有效 JWT:`/home/node/.openclaw/jwt.json`(字段 `web_jwt`)
|
|
20
|
-
- `exec` 工具可用
|
|
21
|
-
|
|
22
|
-
## 凭证文件
|
|
23
|
-
|
|
24
|
-
**内置凭证(随 skill 下发):** `{baseDir}/secrets/bug-report.json`,下载后可直接使用。修改收件人请直接编辑该文件,或使用 `--cred-file` 指定其它路径。
|
|
25
|
-
|
|
26
|
-
## 触发词
|
|
27
|
-
|
|
28
|
-
**唯一触发词**:「在线提交bug」(`bug` 不区分大小写;允许 `在线提交 bug` 中间有空格)
|
|
29
|
-
|
|
30
|
-
| 用户说的话 | 是否触发 |
|
|
31
|
-
|-----------|----------|
|
|
32
|
-
| 「在线提交bug」/「在线提交 Bug」 | ✅ 触发 |
|
|
33
|
-
| 「提 Bug」 | ❌ 不触发,正常回答 |
|
|
34
|
-
| 「报 Bug」 | ❌ 不触发,正常回答 |
|
|
35
|
-
| 「系统坏了」 | ❌ 不触发,正常回答 |
|
|
36
|
-
| 「有个 Bug」 | ❌ 不触发,正常回答 |
|
|
37
|
-
|
|
38
|
-
## Agent 角色定位
|
|
39
|
-
|
|
40
|
-
**Agent 是 Bug 信息收集者,不是问题解决者。**
|
|
41
|
-
|
|
42
|
-
| Agent 行为 | 正确/错误 |
|
|
43
|
-
|-----------|----------|
|
|
44
|
-
| 收集用户描述 → 提交工单 | ✅ 正确 |
|
|
45
|
-
| 尝试自己分析/修复 Bug | ❌ 错误 |
|
|
46
|
-
| 询问技术细节、API、配置 | ❌ 错误 |
|
|
47
|
-
| 让用户补充现象描述 | ✅ 正确 |
|
|
12
|
+
```bash
|
|
13
|
+
uv run {baseDir}/scripts/report_bug.py ensure-friend --json
|
|
14
|
+
```
|
|
48
15
|
|
|
49
|
-
|
|
16
|
+
| 退出码 | Agent 行为 |
|
|
17
|
+
|--------|-----------|
|
|
18
|
+
| 0 | 进入 Step 2 |
|
|
19
|
+
| 2 | 回复「研发值班还不是您的好友,已自动发送申请。通过后请重新提交 Bug」→ 结束 |
|
|
20
|
+
| 其他 | 回复「提交 Bug 失败,失败原因为:{stderr}」→ 结束 |
|
|
50
21
|
|
|
51
|
-
|
|
22
|
+
## Step 2:收集描述
|
|
52
23
|
|
|
53
|
-
|
|
24
|
+
回复以下内容让用户补充:
|
|
54
25
|
|
|
55
26
|
```
|
|
56
27
|
好的,我来帮您提交 Bug 工单。
|
|
57
28
|
|
|
58
|
-
|
|
59
|
-
- 具体是什么问题/现象?
|
|
60
|
-
- 大概什么时候发生的?
|
|
61
|
-
- 有复现步骤吗?(可选)
|
|
62
|
-
|
|
63
|
-
补充完之后告诉我,我会提交处理。
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
**关键点**:
|
|
67
|
-
- **不尝试**:不分析原因、不尝试修复、不给解决方案
|
|
68
|
-
- **只收集**:等待用户补充描述
|
|
69
|
-
- **不询问技术细节**
|
|
70
|
-
|
|
71
|
-
### Step 2:等待用户补充描述
|
|
72
|
-
|
|
73
|
-
用户补充描述后,记录用户原话作为 Bug 描述。
|
|
74
|
-
|
|
75
|
-
若描述不足,追问:「请问具体现象是什么?」
|
|
76
|
-
不追问技术细节。
|
|
77
|
-
|
|
78
|
-
### Step 3:获取完整 session 文件
|
|
79
|
-
|
|
80
|
-
```bash
|
|
81
|
-
SESSION_KEY="{当前sessionKey}"
|
|
82
|
-
SESSION_ID=$(cat {agentDir}/sessions/sessions.json | jq -r '."${SESSION_KEY}".sessionId')
|
|
83
|
-
SESSION_FILE="{agentDir}/sessions/${SESSION_ID}.jsonl"
|
|
29
|
+
请补充一下您的具体问题/现象,如果能提供复现步骤,那更好。
|
|
84
30
|
```
|
|
85
31
|
|
|
86
|
-
|
|
32
|
+
记录用户原话。若描述不足追问「请问具体现象是什么?」
|
|
87
33
|
|
|
88
|
-
|
|
34
|
+
## Step 3:提交工单
|
|
89
35
|
|
|
90
|
-
|
|
91
|
-
cd {workspace}/skills/sophnet-oss
|
|
92
|
-
uv run --with sophnet-tools python scripts/upload_file.py \
|
|
93
|
-
--file "${SESSION_FILE}" --timeout 60
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
### Step 5:发送工单
|
|
36
|
+
获取当前 session 的 sessionKey 和 agentId(sessionKey 形如 `agent:<AgentID>:...`,取第二段即为 agentId),然后执行:
|
|
97
37
|
|
|
98
38
|
```bash
|
|
99
|
-
|
|
100
|
-
uv run scripts/report_bug.py send \
|
|
101
|
-
--sender-id "{sender_id}" \
|
|
102
|
-
--sender-name "{sender_name}" \
|
|
39
|
+
uv run {baseDir}/scripts/report_bug.py send \
|
|
103
40
|
--description "{用户完整描述}" \
|
|
104
|
-
--session-key "
|
|
105
|
-
--
|
|
41
|
+
--session-key "{sessionKey}" \
|
|
42
|
+
--agent-id "{agentId}" \
|
|
106
43
|
--json
|
|
107
44
|
```
|
|
108
45
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
- 成功:「已提交 Bug 工单,会跟进处理。」
|
|
112
|
-
- 失败:「已记录问题,稍后将补发工单。」
|
|
113
|
-
|
|
114
|
-
## 与「知识库反馈」分流
|
|
115
|
-
|
|
116
|
-
| 用户意图 | 处理 |
|
|
117
|
-
|---------|------|
|
|
118
|
-
| 「文档错了」「知识库内容有误」 | `memory/feedback-*.md` 流程 |
|
|
119
|
-
| 「在线提交 Bug」 | **本 Skill** → 收集并提交 |
|
|
120
|
-
|
|
121
|
-
## 安全约束
|
|
46
|
+
## Step 4:回复用户
|
|
122
47
|
|
|
123
|
-
-
|
|
124
|
-
-
|
|
125
|
-
- **不尝试**:分析或修复 Bug
|
|
48
|
+
- 退出码 0 → 「已提交 Bug 工单,会跟进处理。」
|
|
49
|
+
- 退出码非 0 → 「提交 Bug 失败,失败原因为:{stderr}」
|
|
126
50
|
|
|
127
51
|
## Forbidden
|
|
128
52
|
|
|
129
|
-
-
|
|
130
|
-
-
|
|
131
|
-
-
|
|
53
|
+
- 不要分析/修复 Bug,只收集信息
|
|
54
|
+
- 不要询问技术细节、API、配置
|
|
55
|
+
- 不要暴露 JWT、凭证、friendId、API 地址
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "online-bug-report"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.7"
|
|
4
4
|
description = "Send VIP online bug reports to a Sophclaw friend via IM"
|
|
5
|
-
requires-python = ">=3.
|
|
5
|
+
requires-python = ">=3.8"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"requests>=2.28.0",
|
|
8
|
+
"sophnet-tools>=0.0.1",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[[tool.uv.index]]
|
|
12
|
+
url = "https://mirrors.aliyun.com/pypi/simple/"
|
|
13
|
+
default = true
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "字段说明参考;实际凭证见 secrets/bug-report.json(与 SKILL.md 同级的 src/secrets/)",
|
|
3
3
|
"dev_friend_id": 0,
|
|
4
|
-
"dev_friend_name": "收件人名称",
|
|
5
|
-
"terminal_label": "虾友 DM",
|
|
6
4
|
"terminal_url": "",
|
|
7
5
|
"session_url_template": "{base_url}/chat?session={session_key}",
|
|
8
6
|
"api_base_url": "https://yagent.sophnet.com/api",
|
|
9
|
-
"openclaw_base_url": ""
|
|
10
|
-
"default_model": "Qwen3.5-122B-A10B"
|
|
7
|
+
"openclaw_base_url": ""
|
|
11
8
|
}
|
|
@@ -15,19 +15,16 @@ from dataclasses import dataclass, field
|
|
|
15
15
|
from datetime import datetime
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Any, Optional
|
|
18
|
-
|
|
18
|
+
import sophnet_tools
|
|
19
19
|
|
|
20
20
|
DEFAULT_TIMEOUT = 30
|
|
21
21
|
DEFAULT_API_BASE_URL = "https://yagent.sophnet.com/api"
|
|
22
22
|
DEFAULT_JWT_PATH = Path("/home/node/.openclaw/jwt.json")
|
|
23
23
|
DEFAULT_OPENCLAW_BASE_PATH = Path("/home/node/.openclaw/.base.json")
|
|
24
|
-
|
|
24
|
+
OSS_UPLOAD_TIMEOUT = 60
|
|
25
25
|
|
|
26
26
|
CONFIG_ALIASES: dict[str, tuple[str, ...]] = {
|
|
27
27
|
"dev_friend_id": ("dev_friend_id", "DEV_FRIEND_ID", "friend_id"),
|
|
28
|
-
"dev_friend_name": ("dev_friend_name", "DEV_FRIEND_NAME", "friend_name"),
|
|
29
|
-
"terminal_label": ("terminal_label", "TERMINAL_LABEL"),
|
|
30
|
-
"terminal_url": ("terminal_url", "TERMINAL_URL"),
|
|
31
28
|
"session_url_template": (
|
|
32
29
|
"session_url_template",
|
|
33
30
|
"SESSION_URL_TEMPLATE",
|
|
@@ -39,10 +36,16 @@ CONFIG_ALIASES: dict[str, tuple[str, ...]] = {
|
|
|
39
36
|
"OPENCLAW_BASE_URL",
|
|
40
37
|
"sophclaw_base_url",
|
|
41
38
|
),
|
|
42
|
-
"default_model": ("default_model", "DEFAULT_MODEL", "llm", "model"),
|
|
43
39
|
}
|
|
44
40
|
|
|
45
41
|
|
|
42
|
+
def get_sessions_dir(agent_id: str) -> Path:
|
|
43
|
+
"""根据 agentId 构造 sessions 目录路径。"""
|
|
44
|
+
if not agent_id.strip():
|
|
45
|
+
raise AppError("agent-id 不能为空")
|
|
46
|
+
return Path("/home/node/.openclaw/agents") / agent_id.strip() / "sessions"
|
|
47
|
+
|
|
48
|
+
|
|
46
49
|
class AppError(RuntimeError):
|
|
47
50
|
pass
|
|
48
51
|
|
|
@@ -161,12 +164,12 @@ class BugReport:
|
|
|
161
164
|
sender_name: str
|
|
162
165
|
user_description: str
|
|
163
166
|
openclaw_base_url: Optional[str] = None
|
|
164
|
-
terminal_label: str = DEFAULT_SOURCE_LABEL
|
|
165
|
-
terminal_url: Optional[str] = None
|
|
166
167
|
session_key: Optional[str] = None
|
|
167
168
|
session_url: Optional[str] = None
|
|
168
|
-
context: Optional[str] = None
|
|
169
169
|
model: Optional[str] = None
|
|
170
|
+
model_provider: Optional[str] = None
|
|
171
|
+
context_tokens: Optional[int] = None
|
|
172
|
+
total_tokens: Optional[int] = None
|
|
170
173
|
oss_urls: list[str] = field(default_factory=list)
|
|
171
174
|
reported_at: Optional[str] = None
|
|
172
175
|
|
|
@@ -185,44 +188,29 @@ def format_bug_message(report: BugReport) -> str:
|
|
|
185
188
|
ts = report.reported_at or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
186
189
|
lines = [
|
|
187
190
|
f"【在线 Bug】{ts}",
|
|
188
|
-
"",
|
|
189
|
-
"
|
|
190
|
-
f"
|
|
191
|
-
f"- 来源: {report.terminal_label.strip() or DEFAULT_SOURCE_LABEL}",
|
|
192
|
-
"",
|
|
193
|
-
"▎用户描述",
|
|
194
|
-
report.user_description.strip(),
|
|
195
|
-
"",
|
|
196
|
-
"▎环境",
|
|
191
|
+
f"▎发件人:{report.sender_name.strip()}",
|
|
192
|
+
f"▎ID: {report.sender_id.strip()}",
|
|
193
|
+
f"▎会话:{report.session_key.strip() if report.session_key else '(无)'}",
|
|
197
194
|
]
|
|
198
195
|
if report.openclaw_base_url:
|
|
199
|
-
lines.append(f"
|
|
200
|
-
else:
|
|
201
|
-
lines.append("- OpenClaw Base URL: (未配置)")
|
|
202
|
-
terminal_line = f"- 终端: {report.terminal_label.strip() or DEFAULT_SOURCE_LABEL}"
|
|
203
|
-
if report.terminal_url and report.terminal_url.strip():
|
|
204
|
-
terminal_line += f" ({report.terminal_url.strip()})"
|
|
205
|
-
lines.append(terminal_line)
|
|
196
|
+
lines.append(f"▎Base URL: {report.openclaw_base_url}")
|
|
206
197
|
if report.model and report.model.strip():
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
lines.append("- sessionKey: (无)")
|
|
216
|
-
if report.session_url and report.session_url.strip():
|
|
217
|
-
lines.append(f"- Session 链接: {report.session_url.strip()}")
|
|
218
|
-
if report.context and report.context.strip():
|
|
219
|
-
lines.extend(["- 近期上下文:", report.context.strip()])
|
|
198
|
+
model_line = report.model.strip()
|
|
199
|
+
if report.model_provider and report.model_provider.strip():
|
|
200
|
+
model_line = f"{report.model_provider.strip()}/{report.model.strip()}"
|
|
201
|
+
lines.append(f"▎模型: {model_line}")
|
|
202
|
+
if report.context_tokens is not None:
|
|
203
|
+
lines.append(f"▎上下文大小: {report.context_tokens}")
|
|
204
|
+
if report.total_tokens is not None:
|
|
205
|
+
lines.append(f"▎当前上下文占用: {report.total_tokens}")
|
|
220
206
|
|
|
221
|
-
lines.extend(["", "▎附件"])
|
|
222
207
|
if report.oss_urls:
|
|
223
|
-
lines.
|
|
224
|
-
|
|
225
|
-
|
|
208
|
+
lines.append(f"▎附件:{report.oss_urls[0]}")
|
|
209
|
+
for u in report.oss_urls[1:]:
|
|
210
|
+
lines.append(f" {u}")
|
|
211
|
+
|
|
212
|
+
lines.append(f"▎用户描述:")
|
|
213
|
+
lines.append(f" {report.user_description.strip()}")
|
|
226
214
|
|
|
227
215
|
return "\n".join(lines).rstrip() + "\n"
|
|
228
216
|
|
|
@@ -349,7 +337,7 @@ def load_friends(api_base: str, token: str, timeout: int) -> list[dict[str, Any]
|
|
|
349
337
|
return out
|
|
350
338
|
|
|
351
339
|
|
|
352
|
-
def find_nearest_agent_config(start_dir: Path
|
|
340
|
+
def find_nearest_agent_config(start_dir: Optional[Path] = None) -> Optional[Path]:
|
|
353
341
|
current = (start_dir or Path.cwd()).resolve()
|
|
354
342
|
for candidate in [current, *current.parents]:
|
|
355
343
|
config_path = candidate / ".config.json"
|
|
@@ -358,7 +346,7 @@ def find_nearest_agent_config(start_dir: Path | None = None) -> Path | None:
|
|
|
358
346
|
return None
|
|
359
347
|
|
|
360
348
|
|
|
361
|
-
def read_model_from_agent_config(config_path: Path
|
|
349
|
+
def read_model_from_agent_config(config_path: Optional[Path] = None) -> Optional[str]:
|
|
362
350
|
path = config_path or find_nearest_agent_config()
|
|
363
351
|
if path is None:
|
|
364
352
|
return None
|
|
@@ -375,39 +363,105 @@ def read_model_from_agent_config(config_path: Path | None = None) -> Optional[st
|
|
|
375
363
|
return None
|
|
376
364
|
|
|
377
365
|
|
|
378
|
-
def
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
366
|
+
def resolve_sender_from_jwt(jwt_path: Path = DEFAULT_JWT_PATH) -> tuple[str, str]:
|
|
367
|
+
"""从 JWT payload 中解析 userId 和 name 作为 sender_id / sender_name。"""
|
|
368
|
+
data = load_json_file(jwt_path)
|
|
369
|
+
token = data.get("web_jwt")
|
|
370
|
+
if not isinstance(token, str) or not token.strip():
|
|
371
|
+
raise AppError(f"JWT 文件缺少有效 web_jwt: {jwt_path}")
|
|
372
|
+
token = token.strip()
|
|
373
|
+
if token.lower().startswith("bearer "):
|
|
374
|
+
token = token[7:].strip()
|
|
375
|
+
parts = token.split(".")
|
|
376
|
+
if len(parts) != 3:
|
|
377
|
+
raise AppError("web_jwt 不是有效的 JWT 格式")
|
|
378
|
+
payload_b64 = parts[1]
|
|
379
|
+
remainder = len(payload_b64) % 4
|
|
380
|
+
if remainder:
|
|
381
|
+
payload_b64 += "=" * (4 - remainder)
|
|
382
382
|
try:
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
383
|
+
raw = base64.urlsafe_b64decode(payload_b64.encode("ascii"))
|
|
384
|
+
payload = json.loads(raw)
|
|
385
|
+
except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
386
|
+
raise AppError(f"JWT payload 解码失败: {exc}") from exc
|
|
387
|
+
user_id = payload.get("userId")
|
|
388
|
+
if user_id is None:
|
|
389
|
+
raise AppError("JWT payload 中未找到 userId 字段")
|
|
390
|
+
sender_id = str(int(user_id))
|
|
391
|
+
sender_name = str(payload.get("name") or "")
|
|
392
|
+
if not sender_name.strip():
|
|
393
|
+
sender_name = sender_id
|
|
394
|
+
return sender_id, sender_name
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def resolve_session_file(session_key: str, agent_id: str) -> tuple[Path, dict[str, Any]]:
|
|
398
|
+
"""根据 sessionKey 查找 .jsonl 会话文件,同时返回 session 元信息。"""
|
|
399
|
+
if not session_key.strip():
|
|
400
|
+
raise AppError("session-key 不能为空")
|
|
401
|
+
if not agent_id.strip():
|
|
402
|
+
raise AppError("agent-id 不能为空")
|
|
403
|
+
sessions_dir = get_sessions_dir(agent_id)
|
|
404
|
+
sessions_json = sessions_dir / "sessions.json"
|
|
405
|
+
if not sessions_json.is_file():
|
|
406
|
+
raise AppError(f"sessions.json 不存在: {sessions_json}")
|
|
407
|
+
data = load_json_file(sessions_json)
|
|
408
|
+
session_entry = data.get(session_key.strip())
|
|
409
|
+
if not isinstance(session_entry, dict):
|
|
410
|
+
raise AppError(f"sessions.json 中未找到 sessionKey={session_key} 的记录")
|
|
411
|
+
session_file = Path(session_entry.get("sessionFile") or "")
|
|
412
|
+
if not session_file.is_file():
|
|
413
|
+
raise AppError(f"session 文件不存在: {session_file}")
|
|
414
|
+
meta = {
|
|
415
|
+
"modelProvider": session_entry.get("modelProvider"),
|
|
416
|
+
"model": session_entry.get("model"),
|
|
417
|
+
"contextTokens": session_entry.get("contextTokens"),
|
|
418
|
+
"totalTokens": session_entry.get("totalTokens"),
|
|
419
|
+
}
|
|
420
|
+
return session_file, meta
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def upload_session_to_oss(session_file: Path, timeout: int = OSS_UPLOAD_TIMEOUT) -> str:
|
|
424
|
+
"""上传 session 文件到 OSS,返回签名下载 URL。"""
|
|
425
|
+
signed_url = sophnet_tools.upload_oss(str(session_file), timeout=timeout)
|
|
426
|
+
if not signed_url:
|
|
427
|
+
raise AppError("OSS 上传失败:未返回下载 URL")
|
|
428
|
+
return signed_url
|
|
388
429
|
|
|
389
430
|
|
|
390
431
|
def build_report_from_args(args: argparse.Namespace) -> BugReport:
|
|
391
432
|
cred = load_cred_file(args.cred_file)
|
|
392
433
|
|
|
434
|
+
# sender: 显式传入优先,否则从 JWT 自动解析
|
|
435
|
+
sender_id = (args.sender_id or "").strip()
|
|
436
|
+
sender_name = (args.sender_name or "").strip()
|
|
437
|
+
if not sender_id or not sender_name:
|
|
438
|
+
jwt_id, jwt_name = resolve_sender_from_jwt()
|
|
439
|
+
if not sender_id:
|
|
440
|
+
sender_id = jwt_id
|
|
441
|
+
if not sender_name:
|
|
442
|
+
sender_name = jwt_name
|
|
443
|
+
|
|
393
444
|
openclaw_base = (
|
|
394
445
|
(args.openclaw_base_url or "").strip()
|
|
395
446
|
or cfg_pick(cred, "openclaw_base_url", "OPENCLAW_BASE_URL")
|
|
396
447
|
or read_openclaw_base_url(Path(args.openclaw_base_path).expanduser())
|
|
397
448
|
)
|
|
398
449
|
|
|
399
|
-
terminal_label = (
|
|
400
|
-
(args.terminal_label or "").strip()
|
|
401
|
-
or cfg_pick(cred, "terminal_label", "BUG_TERMINAL_LABEL")
|
|
402
|
-
or DEFAULT_SOURCE_LABEL
|
|
403
|
-
)
|
|
404
|
-
terminal_url = (
|
|
405
|
-
(args.terminal_url or "").strip()
|
|
406
|
-
or cfg_pick(cred, "terminal_url", "BUG_TERMINAL_URL")
|
|
407
|
-
or None
|
|
408
|
-
)
|
|
409
|
-
|
|
410
450
|
session_key = (args.session_key or "").strip() or None
|
|
451
|
+
agent_id = (args.agent_id or "").strip() or None
|
|
452
|
+
|
|
453
|
+
# OSS URL: 显式传入优先,否则根据 session_key 自动上传
|
|
454
|
+
explicit_oss_urls = [normalize_http_url(u, field_name="oss-url") for u in (args.oss_url or [])]
|
|
455
|
+
session_meta: dict[str, Any] = {}
|
|
456
|
+
if explicit_oss_urls:
|
|
457
|
+
oss_urls = explicit_oss_urls
|
|
458
|
+
elif session_key and agent_id:
|
|
459
|
+
session_file, session_meta = resolve_session_file(session_key, agent_id)
|
|
460
|
+
oss_url = upload_session_to_oss(session_file)
|
|
461
|
+
oss_urls = [oss_url]
|
|
462
|
+
else:
|
|
463
|
+
oss_urls = []
|
|
464
|
+
|
|
411
465
|
template = (args.session_url_template or "").strip() or cfg_pick(
|
|
412
466
|
cred, "session_url_template", "BUG_SESSION_URL_TEMPLATE"
|
|
413
467
|
)
|
|
@@ -419,33 +473,38 @@ def build_report_from_args(args: argparse.Namespace) -> BugReport:
|
|
|
419
473
|
openclaw_base,
|
|
420
474
|
)
|
|
421
475
|
|
|
422
|
-
context = (args.context or "").strip() or read_context_file(args.context_file)
|
|
423
|
-
|
|
424
|
-
oss_urls = [normalize_http_url(u, field_name="oss-url") for u in (args.oss_url or [])]
|
|
425
|
-
|
|
426
476
|
model = (
|
|
427
477
|
(args.model or "").strip()
|
|
478
|
+
or session_meta.get("model")
|
|
428
479
|
or cfg_pick(cred, "default_model", "BUG_MODEL")
|
|
429
480
|
or read_model_from_agent_config()
|
|
430
481
|
) or None
|
|
482
|
+
model_provider = (
|
|
483
|
+
session_meta.get("modelProvider")
|
|
484
|
+
or cfg_pick(cred, "model_provider")
|
|
485
|
+
) or None
|
|
486
|
+
|
|
487
|
+
def _session_int(key: str) -> Optional[int]:
|
|
488
|
+
v = session_meta.get(key)
|
|
489
|
+
return int(v) if isinstance(v, (int, float)) else None
|
|
431
490
|
|
|
432
491
|
return BugReport(
|
|
433
|
-
sender_id=
|
|
434
|
-
sender_name=
|
|
492
|
+
sender_id=sender_id,
|
|
493
|
+
sender_name=sender_name,
|
|
435
494
|
user_description=args.description,
|
|
436
495
|
openclaw_base_url=openclaw_base,
|
|
437
|
-
terminal_label=terminal_label,
|
|
438
|
-
terminal_url=terminal_url,
|
|
439
496
|
session_key=session_key,
|
|
440
497
|
session_url=session_url,
|
|
441
|
-
context=context,
|
|
442
498
|
model=model,
|
|
499
|
+
model_provider=model_provider,
|
|
500
|
+
context_tokens=_session_int("contextTokens"),
|
|
501
|
+
total_tokens=_session_int("totalTokens"),
|
|
443
502
|
oss_urls=oss_urls,
|
|
444
503
|
reported_at=(args.reported_at or "").strip() or None,
|
|
445
504
|
)
|
|
446
505
|
|
|
447
506
|
|
|
448
|
-
def resolve_dev_friend(args: argparse.Namespace, cred: dict[str, Any]) ->
|
|
507
|
+
def resolve_dev_friend(args: argparse.Namespace, cred: dict[str, Any]) -> int:
|
|
449
508
|
if args.friend_id is not None and args.friend_id > 0:
|
|
450
509
|
friend_id = int(args.friend_id)
|
|
451
510
|
else:
|
|
@@ -458,12 +517,52 @@ def resolve_dev_friend(args: argparse.Namespace, cred: dict[str, Any]) -> tuple[
|
|
|
458
517
|
raise AppError(f"dev_friend_id 不是有效整数: {raw}") from exc
|
|
459
518
|
if friend_id <= 0:
|
|
460
519
|
raise AppError("friend-id 必须为正整数")
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
520
|
+
return friend_id
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def request_friend(api_base: str, token: str, friend_id: int, timeout: int) -> dict[str, Any]:
|
|
524
|
+
"""发起好友请求。"""
|
|
525
|
+
url = f"{api_base}/sys/openclaw/friend/request"
|
|
526
|
+
return request_json("POST", url, token, timeout, payload={"friendId": friend_id})
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def resolve_api_base(
|
|
530
|
+
args: argparse.Namespace,
|
|
531
|
+
cred: dict[str, Any],
|
|
532
|
+
) -> str:
|
|
533
|
+
"""从参数和凭证中解析 api_base_url,三处调用共用。"""
|
|
534
|
+
return normalize_http_url(
|
|
535
|
+
(args.api_base_url or "").strip()
|
|
536
|
+
or cfg_pick(cred, "api_base_url", "BUG_API_BASE_URL")
|
|
537
|
+
or DEFAULT_API_BASE_URL,
|
|
538
|
+
field_name="api-base-url",
|
|
465
539
|
)
|
|
466
|
-
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def cmd_ensure_friend(args: argparse.Namespace) -> int:
|
|
543
|
+
"""ensure-friend 子命令:检查 dev_friend_id 是否已是好友,否则发起好友请求。
|
|
544
|
+
|
|
545
|
+
退出码: 0=已是好友, 2=刚发起好友请求, 1=异常
|
|
546
|
+
"""
|
|
547
|
+
cred = load_cred_file(getattr(args, "cred_file", None), required=True)
|
|
548
|
+
friend_id = resolve_dev_friend(args, cred)
|
|
549
|
+
api_base = resolve_api_base(args, cred)
|
|
550
|
+
jwt_path = Path(args.jwt_path).expanduser()
|
|
551
|
+
token = read_bearer_token(jwt_path)
|
|
552
|
+
ensure_jwt_valid(token, jwt_path, args.allow_expired_jwt)
|
|
553
|
+
|
|
554
|
+
friends = load_friends(api_base, token, args.timeout)
|
|
555
|
+
friend_ids = {f.get("friendId") for f in friends}
|
|
556
|
+
|
|
557
|
+
if friend_id in friend_ids:
|
|
558
|
+
result = {"status": "already_friend", "friendId": friend_id}
|
|
559
|
+
print_json(result)
|
|
560
|
+
return 0
|
|
561
|
+
|
|
562
|
+
request_friend(api_base, token, friend_id, args.timeout)
|
|
563
|
+
result = {"status": "request_sent", "friendId": friend_id}
|
|
564
|
+
print_json(result)
|
|
565
|
+
return 2
|
|
467
566
|
|
|
468
567
|
|
|
469
568
|
def add_report_args(parser: argparse.ArgumentParser) -> None:
|
|
@@ -472,10 +571,11 @@ def add_report_args(parser: argparse.ArgumentParser) -> None:
|
|
|
472
571
|
"-c",
|
|
473
572
|
help="凭证 JSON 路径(默认 src/secrets/bug-report.json)",
|
|
474
573
|
)
|
|
475
|
-
parser.add_argument("--sender-id",
|
|
476
|
-
parser.add_argument("--sender-name",
|
|
574
|
+
parser.add_argument("--sender-id", default="", help="发件人 ID(默认从 JWT 自动解析)")
|
|
575
|
+
parser.add_argument("--sender-name", default="", help="发件人展示名(默认从 JWT 自动解析)")
|
|
477
576
|
parser.add_argument("--description", required=True, help="用户 Bug 描述")
|
|
478
577
|
parser.add_argument("--session-key", default="", help="当前会话 sessionKey")
|
|
578
|
+
parser.add_argument("--agent-id", default="", help="当前 Agent ID(用于定位 sessions 目录)")
|
|
479
579
|
parser.add_argument(
|
|
480
580
|
"--session-url",
|
|
481
581
|
default="",
|
|
@@ -496,15 +596,10 @@ def add_report_args(parser: argparse.ArgumentParser) -> None:
|
|
|
496
596
|
default=str(DEFAULT_OPENCLAW_BASE_PATH),
|
|
497
597
|
help="base_url JSON 路径",
|
|
498
598
|
)
|
|
499
|
-
parser.add_argument("--
|
|
500
|
-
parser.add_argument("--terminal-url", default="", help="终端入口 URL(可选)")
|
|
501
|
-
parser.add_argument(
|
|
502
|
-
"--model",
|
|
599
|
+
parser.add_argument("--model",
|
|
503
600
|
default="",
|
|
504
601
|
help="用户/客服会话使用的模型(默认读凭证 default_model 或就近 .config.json 的 llm)",
|
|
505
602
|
)
|
|
506
|
-
parser.add_argument("--context", default="", help="近期对话上下文(多行文本)")
|
|
507
|
-
parser.add_argument("--context-file", help="从文件读取近期上下文")
|
|
508
603
|
parser.add_argument(
|
|
509
604
|
"--oss-url",
|
|
510
605
|
action="append",
|
|
@@ -524,7 +619,6 @@ def add_http_args(parser: argparse.ArgumentParser) -> None:
|
|
|
524
619
|
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
|
|
525
620
|
parser.add_argument("--allow-expired-jwt", action="store_true")
|
|
526
621
|
parser.add_argument("--friend-id", type=int, help="研发值班虾友 friendId")
|
|
527
|
-
parser.add_argument("--friend-name", default="", help="研发虾友展示名(日志用)")
|
|
528
622
|
|
|
529
623
|
|
|
530
624
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -540,6 +634,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
540
634
|
add_http_args(p_send)
|
|
541
635
|
p_send.add_argument("--json", action="store_true", help="JSON 输出")
|
|
542
636
|
|
|
637
|
+
p_ensure = sub.add_parser("ensure-friend", help="校验研发虾友是否为好友,否则发起好友请求")
|
|
638
|
+
p_ensure.add_argument(
|
|
639
|
+
"--cred-file",
|
|
640
|
+
"-c",
|
|
641
|
+
help="凭证 JSON 路径(默认 src/secrets/bug-report.json)",
|
|
642
|
+
)
|
|
643
|
+
add_http_args(p_ensure)
|
|
644
|
+
p_ensure.add_argument("--json", action="store_true", help="JSON 输出")
|
|
645
|
+
|
|
543
646
|
p_friends = sub.add_parser("list-friends", help="列出虾友(配置 dev_friend_id 时用)")
|
|
544
647
|
p_friends.add_argument(
|
|
545
648
|
"--cred-file",
|
|
@@ -558,14 +661,12 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
558
661
|
if getattr(args, "timeout", DEFAULT_TIMEOUT) <= 0:
|
|
559
662
|
raise AppError("timeout 必须为正整数")
|
|
560
663
|
|
|
664
|
+
if args.command == "ensure-friend":
|
|
665
|
+
return cmd_ensure_friend(args)
|
|
666
|
+
|
|
561
667
|
if args.command == "list-friends":
|
|
562
668
|
cred = load_cred_file(getattr(args, "cred_file", None))
|
|
563
|
-
api_base =
|
|
564
|
-
(args.api_base_url or "").strip()
|
|
565
|
-
or cfg_pick(cred, "api_base_url", "BUG_API_BASE_URL")
|
|
566
|
-
or DEFAULT_API_BASE_URL,
|
|
567
|
-
field_name="api-base-url",
|
|
568
|
-
)
|
|
669
|
+
api_base = resolve_api_base(args, cred)
|
|
569
670
|
token = read_bearer_token(Path(args.jwt_path).expanduser())
|
|
570
671
|
ensure_jwt_valid(token, Path(args.jwt_path).expanduser(), args.allow_expired_jwt)
|
|
571
672
|
friends = load_friends(api_base, token, args.timeout)
|
|
@@ -583,6 +684,9 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
583
684
|
"senderId": report.sender_id,
|
|
584
685
|
"senderName": report.sender_name,
|
|
585
686
|
"model": report.model,
|
|
687
|
+
"modelProvider": report.model_provider,
|
|
688
|
+
"contextTokens": report.context_tokens,
|
|
689
|
+
"totalTokens": report.total_tokens,
|
|
586
690
|
"sessionKey": report.session_key,
|
|
587
691
|
"sessionUrl": report.session_url,
|
|
588
692
|
"ossUrls": report.oss_urls,
|
|
@@ -595,13 +699,8 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
595
699
|
return 0
|
|
596
700
|
|
|
597
701
|
cred = load_cred_file(args.cred_file, required=True)
|
|
598
|
-
friend_id
|
|
599
|
-
api_base =
|
|
600
|
-
(args.api_base_url or "").strip()
|
|
601
|
-
or cfg_pick(cred, "api_base_url", "BUG_API_BASE_URL")
|
|
602
|
-
or DEFAULT_API_BASE_URL,
|
|
603
|
-
field_name="api-base-url",
|
|
604
|
-
)
|
|
702
|
+
friend_id = resolve_dev_friend(args, cred)
|
|
703
|
+
api_base = resolve_api_base(args, cred)
|
|
605
704
|
jwt_path = Path(args.jwt_path).expanduser()
|
|
606
705
|
token = read_bearer_token(jwt_path)
|
|
607
706
|
ensure_jwt_valid(token, jwt_path, args.allow_expired_jwt)
|
|
@@ -609,7 +708,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
609
708
|
sent = send_im_message(api_base, token, rid, message, args.timeout)
|
|
610
709
|
result = {
|
|
611
710
|
"success": True,
|
|
612
|
-
"friend": {"friendId": friend_id
|
|
711
|
+
"friend": {"friendId": friend_id},
|
|
613
712
|
"rid": rid,
|
|
614
713
|
"messageId": sent.get("_id"),
|
|
615
714
|
"message": message,
|
|
@@ -618,7 +717,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
618
717
|
print_json(result)
|
|
619
718
|
else:
|
|
620
719
|
print("✅ Bug 工单已发送给研发虾友。")
|
|
621
|
-
print(f"
|
|
720
|
+
print(f"收件人 friendId={friend_id}")
|
|
622
721
|
if sent.get("_id"):
|
|
623
722
|
print(f"messageId: {sent.get('_id')}")
|
|
624
723
|
return 0
|
|
@@ -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())
|