codebee 0.1.69 → 0.1.70

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 (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +4 -3
  3. package/app/browser-final-1440.png +0 -0
  4. package/app/browser-final-390.png +0 -0
  5. package/app/browser-google-fallback.png +0 -0
  6. package/app/browser-google-test.png +0 -0
  7. package/app/browser-workbench-1440.png +0 -0
  8. package/app/browser-workbench-390.png +0 -0
  9. package/app/browser-workspace-google.png +0 -0
  10. package/app/browser-workspace-smoke.png +0 -0
  11. package/app/browser-workspace.png +0 -0
  12. package/app/core/audit.py +30 -0
  13. package/app/core/automation.py +107 -12
  14. package/app/core/benchstore.py +95 -0
  15. package/app/core/bookmeta.py +9 -4
  16. package/app/core/builtin_agent.py +472 -33
  17. package/app/core/catalog.py +7 -1
  18. package/app/core/dispatch.py +20 -3
  19. package/app/core/error_codes.py +11 -2
  20. package/app/core/evalbench.py +720 -0
  21. package/app/core/flows.py +228 -3
  22. package/app/core/jobs.py +22 -1
  23. package/app/core/manager.py +21 -0
  24. package/app/core/market_remote.py +10 -0
  25. package/app/core/mcp_client.py +287 -0
  26. package/app/core/modelhub.py +185 -2
  27. package/app/core/notify.py +118 -23
  28. package/app/core/novel_quality.py +189 -0
  29. package/app/core/paths.py +4 -0
  30. package/app/core/pipeline.py +426 -28
  31. package/app/core/planner.py +243 -22
  32. package/app/core/plugins.py +525 -0
  33. package/app/core/publish/browser.py +129 -13
  34. package/app/core/publish/fanqie.py +6 -0
  35. package/app/core/publish/flow.py +154 -5
  36. package/app/core/publish/flows-fanqie-calibrated.json +11 -7
  37. package/app/core/publish/ledger.py +29 -3
  38. package/app/core/publish/manager.py +134 -2
  39. package/app/core/publish/qimao.py +5 -0
  40. package/app/core/remotesync.py +121 -0
  41. package/app/core/runner.py +80 -5
  42. package/app/core/settings.py +39 -2
  43. package/app/core/settings_schema.py +8 -0
  44. package/app/core/store.py +30 -8
  45. package/app/core/usage.py +187 -14
  46. package/app/core/zentao.py +153 -4
  47. package/app/current-browser.png +0 -0
  48. package/app/main.py +279 -15
  49. package/app/mcp_server.py +175 -0
  50. package/app/pet.py +82 -21
  51. package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
  52. package/app/plugins/code-review-helper/skills/review.md +16 -0
  53. package/app/ui/app.js +1380 -73
  54. package/app/ui/i18n.js +248 -6
  55. package/app/ui/index.html +277 -13
  56. package/app/ui/style.css +786 -0
  57. package/package.json +2 -1
package/app/core/flows.py CHANGED
@@ -9,10 +9,12 @@
9
9
  """
10
10
  from __future__ import annotations
11
11
 
12
+ import base64
12
13
  import hashlib
13
14
  import json
14
15
  import re
15
16
  import threading
17
+ import time
16
18
 
17
19
  from . import paths, volumes
18
20
 
@@ -50,7 +52,7 @@ BUILTIN_FLOWS = [
50
52
  "threshold": 7.0, "rounds": 2,
51
53
  "serial": {"chapters": 8, "words_per_chapter": 2500},
52
54
  "goal_hint": "题材/受众/卖点 + 总字数(例:2 万字都市女频,可签约平台)",
53
- "note": "大纲 → 逐章起草 → 每章多维评审修订 → 全局一致性评审 → 合并(可断点续跑)"},
55
+ "note": "大纲 → 逐章起草 → 签约质量门禁(开篇/推进/文风/情感/节奏)→ 全局一致性评审 → 合并(可断点续跑)"},
54
56
  {"id": "article", "name": "自媒体文章", "icon": "i-news", "engine": "review", "builtin": True,
55
57
  "manuscript": "article.md",
56
58
  "rubric": ["选题与标题", "开头吸引力", "结构节奏", "平台适配", "传播性"],
@@ -296,11 +298,13 @@ def flow_drift(task):
296
298
  return {"flow": cur.get("id") or "", "pinned": pinned, "current": current}
297
299
 
298
300
 
299
- def upsert_flow(payload):
301
+ def upsert_flow(payload, source="manual"):
300
302
  """新增/更新流程。返回 (flow, 错误)。
301
303
 
302
304
  - 预置流程 id:写入 overrides(可编辑、可恢复默认);
303
- - 新 id:创建自定义流程——**只需 name + engine**,其余字段留空走引擎默认。
305
+ - 新 id:创建自定义流程——**只需 name + engine**,其余字段留空走引擎默认;
306
+ - source:覆盖成因,进版本历史(manual=手动改 / import=分享码导入 /
307
+ restore=恢复操作顶下旧版);语义与名字都相同则不产生历史噪音。
304
308
  """
305
309
  fid = str(payload.get("id") or "").strip()
306
310
  name = str(payload.get("name") or "").strip()
@@ -319,6 +323,7 @@ def upsert_flow(payload):
319
323
  ov.pop("id", None)
320
324
  ov["engine"] = base["engine"] # 引擎不可改(避免语义错乱)
321
325
  merged = _apply_overrides(base, ov)
326
+ _push_history(fid, merged, source)
322
327
  with _LOCK:
323
328
  data = _read()
324
329
  ovs = data.get("overrides") if isinstance(data.get("overrides"), dict) else {}
@@ -362,6 +367,7 @@ def upsert_flow(payload):
362
367
  elif engine == "code":
363
368
  flow["verify_command"] = str(payload.get("verify_command") or "")[:200]
364
369
  # direct:无流程参数(目标+附件即全部输入)
370
+ _push_history(fid, flow, source)
365
371
  with _LOCK:
366
372
  data = _read()
367
373
  flows = data.get("flows") if isinstance(data.get("flows"), list) else []
@@ -399,3 +405,222 @@ def reset_flow(flow_id):
399
405
  data["overrides"] = ovs
400
406
  _write(data)
401
407
  return None
408
+
409
+
410
+ # ---- 流程分享码(参考 Dify 模板中心 / n8n workflow 模板的可分发思路)----
411
+ # 把「流程」变成可分发资产:导出为一段可粘贴的分享码,对方在任务类型管理里
412
+ # 粘贴导入即装。码体 = 前缀 + urlsafe base64(JSON),无签名——分享码本来就
413
+ # 是明文可读的信封,导入时过 upsert_flow 全套规范化,坏数据进不来。
414
+
415
+ _SHARE_PREFIX = "CBFLOW1."
416
+ _SHARE_KIND = "codebee-flow"
417
+ # 导出字段白名单:与 _EDITABLE 对齐 + 身份/展示字段。builtin 标记不导出
418
+ # (对端是否预置由对端的 BUILTIN_FLOWS 决定,不能跨机携带)。
419
+ _SHARE_FIELDS = ("id", "name", "engine", "icon", "goal_hint", "note",
420
+ "manuscript", "rubric", "threshold", "rounds", "best_of",
421
+ "serial", "draft_prompt", "critique_prompt", "verify_command")
422
+
423
+
424
+ def export_flow_code(flow_id):
425
+ """导出流程为分享码。返回 (code, 错误)。"""
426
+ f = get_flow(str(flow_id or "").strip())
427
+ if not f:
428
+ return None, "流程不存在"
429
+ payload = {"kind": _SHARE_KIND, "v": 1,
430
+ "flow": {k: f.get(k) for k in _SHARE_FIELDS if k in f}}
431
+ blob = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
432
+ return _SHARE_PREFIX + base64.urlsafe_b64encode(blob.encode("utf-8")).decode("ascii"), None
433
+
434
+
435
+ def _decode_share_code(code):
436
+ """分享码 → (flow_in, 错误)。解码与校验共用(preview / import 两步走)。"""
437
+ if not isinstance(code, str) or not code.strip().startswith(_SHARE_PREFIX):
438
+ return None, "不是有效的 CodeBee 流程分享码(应以 %s 开头)" % _SHARE_PREFIX
439
+ raw = code.strip()[len(_SHARE_PREFIX):].strip()
440
+ try:
441
+ blob = base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))
442
+ payload = json.loads(blob.decode("utf-8"))
443
+ except Exception:
444
+ return None, "分享码解析失败(可能被截断或篡改)"
445
+ if not isinstance(payload, dict) or payload.get("kind") != _SHARE_KIND:
446
+ return None, "分享码内容不是 CodeBee 流程"
447
+ flow_in = payload.get("flow")
448
+ if not isinstance(flow_in, dict):
449
+ return None, "分享码缺少流程定义"
450
+ if not str(flow_in.get("id") or "").strip():
451
+ return None, "分享码缺少流程 ID"
452
+ return flow_in, None
453
+
454
+
455
+ def preview_flow_code(code):
456
+ """分享码两步导入的第一步:解析并预览,不落盘。
457
+
458
+ 返回 (预览, 错误):预览带流程定义摘要 + 与现有同 ID 流程的冲突提示。"""
459
+ flow_in, err = _decode_share_code(code)
460
+ if err:
461
+ return None, err
462
+ fid = str(flow_in.get("id") or "").strip()
463
+ existed = get_flow(fid)
464
+ preview = {"id": fid,
465
+ "name": str(flow_in.get("name") or "")[:20],
466
+ "engine": str(flow_in.get("engine") or "review"),
467
+ "rubric": flow_in.get("rubric") if isinstance(flow_in.get("rubric"), list) else [],
468
+ "threshold": flow_in.get("threshold"),
469
+ "rounds": flow_in.get("rounds"),
470
+ "has_prompts": bool(str(flow_in.get("draft_prompt") or "").strip()
471
+ or str(flow_in.get("critique_prompt") or "").strip()),
472
+ "serial": bool(isinstance(flow_in.get("serial"), dict)
473
+ and flow_in.get("serial", {}).get("chapters")),
474
+ "exists": bool(existed),
475
+ "existing_name": (existed or {}).get("name") or "",
476
+ "builtin": bool(existed and existed.get("builtin"))}
477
+ return preview, None
478
+
479
+
480
+ def import_flow_code(code):
481
+ """导入分享码。返回 (result, 错误);result = {id, name, status, builtin}。
482
+
483
+ status:created=新建自定义流程;updated=覆盖已有定义(预置流程写
484
+ overrides,可「恢复默认」回滚);noop=内容与现状完全一致。UI 走
485
+ preview_flow_code 预览确认后再进这里。"""
486
+ flow_in, err = _decode_share_code(code)
487
+ if err:
488
+ return None, err
489
+ fid = str(flow_in.get("id") or "").strip()
490
+ existed = get_flow(fid)
491
+ existed_digest = flow_digest(existed) if existed else ""
492
+ flow, err = upsert_flow(flow_in, source="import")
493
+ if err:
494
+ return None, err
495
+ if existed and flow_digest(flow) == existed_digest \
496
+ and flow.get("name") == existed.get("name"):
497
+ status = "noop"
498
+ else:
499
+ status = "updated" if existed else "created"
500
+ return {"id": fid, "name": flow.get("name"), "status": status,
501
+ "builtin": bool(existed and existed.get("builtin"))}, None
502
+
503
+
504
+ # ---- 版本历史(Langfuse prompt management 借鉴):提示词/参数改动可回滚 ----
505
+ # 覆盖前的当前版自动快照进 data/flows_history.json(每流程 ≤_HISTORY_MAX 条),
506
+ # 恢复=把历史版重新 upsert 回来;runs 已按 flow_revision(digest 前 16)对账,
507
+ # 每版顺手给出实测战绩——「哪版提示词好用」不再靠感觉。
508
+
509
+ _HISTORY_MAX = 10 # 每流程保留的历史版本数(新的顶旧的)
510
+
511
+
512
+ def _history_read():
513
+ try:
514
+ data = json.loads((paths.DATA_DIR / "flows_history.json").read_text(encoding="utf-8"))
515
+ return data if isinstance(data, dict) else {}
516
+ except Exception:
517
+ return {}
518
+
519
+
520
+ def _history_write(data):
521
+ paths.DATA_DIR.mkdir(parents=True, exist_ok=True)
522
+ tmp = paths.DATA_DIR / "flows_history.json.tmp"
523
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
524
+ tmp.replace(paths.DATA_DIR / "flows_history.json")
525
+
526
+
527
+ def _push_history(fid, new_flow, source):
528
+ """把「即将被覆盖的当前版」存进历史;语义与名字都相同则跳过(不产噪音)。"""
529
+ try:
530
+ cur = get_flow(fid)
531
+ if not cur:
532
+ return # 首次创建没有旧版可存
533
+ if flow_digest(cur) == flow_digest(new_flow) \
534
+ and cur.get("name") == new_flow.get("name"):
535
+ return
536
+ with _LOCK:
537
+ data = _history_read()
538
+ versions = data.get(fid) if isinstance(data.get(fid), list) else []
539
+ versions.insert(0, {"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
540
+ "source": str(source or "manual")[:16],
541
+ "flow": {k: cur.get(k) for k in _SHARE_FIELDS if k in cur}})
542
+ data[fid] = versions[:_HISTORY_MAX]
543
+ _history_write(data)
544
+ except Exception:
545
+ pass # 历史是增强,永远不挡保存
546
+
547
+
548
+ _RUNS_PROVIDER = None # main.py 启动注入 store.list_runs;flows(L0) 不反向
549
+ # 依赖 store(L1)——静态图不成环(同 set_run_estimator 模式)
550
+
551
+
552
+ def set_runs_provider(fn):
553
+ """注入 run 遍历器(应用入口传 store.list_runs);未注入时版本无战绩。"""
554
+ global _RUNS_PROVIDER
555
+ _RUNS_PROVIDER = fn if callable(fn) else None
556
+
557
+
558
+ def _flow_stats(digest16):
559
+ """该流程修订的实测战绩:任务数/通过率/均分(按 run 钉的 flow_revision 对账)。
560
+
561
+ 均分分母只算有评分的 run;无记录返回 None。"""
562
+ try:
563
+ if not _RUNS_PROVIDER:
564
+ return None
565
+ runs = wins = scored_n = 0
566
+ total = 0.0
567
+ for run in (_RUNS_PROVIDER(limit=None) or []):
568
+ if not isinstance(run, dict):
569
+ continue
570
+ rev = str(((run.get("execution_snapshot") or {}).get("flow_revision")) or "")
571
+ if rev[:16] != digest16:
572
+ continue
573
+ runs += 1
574
+ v = run.get("verdict") or {}
575
+ if v.get("publishable") is True:
576
+ wins += 1
577
+ if isinstance(v.get("overall"), (int, float)):
578
+ total += float(v["overall"])
579
+ scored_n += 1
580
+ if not runs:
581
+ return None
582
+ return {"runs": runs, "pass_rate": round(wins / runs, 2),
583
+ "avg_overall": round(total / scored_n, 1) if scored_n else None}
584
+ except Exception:
585
+ return None
586
+
587
+
588
+ def flow_versions(flow_id):
589
+ """给 UI 的版本清单:当前版在前,历史版新→旧;每版带 digest 与实测战绩。"""
590
+ fid = str(flow_id or "").strip()
591
+ out = []
592
+ cur = get_flow(fid)
593
+ if cur:
594
+ d = flow_digest(cur)[:16]
595
+ out.append({"current": True, "ts": "", "source": "current", "name": cur.get("name"),
596
+ "digest": d, "stats": _flow_stats(d)})
597
+ for snap in _history_read().get(fid) or []:
598
+ d = flow_digest(snap.get("flow") or {})[:16]
599
+ out.append({"current": False, "ts": str(snap.get("ts") or ""),
600
+ "source": str(snap.get("source") or "manual"),
601
+ "name": (snap.get("flow") or {}).get("name"),
602
+ "digest": d, "stats": _flow_stats(d)})
603
+ return out
604
+
605
+
606
+ def restore_flow_version(flow_id, ts):
607
+ """恢复历史版本。返回 (flow, 错误)。
608
+
609
+ 恢复动作本身也会把「被顶下来的当前版」存进历史(source=restore),
610
+ 恢复错了还能再恢复回去;目标条目先从历史中消费掉再做 upsert——
611
+ ts 是秒级精度,顺序反了同秒快照会被误删。"""
612
+ fid = str(flow_id or "").strip()
613
+ ts = str(ts or "")
614
+ with _LOCK:
615
+ data = _history_read()
616
+ versions = data.get(fid) if isinstance(data.get(fid), list) else []
617
+ snap = next((s for s in versions if s.get("ts") == ts), None)
618
+ if snap:
619
+ data[fid] = [s for s in versions if s.get("ts") != ts]
620
+ _history_write(data)
621
+ if not snap:
622
+ return None, "历史版本不存在(可能已被消费)"
623
+ flow, err = upsert_flow(dict(snap.get("flow") or {}), source="restore")
624
+ if err:
625
+ return None, err
626
+ return flow, None
package/app/core/jobs.py CHANGED
@@ -538,7 +538,11 @@ def cancel_event_for(run_id):
538
538
  return ev
539
539
 
540
540
 
541
- AUTO_RESUME_MAX = 3 # 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)
541
+ # 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)。3 是 2026-09-25
542
+ # 戍边骑奴 9-20·续2 案的上限:20 章连载 3 轮烧完次数后卡死在「任务总时限已到」
543
+ # 等人工,而每轮都在稳定进章——止损已由零进度两轮同因止损兜底,上限只作
544
+ # 最后保险(真烧钱还有日/月花费硬顶闸),10 轮足够 20 章慢链天跑完。
545
+ AUTO_RESUME_MAX = 10
542
546
  AUTO_RESUME_DELAY_S = 300 # 自动续跑延迟入队秒数:网关限流/欠费窗口通常分钟级,
543
547
  # 立即重排会撞在同一堵墙上把续跑次数烧光(2026-09-17 七猫实测)
544
548
 
@@ -589,6 +593,8 @@ def _maybe_auto_resume(run_id):
589
593
  终态写明死因,不再烧剩余的退避次数。超时例外:错误签名恒为同一句
590
594
  「任务总时限已到」,但每轮都在落章——本轮比上轮多过审了章就不算
591
595
  「同因无效重试」,续跑交给次数上限兜底;两轮一章未进则照常止损。
596
+ 上限烧完时(超时且有进章)把已过审进度与断点续跑指引写进错误行,
597
+ 别让执行结果卡读起来像白跑。
592
598
  """
593
599
  try:
594
600
  from . import store
@@ -601,6 +607,21 @@ def _maybe_auto_resume(run_id):
601
607
  if run.get("cancelled_by_user") or run.get("status") == "cancelled":
602
608
  return False # 用户主动取消的运行绝不自动续跑
603
609
  if int(run.get("auto_resumes") or 0) >= AUTO_RESUME_MAX:
610
+ # 上限烧完不再续跑。但「任务总时限已到」+本轮有进章时,错误行必须
611
+ # 带上进度与续跑方式——不然执行结果卡只写「任务总时限已到」,读起来
612
+ # 像白跑(2026-09-25 续2 案:用户看到 64/64 步+超时以为续跑没生效)。
613
+ if run.get("status") == "timeout" and _serial_progress(run) > 0:
614
+ try:
615
+ total = int((task.get("serial") or {}).get("chapters") or 0)
616
+ where = ("/" + str(total) + " 章") if total > 0 else " 章"
617
+ store.update_run(
618
+ run_id,
619
+ error=(run.get("error") or "")
620
+ + "|自动续跑次数已用完,已过审 "
621
+ + str(_serial_progress(run)) + where
622
+ + ";点重试将从断点继续,已过审章不会重写")
623
+ except Exception:
624
+ pass
604
625
  return False
605
626
  prev_id = run.get("auto_resumed_from")
606
627
  if prev_id:
@@ -2089,6 +2089,27 @@ def check_updates_async(force=False):
2089
2089
  return len(entries)
2090
2090
 
2091
2091
 
2092
+ def upgrade_all_targets():
2093
+ """「一键升级全部」的目标条目,按目录顺序返回。
2094
+
2095
+ 只收同时满足:已安装 + 配了 upgrade 命令(op=upgrade 硬依赖该字段,
2096
+ 没配的跟单条一样不出「升级」按钮)+ 更新检查不是「已是最新」。
2097
+ 检查结果为 unknown(还没查)/ unsupported(渠道查不了)的也升级——
2098
+ npm/pip 的升级命令幂等,宁可空跑一次也不漏升;唯一跳过「已是最新」,
2099
+ 防无谓重装(重装易撞 EBUSY 文件锁)。
2100
+ """
2101
+ detected = detect_all()
2102
+ out = []
2103
+ for e in catalog.load():
2104
+ det = (detected or {}).get(e["id"]) or {}
2105
+ if not det.get("installed") or not e.get("upgrade"):
2106
+ continue
2107
+ if update_info(e).get("status") == "current":
2108
+ continue
2109
+ out.append(e)
2110
+ return out
2111
+
2112
+
2092
2113
  def catalog_view():
2093
2114
  """管理页数据:catalog + 检测 + 版本 + 模型 + 编排启用状态。"""
2094
2115
  from . import registry
@@ -1039,6 +1039,15 @@ def preview_remote(entry_id):
1039
1039
  _PREVIEW_CACHE[token] = {"entry_id": entry_id, "files": files,
1040
1040
  "stripped": stripped, "ts": time.time()}
1041
1041
  file_list = sorted(files.items(), key=lambda kv: kv[0])
1042
+ # 内容安检(ClawHavoc 投毒案借鉴):对全部技能文本跑危险模式扫描,
1043
+ # 风险随预览返回——UI 高风险红字确认;发现列表给行号可定位。
1044
+ try:
1045
+ from . import skill_scan
1046
+ risk_findings = skill_scan.scan_text(
1047
+ "\n".join(str(v) for v in files.values()))
1048
+ risk_label = skill_scan.risk_label(risk_findings)
1049
+ except Exception:
1050
+ risk_findings, risk_label = [], ""
1042
1051
  return {
1043
1052
  "token": token, "id": entry_id, "title": entry["title"],
1044
1053
  "source": entry["source_name"], "version": entry["version"],
@@ -1048,6 +1057,7 @@ def preview_remote(entry_id):
1048
1057
  "total_chars": sum(len(v) for v in files.values()),
1049
1058
  "file_list": [{"path": k, "chars": len(v)} for k, v in file_list[:60]],
1050
1059
  "stripped": sorted(stripped)[:40], "stripped_total": len(stripped),
1060
+ "risk_label": risk_label, "risk_findings": risk_findings[:8],
1051
1061
  "expires_in": _PREVIEW_TTL,
1052
1062
  }, None
1053
1063
  except ValueError as e:
@@ -0,0 +1,287 @@
1
+ # -*- coding: utf-8 -*-
2
+ """内置智能体的 MCP 客户端(Model Context Protocol,stdio 形态)。
3
+
4
+ 零依赖:JSON-RPC 2.0 走子进程 stdin/stdout(按行分帧——MCP stdio 传输约定)。
5
+ 每次工具调用重新拉起服务器进程:没有长驻会话要管,冷启动成本对内置智能体
6
+ 的低频工具循环可接受;进程用完即关(finally 杀树),绝不留孤儿。
7
+
8
+ 配置(settings.mcp_servers,JSON 数组字符串,≤4 个服务器):
9
+ [{"name": "fs", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-fs", "C:/x"],
10
+ "env": {"KEY": "v"}}]
11
+ name 用于工具名前缀(mcp__<name>__<tool>),必须 [a-z0-9_-]。
12
+
13
+ 安全口径:服务器命令是用户在本机自己配置的(与内置智能体 run_command 同一
14
+ 自主权级别);本模块只做传输与解析,不校验工具语义。工具清单按服务器缓存
15
+ (TTL 300s)——每次对话请求重建 tools 列表时不能每次都冷启动全量服务器。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import subprocess
23
+ import threading
24
+ import time
25
+
26
+ _LOCK = threading.RLock()
27
+ _TOOLS_CACHE = {} # server_key → (ts, tools)
28
+ _TOOLS_TTL = 300.0
29
+
30
+ MAX_SERVERS = 4
31
+ MAX_MCP_TOOLS = 24 # 全部服务器合计注入内置智能体的工具数上限
32
+ _NAME_RE = re.compile(r"^[a-z0-9_-]{1,24}$")
33
+ _INIT_TIMEOUT = 15.0
34
+ _CALL_TIMEOUT = 60.0
35
+ _CALL_TIMEOUT_MAX = 300.0
36
+
37
+ _CLIENT_INFO = {"name": "CodeBee", "version": "1.0"}
38
+
39
+
40
+ def invalidate_tools_cache():
41
+ """服务器配置变化后丢弃已发现工具(插件启停等场景调用)。"""
42
+ with _LOCK:
43
+ _TOOLS_CACHE.clear()
44
+
45
+
46
+ def parse_servers(text):
47
+ """settings.mcp_servers 文本 → 校验后的服务器配置列表。返回 (servers, 错误)。"""
48
+ raw = str(text or "").strip()
49
+ if not raw:
50
+ return [], None
51
+ try:
52
+ arr = json.loads(raw)
53
+ except Exception:
54
+ return None, "mcp_servers 不是合法 JSON"
55
+ if not isinstance(arr, list):
56
+ return None, "mcp_servers 必须是数组"
57
+ if len(arr) > MAX_SERVERS:
58
+ return None, "最多 %d 个 MCP 服务器" % MAX_SERVERS
59
+ out = []
60
+ for item in arr:
61
+ if not isinstance(item, dict):
62
+ return None, "MCP 服务器配置必须是对象"
63
+ name = str(item.get("name") or "").strip()
64
+ command = str(item.get("command") or "").strip()
65
+ if not _NAME_RE.match(name):
66
+ return None, "服务器名 %r 只能是小写字母/数字/-/_(≤24 位)" % name[:24]
67
+ if not command:
68
+ return None, "服务器 %s 缺 command" % name
69
+ args = item.get("args") if isinstance(item.get("args"), list) else []
70
+ args = [str(a) for a in args][:16]
71
+ env = item.get("env") if isinstance(item.get("env"), dict) else {}
72
+ env = {str(k): str(v) for k, v in list(env.items())[:16]}
73
+ out.append({"name": name, "command": command, "args": args, "env": env})
74
+ return out, None
75
+
76
+
77
+ class _Session:
78
+ """一次性的 MCP 服务器会话:spawn → initialize → (list/call)→ 杀树。"""
79
+
80
+ def __init__(self, server):
81
+ self.server = server
82
+ self.proc = None
83
+ self.lines = None # reader 线程产出的行队列
84
+ self.err = None
85
+
86
+ def _pump_err(self):
87
+ try:
88
+ for _ in self.proc.stderr:
89
+ pass
90
+ except Exception:
91
+ pass
92
+
93
+ def _pump_out(self):
94
+ try:
95
+ for raw in self.proc.stdout:
96
+ self.lines.put(raw.decode("utf-8", errors="replace").rstrip("\r\n"))
97
+ except Exception:
98
+ pass
99
+ finally:
100
+ self.lines.put(None) # EOF 哨兵
101
+
102
+ def __enter__(self):
103
+ try:
104
+ self.proc = subprocess.Popen(
105
+ [self.server["command"]] + list(self.server.get("args") or []),
106
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE,
107
+ stderr=subprocess.PIPE, text=False,
108
+ env={**os.environ, **(self.server.get("env") or {})})
109
+ except Exception as e:
110
+ raise RuntimeError("MCP 服务器启动失败: %s" % e)
111
+ import queue as _queue
112
+ self.lines = _queue.Queue()
113
+ threading.Thread(target=self._pump_out, daemon=True).start()
114
+ threading.Thread(target=self._pump_err, daemon=True).start()
115
+ self._request("initialize", {
116
+ "protocolVersion": "2024-11-05", "capabilities": {},
117
+ "clientInfo": _CLIENT_INFO}, timeout=_INIT_TIMEOUT)
118
+ self._notify("notifications/initialized")
119
+ return self
120
+
121
+ def __exit__(self, *exc):
122
+ try:
123
+ if self.proc and self.proc.stdin:
124
+ try:
125
+ self.proc.stdin.close()
126
+ except Exception:
127
+ pass
128
+ finally:
129
+ try:
130
+ if self.proc:
131
+ self.proc.kill()
132
+ except Exception:
133
+ pass
134
+ return False
135
+
136
+ def _send(self, obj):
137
+ data = json.dumps(obj, ensure_ascii=False).encode("utf-8") + b"\n"
138
+ self.proc.stdin.write(data)
139
+ self.proc.stdin.flush()
140
+
141
+ def _notify(self, method, params=None):
142
+ self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
143
+
144
+ def _request(self, method, params, timeout):
145
+ """单请求单响应;忽略通知与未知 id 的服务端请求。"""
146
+ rid = id({})
147
+ self._send({"jsonrpc": "2.0", "id": 1 if method == "initialize" else rid,
148
+ "method": method, "params": params or {}})
149
+ deadline = time.time() + max(1.0, timeout)
150
+ while True:
151
+ remain = deadline - time.time()
152
+ if remain <= 0:
153
+ raise RuntimeError("%s 超时(%.0fs)" % (method, timeout))
154
+ try:
155
+ line = self.lines.get(timeout=remain)
156
+ except Exception:
157
+ raise RuntimeError("%s 超时(%.0fs)" % (method, timeout))
158
+ if line is None:
159
+ raise RuntimeError("MCP 服务器提前退出")
160
+ line = line.strip()
161
+ if not line.startswith("{"):
162
+ continue
163
+ try:
164
+ msg = json.loads(line)
165
+ except Exception:
166
+ continue
167
+ if not isinstance(msg, dict) or msg.get("id") is None:
168
+ continue # 通知/服务端请求:不匹配本调用
169
+ if msg.get("id") != (1 if method == "initialize" else rid):
170
+ continue
171
+ if msg.get("error"):
172
+ raise RuntimeError("MCP %s 失败: %s" % (
173
+ method, json.dumps(msg["error"], ensure_ascii=False)[:200]))
174
+ return msg.get("result") or {}
175
+
176
+
177
+ def list_tools(server):
178
+ """发现服务器工具。返回 {"ok", "tools":[{"name","description","input_schema"}], "error"}。"""
179
+ try:
180
+ with _Session(server) as sess:
181
+ res = sess._request("tools/list", {}, timeout=_INIT_TIMEOUT)
182
+ tools = []
183
+ for t in (res.get("tools") or [])[:16]:
184
+ if isinstance(t, dict) and isinstance(t.get("name"), str) and t["name"]:
185
+ tools.append({"name": t["name"],
186
+ "description": str(t.get("description") or "")[:400],
187
+ "input_schema": t.get("input_schema")
188
+ if isinstance(t.get("input_schema"), dict) else
189
+ {"type": "object", "properties": {}}})
190
+ return {"ok": True, "tools": tools, "error": ""}
191
+ except Exception as e:
192
+ return {"ok": False, "tools": [], "error": str(e)[:200]}
193
+
194
+
195
+ def call_tool(server, tool_name, arguments, timeout_s=_CALL_TIMEOUT):
196
+ """调用一个工具。返回 {"ok", "text", "error", "is_error"}。"""
197
+ try:
198
+ timeout = min(_CALL_TIMEOUT_MAX, max(5.0, float(timeout_s or _CALL_TIMEOUT)))
199
+ with _Session(server) as sess:
200
+ res = sess._request("tools/call",
201
+ {"name": tool_name,
202
+ "arguments": arguments if isinstance(arguments, dict) else {}},
203
+ timeout=timeout)
204
+ parts = []
205
+ for c in res.get("content") or []:
206
+ if isinstance(c, dict) and c.get("type") == "text" and c.get("text"):
207
+ parts.append(str(c["text"]))
208
+ text = "\n".join(parts)[:20000]
209
+ is_err = bool(res.get("isError"))
210
+ return {"ok": bool(text) and not is_err, "text": text,
211
+ "error": "" if not is_err else (text or "工具返回 isError"),
212
+ "is_error": is_err}
213
+ except Exception as e:
214
+ return {"ok": False, "text": "", "error": str(e)[:200], "is_error": False}
215
+
216
+
217
+ # ---- 面向 builtin_agent 的合并视图(带缓存) ----
218
+
219
+ def tool_specs_cached(force=False):
220
+ """全部服务器的工具合并清单:[{server, name(原名), full_name, description,
221
+ input_schema}]。TTL 内走缓存;单个服务器失败跳过(error 挂在条目外不打断)。"""
222
+ with _LOCK:
223
+ if not force:
224
+ hit = True
225
+ for key, (ts, _t) in _TOOLS_CACHE.items():
226
+ if time.time() - ts > _TOOLS_TTL:
227
+ hit = False
228
+ break
229
+ if hit and _TOOLS_CACHE:
230
+ out = []
231
+ for _key, (_ts, tools) in _TOOLS_CACHE.items():
232
+ out.extend(tools)
233
+ return out[:MAX_MCP_TOOLS]
234
+ servers, err = parse_servers(_settings_text())
235
+ if err or not servers:
236
+ return []
237
+ out = []
238
+ with _LOCK:
239
+ _TOOLS_CACHE.clear()
240
+ for srv in servers:
241
+ res = list_tools(srv)
242
+ if not res.get("ok"):
243
+ continue
244
+ specs = []
245
+ for t in res["tools"]:
246
+ specs.append({"server": srv["name"], "name": t["name"],
247
+ "full_name": "mcp__%s__%s" % (srv["name"], t["name"]),
248
+ "description": t["description"],
249
+ "input_schema": t["input_schema"]})
250
+ with _LOCK:
251
+ _TOOLS_CACHE[srv["name"]] = (time.time(), specs)
252
+ out.extend(specs)
253
+ if len(out) >= MAX_MCP_TOOLS:
254
+ break
255
+ return out[:MAX_MCP_TOOLS]
256
+
257
+
258
+ def dispatch_full_name(full_name, arguments, timeout_s=_CALL_TIMEOUT):
259
+ """mcp__<server>__<tool> → 找服务器配置并调用。返回 {"ok","text","error"}。"""
260
+ m = re.match(r"^mcp__([a-z0-9_-]{1,24})__(.+)$", str(full_name or ""))
261
+ if not m:
262
+ return {"ok": False, "text": "", "error": "非法 MCP 工具名"}
263
+ sname, tool = m.group(1), m.group(2)
264
+ servers, err = parse_servers(_settings_text())
265
+ if err:
266
+ return {"ok": False, "text": "", "error": err}
267
+ srv = next((s for s in servers if s["name"] == sname), None)
268
+ if not srv:
269
+ return {"ok": False, "text": "", "error": "MCP 服务器 %s 未配置" % sname}
270
+ return call_tool(srv, tool, arguments, timeout_s=timeout_s)
271
+
272
+
273
+ _SETTINGS_TEXT = None # main.py 启动注入(读 settings.mcp_servers)——本模块
274
+ # 不直接依赖 settings(L0),静态图保持干净(同 set_run_estimator 模式)
275
+
276
+
277
+ def set_settings_text(fn):
278
+ """注入 MCP 服务器清单读取器;未注入时工具清单为空(MCP 功能关闭)。"""
279
+ global _SETTINGS_TEXT
280
+ _SETTINGS_TEXT = fn if callable(fn) else None
281
+
282
+
283
+ def _settings_text():
284
+ try:
285
+ return str(_SETTINGS_TEXT() or "") if _SETTINGS_TEXT else ""
286
+ except Exception:
287
+ return ""