codebee 0.1.0

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/app/__init__.py +0 -0
  4. package/app/core/__init__.py +0 -0
  5. package/app/core/attachments.py +322 -0
  6. package/app/core/automation.py +585 -0
  7. package/app/core/bookmeta.py +296 -0
  8. package/app/core/capability.py +130 -0
  9. package/app/core/catalog.py +319 -0
  10. package/app/core/compaction.py +186 -0
  11. package/app/core/diagnostics.py +115 -0
  12. package/app/core/env_scrub.py +84 -0
  13. package/app/core/error_codes.py +65 -0
  14. package/app/core/flows.py +328 -0
  15. package/app/core/gitmod.py +949 -0
  16. package/app/core/goal_service.py +159 -0
  17. package/app/core/health.py +294 -0
  18. package/app/core/history.py +32 -0
  19. package/app/core/jobs.py +424 -0
  20. package/app/core/manager.py +1415 -0
  21. package/app/core/market.py +299 -0
  22. package/app/core/market_remote.py +896 -0
  23. package/app/core/mocks.py +64 -0
  24. package/app/core/modelhub.py +2750 -0
  25. package/app/core/paths.py +60 -0
  26. package/app/core/pipeline.py +2161 -0
  27. package/app/core/planner.py +493 -0
  28. package/app/core/registry.py +105 -0
  29. package/app/core/remote.py +303 -0
  30. package/app/core/repeat_guard.py +124 -0
  31. package/app/core/router.py +120 -0
  32. package/app/core/runner.py +856 -0
  33. package/app/core/selfupdate.py +170 -0
  34. package/app/core/session_log.py +162 -0
  35. package/app/core/sessions.py +312 -0
  36. package/app/core/settings.py +85 -0
  37. package/app/core/settings_schema.py +250 -0
  38. package/app/core/skillpacks/fanqie-novel.md +80 -0
  39. package/app/core/skillpacks/market/character-bible.md +66 -0
  40. package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
  41. package/app/core/skillpacks/market/git-workflow.md +57 -0
  42. package/app/core/skillpacks/market/release-notes.md +72 -0
  43. package/app/core/skillpacks/market/weekly-report.md +71 -0
  44. package/app/core/skillpacks/market/worldview-consistency.md +70 -0
  45. package/app/core/skillpacks/qimao-signing.md +105 -0
  46. package/app/core/skills.py +649 -0
  47. package/app/core/step_runner.py +61 -0
  48. package/app/core/store.py +1321 -0
  49. package/app/core/token_meter.py +130 -0
  50. package/app/core/usage.py +450 -0
  51. package/app/main.py +1448 -0
  52. package/app/ui/app.js +8021 -0
  53. package/app/ui/i18n.js +1709 -0
  54. package/app/ui/icons/brand-horizontal.png +0 -0
  55. package/app/ui/icons/brand-square.png +0 -0
  56. package/app/ui/icons/icon-192.png +0 -0
  57. package/app/ui/icons/icon-512.png +0 -0
  58. package/app/ui/icons/logo-horizontal.png +0 -0
  59. package/app/ui/icons/logo-mark.png +0 -0
  60. package/app/ui/index.html +864 -0
  61. package/app/ui/manifest.json +16 -0
  62. package/app/ui/qrcode.js +2297 -0
  63. package/app/ui/style.css +2733 -0
  64. package/bin/tutti.js +121 -0
  65. package/package.json +39 -0
