sophhub 0.4.29 → 0.4.30

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.29",
3
+ "version": "0.4.30",
4
4
  "description": "SophHub CLI - Manage and download AI Agent skills and agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "agent-install",
3
- "version": "0.1.7",
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-20"
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.5",
3
+ "version": "1.0.7",
4
4
  "types": [
5
5
  "store"
6
6
  ],
7
7
  "displayName": "在线提Bug",
8
- "description": "用户说「在线提交bug」时收集 Bug 并经由虾友 DM 发送工单(含 session 附件)",
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-19"
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**」后,收集 Bug 描述并提交工单。
8
+ 用户说「**在线提交 Bug**」(`bug` 不区分大小写,允许中间空格)后执行。
11
9
 
12
- ## Processing Mode
10
+ ## Step 1:校验好友
13
11
 
14
- **STRICT SERIAL PROCESSING ONLY** — 单次顺序执行。
15
-
16
- ## Prerequisites
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
- ### Step 1:用户说「在线提交 Bug」
22
+ ## Step 2:收集描述
52
23
 
53
- Agent **立即停止任何尝试解决问题的行为**,回复:
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
- **上传完整的 `.jsonl` 文件**(包含整个对话历史)。
32
+ 记录用户原话。若描述不足追问「请问具体现象是什么?」
87
33
 
88
- ### Step 4:上传到 OSS
34
+ ## Step 3:提交工单
89
35
 
90
- ```bash
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
- cd {workspace}/skills/online-bug-report/src
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 "${SESSION_KEY}" \
105
- --oss-url "${OSS_URL}" \
41
+ --session-key "{sessionKey}" \
42
+ --agent-id "{agentId}" \
106
43
  --json
107
44
  ```
108
45
 
109
- ### Step 6:回复用户
110
-
111
- - 成功:「已提交 Bug 工单,会跟进处理。」
112
- - 失败:「已记录问题,稍后将补发工单。」
113
-
114
- ## 与「知识库反馈」分流
115
-
116
- | 用户意图 | 处理 |
117
- |---------|------|
118
- | 「文档错了」「知识库内容有误」 | `memory/feedback-*.md` 流程 |
119
- | 「在线提交 Bug」 | **本 Skill** → 收集并提交 |
120
-
121
- ## 安全约束
46
+ ## Step 4:回复用户
122
47
 
123
- - **不暴露**:JWT、凭证路径、friendId、API 地址
124
- - **不询问**:技术细节、配置参数
125
- - **不尝试**:分析或修复 Bug
48
+ - 退出码 0 → 「已提交 Bug 工单,会跟进处理。」
49
+ - 退出码非 0 → 「提交 Bug 失败,失败原因为:{stderr}」
126
50
 
127
51
  ## Forbidden
128
52
 
129
- - 不要尝试解决用户的问题
130
- - 不要向用户暴露 JWT、凭证
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.5"
3
+ version = "1.0.7"
4
4
  description = "Send VIP online bug reports to a Sophclaw friend via IM"
5
- requires-python = ">=3.10"
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
- DEFAULT_SOURCE_LABEL = "虾友 DM"
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"- 用户: {report.sender_name.strip()} ({report.sender_id.strip()})",
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"- OpenClaw Base URL: {report.openclaw_base_url}")
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
- lines.append(f"- 模型: {report.model.strip()}")
208
- else:
209
- lines.append("- 模型: (未知)")
210
-
211
- lines.extend(["", "▎会话"])
212
- if report.session_key and report.session_key.strip():
213
- lines.append(f"- sessionKey: {report.session_key.strip()}")
214
- else:
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.extend(report.oss_urls)
224
- else:
225
- lines.append("(无)")
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 | None = None) -> Path | None:
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 | None = None) -> Optional[str]:
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 read_context_file(path: Optional[str]) -> Optional[str]:
379
- if not path:
380
- return None
381
- file_path = Path(path).expanduser()
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
- text = file_path.read_text(encoding="utf-8")
384
- except OSError as exc:
385
- raise AppError(f"无法读取 context 文件 {file_path}: {exc}") from exc
386
- text = text.strip()
387
- return text or None
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=args.sender_id,
434
- sender_name=args.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]) -> tuple[int, str]:
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
- friend_name = (
462
- (args.friend_name or "").strip()
463
- or cfg_pick(cred, "dev_friend_name", "BUG_DEV_FRIEND_NAME")
464
- or "研发值班"
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
- return friend_id, friend_name
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", required=True, help="发件人 ID(虾友/Bot senderId)")
476
- parser.add_argument("--sender-name", required=True, help="发件人展示名")
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("--terminal-label", default="", help="终端名称,默认「虾友 DM」")
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 = normalize_http_url(
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, friend_name = resolve_dev_friend(args, cred)
599
- api_base = normalize_http_url(
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, "displayName": friend_name},
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"收件人: {friend_name} (friendId={friend_id})")
720
+ print(f"收件人 friendId={friend_id}")
622
721
  if sent.get("_id"):
623
722
  print(f"messageId: {sent.get('_id')}")
624
723
  return 0
@@ -1,6 +1,3 @@
1
1
  {
2
- "dev_friend_id": 55039,
3
- "dev_friend_name": "用户2199",
4
- "terminal_label": "虾友 DM",
5
- "default_model": "Qwen3.5-122B-A10B"
2
+ "dev_friend_id": 23858
6
3
  }