monkeycode-sdk 1.7.0__py3-none-any.whl
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.
- monkeycode/__init__.py +58 -0
- monkeycode/client.py +2141 -0
- monkeycode/consts.py +29 -0
- monkeycode/errors.py +30 -0
- monkeycode/links.py +138 -0
- monkeycode/models.py +227 -0
- monkeycode/pow.py +65 -0
- monkeycode/session.py +140 -0
- monkeycode/stream.py +381 -0
- monkeycode_sdk-1.7.0.dist-info/METADATA +93 -0
- monkeycode_sdk-1.7.0.dist-info/RECORD +14 -0
- monkeycode_sdk-1.7.0.dist-info/WHEEL +5 -0
- monkeycode_sdk-1.7.0.dist-info/licenses/LICENSE +21 -0
- monkeycode_sdk-1.7.0.dist-info/top_level.txt +1 -0
monkeycode/consts.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""枚举常量。"""
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TaskType(str, Enum):
|
|
6
|
+
"""任务类型。"""
|
|
7
|
+
|
|
8
|
+
DEVELOP = "develop"
|
|
9
|
+
DESIGN = "design"
|
|
10
|
+
REVIEW = "review"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TaskSubType(str, Enum):
|
|
14
|
+
"""任务子类型。"""
|
|
15
|
+
|
|
16
|
+
GENERATE_DOCS = "generate_docs"
|
|
17
|
+
GENERATE_REQUIREMENT = "generate_requirement"
|
|
18
|
+
GENERATE_DESIGN = "generate_design"
|
|
19
|
+
GENERATE_TASKLIST = "generate_tasklist"
|
|
20
|
+
EXECUTE_TASK = "execute_task"
|
|
21
|
+
PR_REVIEW = "pr_review"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CliName(str, Enum):
|
|
25
|
+
"""CLI 名称。"""
|
|
26
|
+
|
|
27
|
+
CODEX = "codex"
|
|
28
|
+
CLAUDE = "claude"
|
|
29
|
+
OPENCODE = "opencode"
|
monkeycode/errors.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""异常定义。"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MonkeyCodeError(Exception):
|
|
5
|
+
"""所有 MonkeyCode SDK 异常的基类。"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIError(MonkeyCodeError):
|
|
9
|
+
"""接口返回业务错误(HTTP 成功但 code != 0)。"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, code: int | None = None, raw: dict | None = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.code = code
|
|
14
|
+
self.raw = raw or {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthenticationError(APIError):
|
|
18
|
+
"""认证失败(邮箱/密码错误、未登录、会话失效)。"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CaptchaRequiredError(AuthenticationError):
|
|
22
|
+
"""登录需要验证码(captcha_token 缺失或无效)。"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RateLimitError(APIError):
|
|
26
|
+
"""触发限流(HTTP 429)。"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TaskError(MonkeyCodeError):
|
|
30
|
+
"""任务相关错误(创建失败、状态异常、超时)。"""
|
monkeycode/links.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""任务预览链接提取与存活检测。
|
|
2
|
+
|
|
3
|
+
从任务历史轮次(get_task_rounds)中提取 AI 拉起的预览链接,
|
|
4
|
+
过滤出 MonkeyCode 预览域名 / localhost / 8000 端口,并做 HTTP 存活检测。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
_URL_RE = re.compile(r'https?://[^\s"\'<>\)\]\}]+')
|
|
16
|
+
|
|
17
|
+
# MonkeyCode 预览域名 / 本地回环 / deploy-website 默认预览端口
|
|
18
|
+
_PREVIEW_HOST_RE = re.compile(
|
|
19
|
+
r"monkeycode-ai\.online|localhost|127\.0\.0\.1|:8000\b"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_MAX_PAGES = 500
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _decode_data(data: Any) -> str:
|
|
26
|
+
"""把 chunk.data 解码为可提取 URL 的文本。"""
|
|
27
|
+
if not isinstance(data, str):
|
|
28
|
+
return json.dumps(data, ensure_ascii=False)
|
|
29
|
+
try:
|
|
30
|
+
decoded = base64.b64decode(data).decode("utf-8", errors="replace")
|
|
31
|
+
return decoded
|
|
32
|
+
except Exception:
|
|
33
|
+
return data
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _to_text(decoded: str) -> str:
|
|
37
|
+
"""尝试把解码结果还原为 JSON 文本,否则原样返回。"""
|
|
38
|
+
try:
|
|
39
|
+
obj = json.loads(decoded)
|
|
40
|
+
if isinstance(obj, (dict, list)):
|
|
41
|
+
return json.dumps(obj, ensure_ascii=False)
|
|
42
|
+
if isinstance(obj, str):
|
|
43
|
+
return obj
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
return decoded
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _unescape(text: str) -> str:
|
|
50
|
+
"""还原 markdown/JSON 转义,避免 URL 粘连转义字符。"""
|
|
51
|
+
text = text.replace("\\r", "")
|
|
52
|
+
text = text.replace("\\t", " ")
|
|
53
|
+
text = text.replace("\\n", "\n")
|
|
54
|
+
text = text.replace('\\"', '"')
|
|
55
|
+
text = text.replace("\\\\", "\\")
|
|
56
|
+
return text
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _clean_url(url: str) -> str:
|
|
60
|
+
"""清洗单条 URL:按换行截断、去掉尾部标点与反斜杠。"""
|
|
61
|
+
url = url.split("\n")[0]
|
|
62
|
+
url = url.rstrip(".,;:!?`)]}>'\"")
|
|
63
|
+
while url.endswith("\\") or url.endswith("`"):
|
|
64
|
+
url = url[:-1]
|
|
65
|
+
return url
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_junk(url: str) -> bool:
|
|
69
|
+
"""过滤占位符/无意义链接。"""
|
|
70
|
+
if "..." in url:
|
|
71
|
+
return True
|
|
72
|
+
# http://localhost: 但没有端口
|
|
73
|
+
if re.match(r"^http://localhost:(\D|$)", url):
|
|
74
|
+
return True
|
|
75
|
+
# 清洗后只剩 http://localhost(无端口)
|
|
76
|
+
if re.match(r"^http://localhost$", url):
|
|
77
|
+
return True
|
|
78
|
+
if url.endswith(":\\") or url.endswith(":/"):
|
|
79
|
+
return True
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract_urls_from_chunk(chunk: dict[str, Any]) -> list[str]:
|
|
84
|
+
"""从单个轮次 chunk 中提取预览 URL 列表。"""
|
|
85
|
+
data = chunk.get("data")
|
|
86
|
+
if data is None:
|
|
87
|
+
return []
|
|
88
|
+
text = _to_text(_decode_data(data))
|
|
89
|
+
text = _unescape(text)
|
|
90
|
+
urls: list[str] = []
|
|
91
|
+
for m in _URL_RE.finditer(text):
|
|
92
|
+
raw_url = m.group(0)
|
|
93
|
+
if _is_junk(raw_url):
|
|
94
|
+
continue
|
|
95
|
+
url = _clean_url(raw_url)
|
|
96
|
+
if url and not _is_junk(url) and _PREVIEW_HOST_RE.search(url):
|
|
97
|
+
urls.append(url)
|
|
98
|
+
return urls
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check_url_status(url: str) -> tuple[str, int | None]:
|
|
102
|
+
"""探测链接存活状态,返回 (状态标记, HTTP 状态码)。
|
|
103
|
+
|
|
104
|
+
- HTTP < 400 -> ("在线", code)
|
|
105
|
+
- HTTP >= 400 -> ("异常", code)
|
|
106
|
+
- 连接失败 -> ("已关闭", None)
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
resp = httpx.get(url, timeout=8, follow_redirects=True)
|
|
110
|
+
code = resp.status_code
|
|
111
|
+
if code < 400:
|
|
112
|
+
return ("在线", code)
|
|
113
|
+
return ("异常", code)
|
|
114
|
+
except Exception:
|
|
115
|
+
return ("已关闭", None)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def collect_preview_links(
|
|
119
|
+
get_rounds, task_id: str, max_pages: int = _MAX_PAGES
|
|
120
|
+
) -> dict[str, int]:
|
|
121
|
+
"""分页拉取任务轮次,收集预览链接及出现次数。
|
|
122
|
+
|
|
123
|
+
get_rounds: 可调用对象,签名 (task_id, cursor, limit) -> dict
|
|
124
|
+
"""
|
|
125
|
+
counts: dict[str, int] = {}
|
|
126
|
+
cursor: str | None = None
|
|
127
|
+
for _ in range(max_pages):
|
|
128
|
+
data = get_rounds(task_id, cursor=cursor, limit=10)
|
|
129
|
+
if not isinstance(data, dict):
|
|
130
|
+
break
|
|
131
|
+
for chunk in data.get("chunks") or []:
|
|
132
|
+
for url in extract_urls_from_chunk(chunk):
|
|
133
|
+
counts[url] = counts.get(url, 0) + 1
|
|
134
|
+
if data.get("has_more") and data.get("next_cursor"):
|
|
135
|
+
cursor = data["next_cursor"]
|
|
136
|
+
else:
|
|
137
|
+
break
|
|
138
|
+
return counts
|
monkeycode/models.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""数据模型(轻量 dataclass,不引入 pydantic 依赖)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class RepoReq:
|
|
11
|
+
"""任务仓库信息。"""
|
|
12
|
+
|
|
13
|
+
repo_url: str
|
|
14
|
+
branch: str = "main"
|
|
15
|
+
repo_filename: str = ""
|
|
16
|
+
zip_url: str = ""
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, Any]:
|
|
19
|
+
return {
|
|
20
|
+
"repo_url": self.repo_url,
|
|
21
|
+
"branch": self.branch,
|
|
22
|
+
"repo_filename": self.repo_filename,
|
|
23
|
+
"zip_url": self.zip_url,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class VMResource:
|
|
29
|
+
"""云端虚拟机资源。
|
|
30
|
+
|
|
31
|
+
默认值为 MonkeyCode 桌面端建任务的默认档位:
|
|
32
|
+
2 核 / 8GB 内存 / 3 小时。
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
core: int = 2
|
|
36
|
+
memory: int = 8 << 30 # 8GB(与桌面端常量一致,字节为单位)
|
|
37
|
+
life: int = 3 * 60 * 60 # 3 小时(秒)
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"core": self.core,
|
|
42
|
+
"memory": self.memory,
|
|
43
|
+
"life": self.life,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class TaskExtraConfig:
|
|
49
|
+
"""任务扩展配置(对应 domain.TaskExtraConfig)。"""
|
|
50
|
+
|
|
51
|
+
skill_ids: list[str] | None = None
|
|
52
|
+
plugin_ids: list[str] | None = None
|
|
53
|
+
project_id: str | None = None
|
|
54
|
+
issue_id: str | None = None
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
out: dict[str, Any] = {}
|
|
58
|
+
if self.skill_ids:
|
|
59
|
+
out["skill_ids"] = self.skill_ids
|
|
60
|
+
if self.plugin_ids:
|
|
61
|
+
out["plugin_ids"] = self.plugin_ids
|
|
62
|
+
if self.project_id:
|
|
63
|
+
out["project_id"] = self.project_id
|
|
64
|
+
if self.issue_id:
|
|
65
|
+
out["issue_id"] = self.issue_id
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Task:
|
|
71
|
+
"""任务对象(响应的精简视图)。"""
|
|
72
|
+
|
|
73
|
+
id: str
|
|
74
|
+
status: str
|
|
75
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def title(self) -> str:
|
|
79
|
+
return str(self.raw.get("title") or "")
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def summary(self) -> str:
|
|
83
|
+
return str(self.raw.get("summary") or "")
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def content(self) -> str:
|
|
87
|
+
return str(self.raw.get("content") or "")
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def branch(self) -> str:
|
|
91
|
+
return str(self.raw.get("branch") or "")
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def repo_url(self) -> str:
|
|
95
|
+
return str(self.raw.get("repo_url") or "")
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def pr_url(self) -> str | None:
|
|
99
|
+
return (self.raw.get("pr_url") or self.raw.get("pull_request_url")) or None
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def model(self) -> dict[str, Any] | None:
|
|
103
|
+
return self.raw.get("model") or None
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def image(self) -> dict[str, Any] | None:
|
|
107
|
+
return self.raw.get("image") or None
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def extra(self) -> TaskExtraConfig | None:
|
|
111
|
+
extra = self.raw.get("extra")
|
|
112
|
+
if isinstance(extra, dict):
|
|
113
|
+
return TaskExtraConfig(
|
|
114
|
+
skill_ids=extra.get("skill_ids"),
|
|
115
|
+
plugin_ids=extra.get("plugin_ids"),
|
|
116
|
+
project_id=extra.get("project_id"),
|
|
117
|
+
issue_id=extra.get("issue_id"),
|
|
118
|
+
)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def created_at(self) -> int | None:
|
|
123
|
+
v = self.raw.get("created_at")
|
|
124
|
+
return int(v) if v is not None else None
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def completed_at(self) -> int | None:
|
|
128
|
+
v = self.raw.get("completed_at")
|
|
129
|
+
return int(v) if v is not None else None
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def result(self) -> Any:
|
|
133
|
+
return self.raw.get("result")
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_dict(cls, data: dict[str, Any]) -> "Task":
|
|
137
|
+
return cls(
|
|
138
|
+
id=str(data.get("id") or data.get("task_id") or ""),
|
|
139
|
+
status=str(data.get("status") or ""),
|
|
140
|
+
raw=data,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class TaskRound:
|
|
146
|
+
"""任务历史轮次(domain.TaskRound 精简视图)。"""
|
|
147
|
+
|
|
148
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def seq(self) -> int | None:
|
|
152
|
+
v = self.raw.get("seq")
|
|
153
|
+
return int(v) if v is not None else None
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def role(self) -> str:
|
|
157
|
+
return str(self.raw.get("role") or "")
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def content(self) -> str:
|
|
161
|
+
return str(self.raw.get("content") or "")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class CaptchaChallenge:
|
|
166
|
+
"""验证码质询。"""
|
|
167
|
+
|
|
168
|
+
token: str
|
|
169
|
+
expires: int
|
|
170
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class TaskStreamChunk:
|
|
175
|
+
"""任务 WebSocket 流式事件。"""
|
|
176
|
+
|
|
177
|
+
type: str
|
|
178
|
+
data: Any = None
|
|
179
|
+
timestamp: int = 0
|
|
180
|
+
turn_seq: int = 0
|
|
181
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class UserInputPayload:
|
|
186
|
+
"""上行用户输入(user-input 内层数据)。"""
|
|
187
|
+
|
|
188
|
+
content: str
|
|
189
|
+
attachments: list[dict] = field(default_factory=list)
|
|
190
|
+
|
|
191
|
+
def encode(self) -> str:
|
|
192
|
+
"""按协议编码为内层 JSON 字符串(content 为 base64)。"""
|
|
193
|
+
import base64
|
|
194
|
+
|
|
195
|
+
inner = {
|
|
196
|
+
"content": base64.b64encode(self.content.encode("utf-8")).decode("ascii"),
|
|
197
|
+
"attachments": self.attachments,
|
|
198
|
+
}
|
|
199
|
+
return json.dumps(inner, ensure_ascii=False)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class CaptchaVerification:
|
|
204
|
+
"""验证码兑换结果。"""
|
|
205
|
+
|
|
206
|
+
success: bool
|
|
207
|
+
token: str = ""
|
|
208
|
+
message: str = ""
|
|
209
|
+
expires: int = 0
|
|
210
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class APIKeyInfo:
|
|
215
|
+
"""OhMyAgent API Key(仅创建响应返回明文,需立即保存)。"""
|
|
216
|
+
|
|
217
|
+
api_key: str
|
|
218
|
+
key_id: str = ""
|
|
219
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
220
|
+
|
|
221
|
+
@classmethod
|
|
222
|
+
def from_dict(cls, data: dict[str, Any]) -> "APIKeyInfo":
|
|
223
|
+
return cls(
|
|
224
|
+
api_key=str(data.get("api_key") or data.get("apiKey") or ""),
|
|
225
|
+
key_id=str(data.get("key_id") or data.get("id") or ""),
|
|
226
|
+
raw=data,
|
|
227
|
+
)
|
monkeycode/pow.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""MonkeyCode PoW 验证码求解器。
|
|
2
|
+
|
|
3
|
+
协议:SHA-256 前缀碰撞(工作量证明),无需识别图片。
|
|
4
|
+
流程:
|
|
5
|
+
1. POST /api/v1/public/captcha/challenge 获取 {challenge: {c,s,d}, token}
|
|
6
|
+
2. 对每个 i(1..c):用确定性 PRNG 生成 salt 和 target,
|
|
7
|
+
爆破 nonce 使 sha256(salt + nonce) 以 target 开头
|
|
8
|
+
3. POST /api/v1/public/captcha/redeem 提交 solutions,得到 captcha_token
|
|
9
|
+
|
|
10
|
+
参考实现:chaitin/MonkeyCode desktop/src/baizhi/pow.rs
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fnv1a32(s: str) -> int:
|
|
18
|
+
"""FNV-1a 32 位哈希,用于播种 PRNG。"""
|
|
19
|
+
h = 0x811C9DC5
|
|
20
|
+
for byte in s.encode("utf-8"):
|
|
21
|
+
h ^= byte
|
|
22
|
+
h = (h * 0x01000193) & 0xFFFFFFFF
|
|
23
|
+
return h
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def prng(seed: str, length: int) -> str:
|
|
27
|
+
"""xorshift32 确定性 PRNG,生成指定长度的十六进制串。"""
|
|
28
|
+
state = fnv1a32(seed)
|
|
29
|
+
result: list[str] = []
|
|
30
|
+
total = 0
|
|
31
|
+
while total < length:
|
|
32
|
+
state ^= (state << 13) & 0xFFFFFFFF
|
|
33
|
+
state ^= state >> 17
|
|
34
|
+
state ^= (state << 5) & 0xFFFFFFFF
|
|
35
|
+
state &= 0xFFFFFFFF
|
|
36
|
+
result.append(f"{state:08x}")
|
|
37
|
+
total += 8
|
|
38
|
+
return "".join(result)[:length]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def solve(
|
|
42
|
+
token: str,
|
|
43
|
+
c: int,
|
|
44
|
+
s: int,
|
|
45
|
+
d: int,
|
|
46
|
+
max_nonce: int = 5_000_000,
|
|
47
|
+
) -> list[int]:
|
|
48
|
+
"""求解 PoW 验证码,返回 solutions(每个子质询的 nonce)。"""
|
|
49
|
+
solutions: list[int] = []
|
|
50
|
+
for i in range(1, c + 1):
|
|
51
|
+
base = f"{token}{i}"
|
|
52
|
+
salt = prng(base, s)
|
|
53
|
+
target = prng(f"{base}d", d)
|
|
54
|
+
nonce = 0
|
|
55
|
+
while True:
|
|
56
|
+
digest = hashlib.sha256(f"{salt}{nonce}".encode("utf-8")).hexdigest()
|
|
57
|
+
if digest.startswith(target):
|
|
58
|
+
solutions.append(nonce)
|
|
59
|
+
break
|
|
60
|
+
nonce += 1
|
|
61
|
+
if nonce > max_nonce:
|
|
62
|
+
raise TimeoutError(
|
|
63
|
+
f"子质询 {i} 超过最大尝试次数 {max_nonce}(salt={salt}, target={target})"
|
|
64
|
+
)
|
|
65
|
+
return solutions
|
monkeycode/session.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""任务高层会话封装(轻量自动重连 + 事件缓冲)。
|
|
2
|
+
|
|
3
|
+
AgentSession 在 TaskStream 之上提供:
|
|
4
|
+
- 断线自动重连(可配置次数与间隔)
|
|
5
|
+
- 后台事件拉取与 asyncio.Queue 缓冲
|
|
6
|
+
- 发送消息前确保连接可用
|
|
7
|
+
|
|
8
|
+
注意:不做消息乱序去重。服务端事件通常按顺序到达,
|
|
9
|
+
turn_seq 仅作标识;如需严格有序,调用方可自行按 turn_seq 排序。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from typing import Any, AsyncIterator
|
|
15
|
+
|
|
16
|
+
import websockets
|
|
17
|
+
|
|
18
|
+
from monkeycode.models import TaskStreamChunk
|
|
19
|
+
from monkeycode.stream import TaskStream
|
|
20
|
+
|
|
21
|
+
_TERMINAL_TYPES = {"task-ended", "task-error"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AgentSession:
|
|
25
|
+
"""任务高层会话。
|
|
26
|
+
|
|
27
|
+
用法:
|
|
28
|
+
session = AgentSession(client, task_id, mode="new")
|
|
29
|
+
await session.start()
|
|
30
|
+
await session.send_user_input("继续")
|
|
31
|
+
async for chunk in session.events():
|
|
32
|
+
print(chunk.type, chunk.data)
|
|
33
|
+
if chunk.type == "task-ended":
|
|
34
|
+
break
|
|
35
|
+
await session.close()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
client: Any,
|
|
41
|
+
task_id: str,
|
|
42
|
+
mode: str = "new",
|
|
43
|
+
max_reconnects: int = 3,
|
|
44
|
+
reconnect_delay: float = 1.0,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.client = client
|
|
47
|
+
self.task_id = task_id
|
|
48
|
+
self.mode = mode
|
|
49
|
+
self.max_reconnects = max_reconnects
|
|
50
|
+
self.reconnect_delay = reconnect_delay
|
|
51
|
+
self._stream = TaskStream(client, task_id, mode=mode)
|
|
52
|
+
self._queue: asyncio.Queue[TaskStreamChunk] = asyncio.Queue()
|
|
53
|
+
self._pump_task: asyncio.Task[None] | None = None
|
|
54
|
+
self._running = False
|
|
55
|
+
|
|
56
|
+
async def start(self) -> None:
|
|
57
|
+
"""建立连接并启动后台事件拉取循环。"""
|
|
58
|
+
await self._ensure_connected()
|
|
59
|
+
if self._running:
|
|
60
|
+
return
|
|
61
|
+
self._running = True
|
|
62
|
+
self._pump_task = asyncio.create_task(self._pump())
|
|
63
|
+
|
|
64
|
+
async def _ensure_connected(self) -> None:
|
|
65
|
+
"""确保 WebSocket 已连接;断线时自动重连。"""
|
|
66
|
+
if self._stream._ws is not None:
|
|
67
|
+
return
|
|
68
|
+
attempts = 0
|
|
69
|
+
while True:
|
|
70
|
+
try:
|
|
71
|
+
await self._stream.connect()
|
|
72
|
+
return
|
|
73
|
+
except Exception:
|
|
74
|
+
attempts += 1
|
|
75
|
+
if attempts > self.max_reconnects:
|
|
76
|
+
raise
|
|
77
|
+
await asyncio.sleep(self.reconnect_delay)
|
|
78
|
+
|
|
79
|
+
async def _pump(self) -> None:
|
|
80
|
+
"""后台拉取事件放入队列;断线时自动重连。"""
|
|
81
|
+
while self._running:
|
|
82
|
+
try:
|
|
83
|
+
chunk = await self._stream.recv()
|
|
84
|
+
await self._queue.put(chunk)
|
|
85
|
+
if chunk.type in _TERMINAL_TYPES:
|
|
86
|
+
break
|
|
87
|
+
except (websockets.ConnectionClosed, asyncio.TimeoutError, OSError):
|
|
88
|
+
if not self._running:
|
|
89
|
+
break
|
|
90
|
+
try:
|
|
91
|
+
await self._ensure_connected()
|
|
92
|
+
except Exception:
|
|
93
|
+
# 重连失败:结束事件循环,由调用方决定后续
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
async def send_user_input(
|
|
97
|
+
self, content: str, attachments: list[dict] | None = None
|
|
98
|
+
) -> None:
|
|
99
|
+
"""发送用户消息;断线时自动重连后重发。"""
|
|
100
|
+
await self._ensure_connected()
|
|
101
|
+
await self._stream.send_user_input(content, attachments)
|
|
102
|
+
|
|
103
|
+
async def send_reply_question(
|
|
104
|
+
self, request_id: str, answers: list[str]
|
|
105
|
+
) -> None:
|
|
106
|
+
"""回复 AI 提问(自动确保连接可用)。"""
|
|
107
|
+
await self._ensure_connected()
|
|
108
|
+
await self._stream.send_reply_question(request_id, answers)
|
|
109
|
+
|
|
110
|
+
async def send_auto_approve(self, enable: bool = True) -> None:
|
|
111
|
+
"""开启/关闭自动批准(自动确保连接可用)。"""
|
|
112
|
+
await self._ensure_connected()
|
|
113
|
+
await self._stream.send_auto_approve(enable)
|
|
114
|
+
|
|
115
|
+
async def events(self) -> AsyncIterator[TaskStreamChunk]:
|
|
116
|
+
"""异步迭代事件队列,直到任务结束或出错。"""
|
|
117
|
+
while True:
|
|
118
|
+
chunk = await self._queue.get()
|
|
119
|
+
yield chunk
|
|
120
|
+
if chunk.type in _TERMINAL_TYPES:
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
async def close(self) -> None:
|
|
124
|
+
"""停止后台拉取并关闭连接。"""
|
|
125
|
+
self._running = False
|
|
126
|
+
if self._pump_task is not None:
|
|
127
|
+
self._pump_task.cancel()
|
|
128
|
+
try:
|
|
129
|
+
await self._pump_task
|
|
130
|
+
except (asyncio.CancelledError, Exception):
|
|
131
|
+
pass
|
|
132
|
+
self._pump_task = None
|
|
133
|
+
await self._stream.close()
|
|
134
|
+
|
|
135
|
+
async def __aenter__(self) -> "AgentSession":
|
|
136
|
+
await self.start()
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
140
|
+
await self.close()
|