@@ -0,0 +1,322 @@
1
+ # -*- coding: utf-8 -*-
2
+ """任务附件(截图/文件):先落「待提交区」,创建任务时移入工作目录 _attachments/。
3
+
4
+ 流程:UI 选好/粘贴文件 → POST /api/attachments(base64)→ save_pending 落
5
+ data/pending/<id>;创建任务时 payload.attachments 带 id 列表 →
6
+ commit_to_workdir 移入 <workdir>/_attachments/ 并把路径清单注入任务上下文
7
+ (context),所有 __CONTEXT__ 注入点(规划/实现/评审/起草)自然可见。
8
+ 图片附件另走 run_agent(images=...)(codex --image),供起草/实现智能体直接"看图"。
9
+
10
+ 安全:文件名去路径化并挡 ..;扩展名白名单;单文件/总数量上限;
11
+ 待提交区有兜底清理(启动时 + 每次提交时顺手),防止崩溃残留堆积。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import html as _html
17
+ import json
18
+ import mimetypes
19
+ import re
20
+ import secrets
21
+ import time
22
+ import zipfile
23
+ from pathlib import Path
24
+
25
+ from . import paths
26
+
27
+ MAX_FILES = 12
28
+ MAX_BYTES = 8 * 1024 * 1024 # 单文件 8MB:截图/文本足够,挡恶意大包
29
+ # Office 文档压缩包实际可到几十 MB(带图的 Excel 很常见),放宽到 24MB
30
+ OFFICE_MAX_BYTES = 24 * 1024 * 1024
31
+ _ALLOWED_EXT = {
32
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg",
33
+ ".txt", ".md", ".markdown", ".csv", ".json", ".log",
34
+ ".py", ".js", ".ts", ".html", ".css", ".xml", ".yaml", ".yml", ".toml",
35
+ ".pdf",
36
+ # Office 文档:现代 zip 格式(docx/xlsx/pptx)会另抽文本伴生文件;
37
+ # 老式二进制(doc/xls/ppt)与 rtf/odt/ods 只落盘,由能读文件的智能体自行处理
38
+ ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
39
+ ".rtf", ".odt", ".ods",
40
+ }
41
+ # 可零依赖抽文本的 zip 系 Office 格式(zipfile + ElementTree,无第三方库)
42
+ _TEXT_EXTRACT_EXT = {".docx", ".xlsx", ".pptx"}
43
+ _EXTRACT_MAX_CHARS = 200000 # 伴生文本上限,防巨型文档灌爆上下文
44
+ _ID_RE = re.compile(r"^[0-9a-f]{16}$")
45
+ # 控制字符/Windows 非法字符/路径分隔一律清掉;中文名保留(落盘和 CLI 都吃得下)
46
+ _NAME_BAD = re.compile(r"[\\/:*?\"<>|\x00-\x1f]")
47
+
48
+
49
+ def _pending_dir() -> Path:
50
+ d = paths.DATA_DIR / "pending"
51
+ d.mkdir(parents=True, exist_ok=True)
52
+ return d
53
+
54
+
55
+ def _safe_name(raw):
56
+ name = str(raw or "file").split("/")[-1].split("\\")[-1].strip()
57
+ name = _NAME_BAD.sub("_", name).strip(". ")
58
+ if not name:
59
+ name = "file"
60
+ return name[:120]
61
+
62
+
63
+ def save_pending(name, data_b64):
64
+ """校验并保存一个待提交附件。返回 {id,name,size,mime};失败抛 ValueError。"""
65
+ name = _safe_name(name)
66
+ ext = Path(name).suffix.lower()
67
+ if ext not in _ALLOWED_EXT:
68
+ raise ValueError("不支持的附件类型 %s(支持图片/文本/Word/Excel/PPT/PDF)"
69
+ % (ext or "无扩展名"))
70
+ try:
71
+ data = base64.b64decode(str(data_b64 or ""), validate=True)
72
+ except Exception:
73
+ raise ValueError("附件数据损坏(base64 解码失败)")
74
+ if not data:
75
+ raise ValueError("附件内容为空: %s" % name)
76
+ cap = OFFICE_MAX_BYTES if ext in _TEXT_EXTRACT_EXT or ext in (
77
+ ".doc", ".xls", ".ppt", ".rtf", ".odt", ".ods") else MAX_BYTES
78
+ if len(data) > cap:
79
+ raise ValueError("附件超过 %dMB: %s" % (cap // 1024 // 1024, name))
80
+ fid = secrets.token_hex(8)
81
+ mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
82
+ (_pending_dir() / fid).write_bytes(data)
83
+ meta = {"id": fid, "name": name, "size": len(data), "mime": mime}
84
+ (_pending_dir() / (fid + ".json")).write_text(
85
+ json.dumps(meta, ensure_ascii=False), encoding="utf-8")
86
+ return meta
87
+
88
+
89
+ _XML_PART_MAX = 20 * 1024 * 1024 # 单个 Office 部件(XML 条目)读入上限
90
+
91
+
92
+ def _safe_xml(data):
93
+ """解析不可信 XML 前的防护:拒 DTD/实体声明(防实体扩展炸弹)+ 限尺寸。
94
+ 正常 Office 部件不含 DOCTYPE;触发即视为恶意包,由上层放弃抽取。"""
95
+ if len(data) > _XML_PART_MAX:
96
+ raise ValueError("xml part too large")
97
+ head = data[:4096].lstrip()
98
+ if head.startswith(b"<?xml"):
99
+ head = head.split(b"?>", 1)[-1].lstrip()
100
+ if head[:9].lower() == b"<!doctype" or b"<!ENTITY" in data[:65536].upper():
101
+ raise ValueError("dtd/entity not allowed")
102
+ import xml.etree.ElementTree as ET
103
+ return ET.fromstring(data)
104
+
105
+
106
+ def _zip_read(zf, name):
107
+ """按尺寸上限读 zip 条目;超限抛错(上层吞掉放弃抽取)。"""
108
+ info = zf.getinfo(name)
109
+ if info.file_size > _XML_PART_MAX:
110
+ raise ValueError("zip entry too large: %s" % name)
111
+ return zf.read(name)
112
+
113
+
114
+ def _xml_text(xml_bytes):
115
+ """粗提 XML 里的可见文本:非文本标签当段落边界,仅保留 w:t/t/a:t 内容。
116
+ 正则剥离本身不展开实体,但仍拒含 DTD/实体的部件(恶意包特征):返回 None。"""
117
+ head = xml_bytes[:4096].lstrip()
118
+ if head.startswith(b"<?xml"):
119
+ head = head.split(b"?>", 1)[-1].lstrip()
120
+ if head[:9].lower() == b"<!doctype" or b"<!ENTITY" in xml_bytes[:65536].upper():
121
+ raise ValueError("dtd/entity not allowed")
122
+ txt = xml_bytes.decode("utf-8", "replace")
123
+ txt = re.sub(r"<(?!/?(?:w:t|t|a:t)\b)[^>]*>", "\n", txt)
124
+ txt = re.sub(r"<[^>]+>", "", txt)
125
+ return _html.unescape(txt)
126
+
127
+
128
+ def _xlsx_text(zf):
129
+ """xlsx → 工作表文本:共享字符串 + 各 sheet 单元格按行列还原(够 agent 看内容)。"""
130
+ ns = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
131
+ "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships"}
132
+ mtag = "{%s}" % ns["m"]
133
+ shared = []
134
+ if "xl/sharedStrings.xml" in zf.namelist():
135
+ root = _safe_xml(_zip_read(zf, "xl/sharedStrings.xml"))
136
+ for si in root.findall("m:si", ns):
137
+ shared.append("".join(t.text or "" for t in si.iter(mtag + "t")))
138
+ out = []
139
+ wb = _safe_xml(_zip_read(zf, "xl/workbook.xml"))
140
+ rels = {}
141
+ if "xl/_rels/workbook.xml.rels" in zf.namelist():
142
+ for rel in _safe_xml(_zip_read(zf, "xl/_rels/workbook.xml.rels")):
143
+ rels[rel.get("Id")] = rel.get("Target") or ""
144
+ for sh in wb.find("m:sheets", ns) or []:
145
+ name = sh.get("name") or "Sheet"
146
+ rid = sh.get("{%s}id" % ns["r"]) or ""
147
+ target = rels.get(rid, "")
148
+ if target and not target.startswith("xl/"):
149
+ target = "xl/" + target.lstrip("/")
150
+ if target not in zf.namelist():
151
+ continue
152
+ lines = ["### 工作表: " + name]
153
+ try:
154
+ ws = _safe_xml(_zip_read(zf, target))
155
+ for row in ws.iter(mtag + "row"):
156
+ cells = []
157
+ for c in row.findall("m:c", ns):
158
+ t = c.get("t")
159
+ if t == "s":
160
+ v = c.find("m:v", ns)
161
+ cells.append(shared[int(v.text)] if v is not None
162
+ and v.text and int(v.text) < len(shared) else "")
163
+ elif t == "inlineStr":
164
+ is_el = c.find("m:is", ns)
165
+ cells.append("".join(x.text or "" for x in is_el.iter(mtag + "t"))
166
+ if is_el is not None else "")
167
+ else:
168
+ v = c.find("m:v", ns)
169
+ cells.append((v.text or "") if v is not None else "")
170
+ if any(cells):
171
+ lines.append("\t".join(cells))
172
+ except Exception:
173
+ lines.append("(该工作表解析失败)")
174
+ out.append("\n".join(lines))
175
+ return "\n\n".join(out)
176
+
177
+
178
+ def extract_office_text(path):
179
+ """docx/xlsx/pptx → 纯文本(零依赖 zipfile+ElementTree,带 DTD/实体防护)。
180
+ 失败/无文本返回 None——绝不因抽取失败拒收附件。"""
181
+ try:
182
+ with zipfile.ZipFile(str(path)) as zf:
183
+ names = zf.namelist()
184
+ if "word/document.xml" in names:
185
+ txt = _xml_text(_zip_read(zf, "word/document.xml"))
186
+ elif "xl/workbook.xml" in names:
187
+ txt = _xlsx_text(zf)
188
+ else:
189
+ slides = sorted((n for n in names
190
+ if re.match(r"^ppt/slides/slide\d+\.xml$", n)),
191
+ key=lambda n: int(re.search(r"(\d+)", n).group(1)))[:100]
192
+ if not slides:
193
+ return None
194
+ txt = "\n\n".join(
195
+ "### 幻灯片 %d\n%s" % (i + 1, _xml_text(_zip_read(zf, n)).strip())
196
+ for i, n in enumerate(slides))
197
+ txt = re.sub(r"\n{3,}", "\n\n", txt or "").strip()
198
+ if not txt:
199
+ return None
200
+ if len(txt) > _EXTRACT_MAX_CHARS:
201
+ txt = txt[:_EXTRACT_MAX_CHARS] + "\n…(超长截断)"
202
+ return txt
203
+ except Exception:
204
+ return None
205
+
206
+
207
+ def commit_to_workdir(workdir, ids):
208
+ """把待提交附件移入 <workdir>/_attachments/,返回 [{name,size,mime,path}]。
209
+
210
+ path 为工作目录内相对路径(正斜杠)。非法/已丢失的 id 静默跳过
211
+ (上传与创建之间隔着用户编辑,包不能因个别文件过期而整体失败;
212
+ 跳过的数量由调用方对照 UI 列表可见)。
213
+ """
214
+ out = []
215
+ pend = _pending_dir()
216
+ adir = Path(workdir) / "_attachments"
217
+ for fid in (ids or [])[:MAX_FILES]:
218
+ fid = str(fid)
219
+ if not _ID_RE.match(fid):
220
+ continue
221
+ f = pend / fid
222
+ if not f.is_file():
223
+ continue
224
+ meta = {}
225
+ try:
226
+ meta = json.loads((pend / (fid + ".json")).read_text(encoding="utf-8"))
227
+ except Exception:
228
+ pass
229
+ name = _safe_name(meta.get("name") or fid)
230
+ stem, ext = Path(name).stem, Path(name).suffix
231
+ i = 2
232
+ while (adir / name).exists():
233
+ name = "%s-%d%s" % (stem, i, ext)
234
+ i += 1
235
+ try:
236
+ adir.mkdir(parents=True, exist_ok=True)
237
+ f.replace(adir / name) # 同盘 move;跨盘回落 copy+delete
238
+ if not (adir / name).is_file():
239
+ (adir / name).write_bytes(f.read_bytes())
240
+ f.unlink(missing_ok=True)
241
+ except OSError:
242
+ continue
243
+ finally:
244
+ (pend / (fid + ".json")).unlink(missing_ok=True)
245
+ item = {"name": name,
246
+ "size": meta.get("size") or (adir / name).stat().st_size,
247
+ "mime": meta.get("mime") or "application/octet-stream",
248
+ "path": "_attachments/" + name}
249
+ # Office zip 文档抽正文 → 伴生 .txt:无头 CLI 读不了二进制容器,
250
+ # 有文本版所有智能体都能直接读;抽取失败只降级不拒收
251
+ if Path(name).suffix.lower() in _TEXT_EXTRACT_EXT:
252
+ try:
253
+ body = extract_office_text(adir / name)
254
+ except Exception:
255
+ body = None
256
+ if body:
257
+ side = name + ".txt"
258
+ try:
259
+ (adir / side).write_text(body, encoding="utf-8")
260
+ item["text_path"] = "_attachments/" + side
261
+ except OSError:
262
+ pass
263
+ out.append(item)
264
+ return out
265
+
266
+
267
+ def context_block(items):
268
+ """附件清单文本,追加到任务 context。相对 workdir,重试/续跑同目录仍有效。"""
269
+ if not items:
270
+ return ""
271
+ lines = ["", "## 附件材料(位于工作目录 _attachments/,可直接读取)"]
272
+ for a in items:
273
+ kind = "图片" if str(a.get("mime", "")).startswith("image/") else "文件"
274
+ line = "- %s(%s,%s)" % (a["path"], kind, _human(a.get("size") or 0))
275
+ if a.get("text_path"):
276
+ line += ",正文文本版见 %s(优先读它)" % a["text_path"]
277
+ lines.append(line)
278
+ lines.append("请在处理目标时参考以上附件;图片附件可直接查看内容。")
279
+ return "\n".join(lines)
280
+
281
+
282
+ def image_paths(task, workdir, limit=6):
283
+ """任务图片附件的绝对路径(传给 codex --image)。缺失的跳过。"""
284
+ out = []
285
+ for a in (task.get("attachments") or []):
286
+ if not str(a.get("mime", "")).startswith("image/"):
287
+ continue
288
+ p = Path(workdir) / str(a.get("path") or "")
289
+ try:
290
+ if a.get("path") and p.is_file():
291
+ out.append(str(p))
292
+ except OSError:
293
+ continue
294
+ if len(out) >= limit:
295
+ break
296
+ return out
297
+
298
+
299
+ def cleanup_stale(max_age_s=7 * 86400):
300
+ """清理超过 max_age_s 的待提交残留(启动时调用)。返回删除数。"""
301
+ n = 0
302
+ try:
303
+ pend = _pending_dir()
304
+ except OSError:
305
+ return 0
306
+ now = time.time()
307
+ for f in pend.glob("*"):
308
+ try:
309
+ if now - f.stat().st_mtime > max_age_s:
310
+ f.unlink()
311
+ n += 1
312
+ except OSError:
313
+ pass
314
+ return n
315
+
316
+
317
+ def _human(n):
318
+ if n >= 1024 * 1024:
319
+ return "%.1fMB" % (n / 1024 / 1024)
320
+ if n >= 1024:
321
+ return "%dKB" % (n // 1024)
322
+ return "%dB" % n