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/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()