codebee 0.1.66 → 0.1.67

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.
@@ -0,0 +1,43 @@
1
+ # -*- coding: utf-8 -*-
2
+ """缺陷复盘报告(defect_retro,借鉴 zl2237/test-defect-retrospective)。
3
+
4
+ 禅道/Jira 缺陷导出 → 产品/开发/测试三视角复盘报告。
5
+ 数据不走网络:缺陷导出(CSV)作为任务附件注入上下文,这里只负责
6
+ 组装分析框架提示词。无附件时提示词会点名要求补附件——报告框架
7
+ 照常输出,不留静默空转。
8
+
9
+ 对照参考实现的取舍:它是本地脚本确定性算指标(analyzer 同输入同输出),
10
+ 我们是 direct 引擎一次性成稿——先跑通「有复盘产物」这一步,指标口径
11
+ 固化(状态/严重度归一在提示词里给锚)后续按需升档。
12
+ """
13
+ from __future__ import annotations
14
+
15
+
16
+ def retro_prompt(goal):
17
+ """组装缺陷复盘三视角提示词。纯框架无抓取,恒有返回值。"""
18
+ return (
19
+ "你是资深质量效能专家,负责输出一份缺陷复盘报告。\n\n"
20
+ "## 输入\n"
21
+ "用户会提供缺陷平台导出数据(推荐禅道/Jira 导出 CSV 作为任务附件;"
22
+ "Excel 二进制读不了,请导出 CSV)。字段口径以「标题、严重程度、"
23
+ "状态、创建日期、所属模块、解决方案、关闭原因、责任人」为锚,"
24
+ "缺失字段按实际有的算并在报告中声明口径。\n\n"
25
+ "## 用户关注点\n%s\n\n"
26
+ "## 你的产出(Markdown 报告)\n"
27
+ "1. **整体画像**:缺陷总量、严重度分布、状态分布、时间走势"
28
+ "(按创建日期聚合)、模块 Top5 集中度——只用表格+一句话结论,不复述原始数据\n"
29
+ "2. **产品视角**:需求类缺陷(描述不清/变更频繁/逻辑矛盾)占比与典型例;"
30
+ "类需求缺陷(被当 Bug 提的优化)点名\n"
31
+ "3. **开发视角**:根因聚类(编码疏忽/设计缺陷/环境/数据/回归)占比;"
32
+ "重复回归的模块点名;修复后又被激活的缺陷单列\n"
33
+ "4. **测试视角**:发现阶段分布(提测后/上线后/用户反馈);漏测推测"
34
+ "(上线后才暴露的缺陷缺什么用例);严重度与发现时长的错配\n"
35
+ "5. **改进动作**:每个视角 2-3 条,带建议责任角色与可验证的完成标准,"
36
+ "拒绝「加强沟通」类空话\n\n"
37
+ "## 纪律\n"
38
+ "- 所有数字必须从提供的导出数据里数出来,数不出的指标写「数据未提供」"
39
+ "而不是编造\n"
40
+ "- 若上下文里没有任何缺陷数据,直接说明需要附带导出 CSV 并给出导出步骤"
41
+ "(禅道:产品-缺陷-导出-CSV),不要硬写报告\n"
42
+ "- 直接输出报告,不要复述本提示词"
43
+ % (goal or "(用户未指定,按全量缺陷复盘)"))
package/app/core/flows.py CHANGED
@@ -9,6 +9,7 @@
9
9
  """
10
10
  from __future__ import annotations
11
11
 
12
+ import hashlib
12
13
  import json
13
14
  import re
14
15
  import threading
@@ -76,7 +77,10 @@ BUILTIN_FLOWS = [
76
77
  "note": "起草 → 多维评审 → 修订循环 → 发布门禁"},
77
78
  {"id": "rank_scan", "name": "扫榜选材", "icon": "i-chart", "engine": "direct", "builtin": True,
78
79
  "goal_hint": "想写哪个方向(一句话,可留空默认分析总榜热门题材)",
79
- "note": "抓取七猫+番茄排行榜公开数据 → AI 提炼跨平台热门题材/人设/差异化切入点(快档直出报告)"},
80
+ "note": "抓取七猫/番茄/起点/纵横四平台排行榜公开数据 → AI 提炼跨平台热门题材/人设/差异化切入点(快档直出报告)"},
81
+ {"id": "defect_retro", "name": "缺陷复盘", "icon": "i-clipboard", "engine": "direct", "builtin": True,
82
+ "goal_hint": "复盘哪个版本/时间段的缺陷(建议附带禅道或 Jira 导出 CSV)",
83
+ "note": "读取附带的缺陷导出数据 → AI 输出产品/开发/测试三视角复盘报告:整体画像、根因聚类、漏测分析、改进动作(快档直出报告)"},
80
84
  {"id": "research", "name": "调研报告", "icon": "i-file-search", "engine": "review", "builtin": True,
81
85
  "manuscript": "report.md",
82
86
  "rubric": ["全面性", "深度", "论据可靠", "可读性", "结论质量"],
@@ -250,6 +254,48 @@ def get_flow(flow_id):
250
254
  return None
251
255
 
252
256
 
257
+ # 参与 digest 的语义字段:会进运行链的参数与提示词。icon/note/goal_hint/name 是
258
+ # 纯展示位——措辞变化不影响执行语义,不应制造「修订漂移」噪音。
259
+ _DIGEST_FIELDS = ("id", "engine", "manuscript", "rubric", "threshold", "rounds",
260
+ "best_of", "serial", "draft_prompt", "critique_prompt",
261
+ "verify_command")
262
+
263
+
264
+ def flow_content_sha256(flow):
265
+ """Return the full semantic content hash for an immutable flow revision."""
266
+ if not isinstance(flow, dict):
267
+ return ""
268
+ basis = {k: flow.get(k) for k in _DIGEST_FIELDS}
269
+ blob = json.dumps(basis, ensure_ascii=False, sort_keys=True, default=str)
270
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
271
+
272
+
273
+ def flow_digest(flow):
274
+ """流程定义的内容指纹(sha256 前 16 位)——「这个任务当时用的是哪一版流程」
275
+ 的对账真源(借鉴 WorkDSH ADR-0010 发布后不可变修订)。"""
276
+ return flow_content_sha256(flow)[:16]
277
+
278
+
279
+ def flow_drift(task):
280
+ """任务流程修订漂移检测:任务创建时钉的 digest vs 当前流程定义。
281
+
282
+ 任务参数在创建时已固化(改流程不影响固化字段),但 task_compile 对历史
283
+ 任务缺失字段(threshold/rubric/manuscript)仍回落当前流程定义——续跑/
284
+ 重试因此可能静默吃到新流程。这里只诊断不裁定:返回
285
+ {"flow", "pinned", "current"} 或 None(无快照/流程已删/无漂移)。"""
286
+ snap = (task or {}).get("flow_snapshot") or {}
287
+ pinned = str(snap.get("digest") or "")
288
+ if not pinned:
289
+ return None
290
+ cur = get_flow(str((task or {}).get("type") or ""))
291
+ if not cur:
292
+ return None
293
+ current = flow_digest(cur)
294
+ if not current or current == pinned:
295
+ return None
296
+ return {"flow": cur.get("id") or "", "pinned": pinned, "current": current}
297
+
298
+
253
299
  def upsert_flow(payload):
254
300
  """新增/更新流程。返回 (flow, 错误)。
255
301
 
package/app/core/jobs.py CHANGED
@@ -564,19 +564,32 @@ def _err_signature(err):
564
564
  return re.sub(r"\s+", " ", s).strip()[:400]
565
565
 
566
566
 
567
+ def _serial_progress(run):
568
+ """连载运行的已过审章数——超时续跑的进度锚点(chapter_scores 长度)。"""
569
+ try:
570
+ return len(run.get("chapter_scores") or [])
571
+ except Exception:
572
+ return 0
573
+
574
+
567
575
  def _maybe_auto_resume(run_id):
568
- """连载任务失败自动续跑:继承已完成章继续,最多 AUTO_RESUME_MAX 次。
576
+ """连载任务失败/超时自动续跑:继承已完成章继续,最多 AUTO_RESUME_MAX 次。
569
577
 
570
578
  真实长篇单次运行常因供应商拥堵超时中断;这里在执行线程收尾时自动续跑一次
571
579
  续跑(store.retry_task 会带上 inherit),让整个流程真正无人值守。
580
+ 2026-09-24 戍边骑奴 9-20 案:「任务总时限已到」终态此前不续跑,而总时限
581
+ 预算默认 1 小时、慢链天 12 章天然跑不完,用户只能守着手动点重试——超时
582
+ 恰恰是最该自动接着写的一种中断,纳入。
572
583
  同因连撞止损:续跑副本再失败时与本次失败的错误签名比对(2026-09-18
573
584
  重写任务 kimi 403 欠费案),一模一样说明退避没换来不同结果,直接落
574
- 终态写明死因,不再烧剩余的退避次数。
585
+ 终态写明死因,不再烧剩余的退避次数。超时例外:错误签名恒为同一句
586
+ 「任务总时限已到」,但每轮都在落章——本轮比上轮多过审了章就不算
587
+ 「同因无效重试」,续跑交给次数上限兜底;两轮一章未进则照常止损。
575
588
  """
576
589
  try:
577
590
  from . import store
578
591
  run = store.get_run(run_id)
579
- if not run or run.get("status") not in ("failed", "cancelled"):
592
+ if not run or run.get("status") not in ("failed", "cancelled", "timeout"):
580
593
  return False
581
594
  task = store.get_task(run.get("task_id")) if run.get("task_id") else None
582
595
  if not task or not task.get("serial"):
@@ -589,8 +602,11 @@ def _maybe_auto_resume(run_id):
589
602
  if prev_id:
590
603
  prev = store.get_run(prev_id)
591
604
  sig_now = _err_signature(run.get("error"))
605
+ timeout_progressed = (run.get("status") == "timeout"
606
+ and _serial_progress(run) > _serial_progress(prev or {}))
592
607
  if (prev and sig_now and prev.get("error")
593
- and sig_now == _err_signature(prev.get("error"))):
608
+ and sig_now == _err_signature(prev.get("error"))
609
+ and not timeout_progressed):
594
610
  store.update_run(run_id, auto_resume_stopped="same_cause",
595
611
  error=(run.get("error") or "")
596
612
  + "|自动续跑止损:连续两次失败原因相同,不再重试")
@@ -29,7 +29,7 @@ import threading
29
29
  import time
30
30
  from pathlib import Path
31
31
 
32
- from . import paths, skills
32
+ from . import paths, revisions, skills
33
33
 
34
34
  _LOCK = threading.RLock()
35
35
  _FILE = paths.DATA_DIR / "knowledge.json"
@@ -82,6 +82,29 @@ def list_entries(scope=None, status=None, tag=None, only_enabled=False):
82
82
  return items
83
83
 
84
84
 
85
+ def revision_for_task(task) -> dict:
86
+ """Return approved knowledge references selected by task scope."""
87
+ scope = str((task or {}).get("type") or "*")
88
+ entries = list_entries(scope=scope, status="approved", only_enabled=True)
89
+ refs = []
90
+ for entry in entries:
91
+ body = entry.get("body") or ""
92
+ digest = revisions.content_sha256(body)
93
+ stored_digest = str(entry.get("content_sha256") or "")
94
+ drift = bool(stored_digest and stored_digest != digest)
95
+ refs.append({"id": entry.get("id") or "",
96
+ "revision_id": (entry.get("revision_id")
97
+ if stored_digest == digest else
98
+ revisions.revision_id("kbrev", digest)) or
99
+ revisions.revision_id("kbrev", digest),
100
+ "content_sha256": digest,
101
+ "revision_drift": drift})
102
+ payload = {"scope": scope, "entries": refs}
103
+ digest = revisions.stable_digest(payload)
104
+ return {"revision_id": revisions.revision_id("knowledge", digest),
105
+ "content_sha256": digest, "scope": scope, "entries": refs}
106
+
107
+
85
108
  def _title_sim(a, b):
86
109
  """标题近似度:字符 bigram 的包含度(交集 / 较短者,中文友好零分词)。
87
110
  用包含度而非 Jaccard:「X」vs「X 指南」的较短者完全被包含 →1.0,
@@ -131,13 +154,25 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
131
154
  if hit is not None:
132
155
  it = hit
133
156
  it["seen"] = int(it.get("seen") or 1) + 1
157
+ old_body = str(it.get("body") or "")
158
+ old_rev = it.get("revision_id") or ""
159
+ new_rev = revisions.make_revision("kbrev", body, source=source)
134
160
  if status == "approved" or it.get("status") != "approved":
161
+ if old_body and old_body != body:
162
+ it.setdefault("revisions", []).append(
163
+ revisions.revision_record(
164
+ "kbrev", old_body, source=it.get("source") or "",
165
+ extra={"body": old_body, "revision_id": old_rev or
166
+ revisions.revision_id("kbrev", revisions.content_sha256(old_body))}))
167
+ it["revisions"] = it["revisions"][-5:]
135
168
  it["body"] = body
169
+ it.update({k: new_rev[k] for k in ("revision_id", "content_sha256")})
136
170
  if status == "approved":
137
171
  it["status"] = "approved"
138
172
  else:
139
173
  it.setdefault("revisions", []).append(
140
- {"at": _now(), "body": body, "source": source})
174
+ revisions.revision_record("kbrev", body, source=source,
175
+ extra={"body": body}))
141
176
  it["revisions"] = it["revisions"][-5:] # 最多留 5 条修订候选
142
177
  for tg in tags:
143
178
  if tg not in (it.get("tags") or []):
@@ -154,6 +189,7 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
154
189
  it["updated_at"] = _now()
155
190
  _save(data)
156
191
  return it
192
+ rev = revisions.make_revision("kbrev", body, source=source)
157
193
  it = {"id": kid, "scope": scope, "title": title, "body": body,
158
194
  "tags": tags, "status": status, "enabled": True,
159
195
  "source": source, "source_file": source_file,
@@ -161,6 +197,7 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
161
197
  "confidence": confidence or "medium",
162
198
  "revisions": [], "hits": 0, "seen": 1,
163
199
  "created_at": _now(), "updated_at": _now(), "kind": "knowledge"}
200
+ it.update({k: rev[k] for k in ("revision_id", "content_sha256")})
164
201
  items.append(it)
165
202
  _save(data)
166
203
  return it
@@ -197,7 +234,17 @@ def entry_op(entry_id, op, fields=None):
197
234
  if str(f.get("title") or "").strip():
198
235
  hit["title"] = str(f["title"]).strip()[:80]
199
236
  if str(f.get("body") or "").strip():
200
- hit["body"] = str(f["body"]).strip()[:1500]
237
+ new_body = str(f["body"]).strip()[:1500]
238
+ old_body = str(hit.get("body") or "")
239
+ if old_body != new_body:
240
+ hit.setdefault("revisions", []).append(
241
+ revisions.revision_record(
242
+ "kbrev", old_body, source=hit.get("source") or "",
243
+ extra={"body": old_body}))
244
+ hit["revisions"] = hit["revisions"][-5:]
245
+ hit["body"] = new_body
246
+ hit.update({k: revisions.make_revision("kbrev", new_body)[k]
247
+ for k in ("revision_id", "content_sha256")})
201
248
  if "tags" in f:
202
249
  hit["tags"] = [str(t).strip()[:20]
203
250
  for t in (f.get("tags") or []) if str(t).strip()][:8]
@@ -1227,6 +1227,13 @@ def _sync_qwen_settings(entry, model, prov):
1227
1227
  path = _guard_home(_config_path(entry))
1228
1228
  if not path:
1229
1229
  return "qwen 配置路径无效或越出用户主目录,已拒绝"
1230
+ # 内闸防 anthropic 面混进 OPENAI_BASE_URL(2026-09-24 戍边骑奴案:史上
1231
+ # 某版把 .../api/anthropic 写进去,qwen 拿 openai 协议打 anthropic 路由,
1232
+ # 网关恒回 404 被误判「网关限流」,每轮 run 起点白烧 2-3 次重试)。
1233
+ # auto 协议不拦——适配层探出的 openai 面由外层 launch_pick 负责把关。
1234
+ if str(prov.get("protocol") or "").lower() == "anthropic":
1235
+ return ("qwen 只认 OpenAI 兼容端点,拒绝把 anthropic 面 %s 写进 OPENAI_BASE_URL"
1236
+ % (prov.get("base_url") or "?"))
1230
1237
  updates = {"OPENAI_API_KEY": prov.get("api_key") or "",
1231
1238
  "OPENAI_BASE_URL": prov.get("base_url") or ""}
1232
1239
  if model:
@@ -24,6 +24,7 @@ import os
24
24
  import ipaddress
25
25
  import json
26
26
  import re
27
+ import secrets
27
28
  import shutil
28
29
  import socket
29
30
  import subprocess
@@ -343,11 +344,13 @@ def _entries_from_cache(cache=None):
343
344
  return entries
344
345
 
345
346
 
346
- def view(offset=0, limit=_PAGE_LIMIT, source=None, q=None):
347
+ def view(offset=0, limit=_PAGE_LIMIT, source=None, q=None, installed=None):
347
348
  """外部目录视图(服务端过滤 + 分页)。缓存全在本地,对全量条目过滤再切页
348
349
  零成本——搜索/来源筛选不再受「已加载页」限制,滚动翻页逛完全部目录。
350
+ installed=真值时只看已安装(「已安装 N」入口的过滤器);installed_total
351
+ 始终是**未过滤前**的全量已装数(徽章计数与过滤条件无关)。
349
352
  返回 {sources, entries(本页), total(过滤后全量), offset, has_more,
350
- categories}。"""
353
+ categories, installed_total}。"""
351
354
  cache = _load_cache()
352
355
  entries = _entries_from_cache(cache)
353
356
  sources = []
@@ -360,6 +363,9 @@ def view(offset=0, limit=_PAGE_LIMIT, source=None, q=None):
360
363
  for e in entries:
361
364
  if e["category"] and e["category"] not in cats:
362
365
  cats.append(e["category"])
366
+ installed_total = sum(1 for e in entries if e["installed"])
367
+ if installed:
368
+ entries = [e for e in entries if e["installed"]]
363
369
  if source:
364
370
  entries = [e for e in entries if e["source_id"] == source]
365
371
  qq = str(q or "").strip().lower()
@@ -372,7 +378,7 @@ def view(offset=0, limit=_PAGE_LIMIT, source=None, q=None):
372
378
  page = entries[offset:offset + limit]
373
379
  return {"sources": sources, "entries": page, "total": len(entries),
374
380
  "offset": offset, "has_more": offset + limit < len(entries),
375
- "categories": cats}
381
+ "categories": cats, "installed_total": installed_total}
376
382
 
377
383
 
378
384
  def _fetch_clawhub_catalog():
@@ -659,7 +665,9 @@ def _safe_extract(zf, dest):
659
665
 
660
666
 
661
667
  def _download_zip(entry, tmp):
662
- """下载 zip(校验 sha256)→ 安全解包 → 返回插件根目录。"""
668
+ """下载 zip(校验 sha256)→ 安全解包 → 返回 (插件根目录, 完整性说明)。
669
+ 完整性:清单带 sha256 且校验通过 = verified;清单没带 = unverified
670
+ (靠 https 传输 + 解包后的纯技能白名单兜底,如实告知不装懂)。"""
663
671
  data = _fetch(entry["install"]["url"], cap=_CAP_DOWNLOAD)
664
672
  want = (entry["install"].get("sha256") or "").lower()
665
673
  if want:
@@ -667,7 +675,7 @@ def _download_zip(entry, tmp):
667
675
  if got != want:
668
676
  raise ValueError("sha256 校验不符(期望 %s,实际 %s)" % (want[:12], got[:12]))
669
677
  dest = _safe_extract(zipfile.ZipFile(io.BytesIO(data)), tmp / "unzip")
670
- return _plugin_root(dest)
678
+ return _plugin_root(dest), ("verified" if want else "unverified")
671
679
 
672
680
 
673
681
  def _gh_split(url):
@@ -870,7 +878,7 @@ def _download_clawhub(entry, tmp):
870
878
  _CLAWHUB_BASE, q(inst["slug"], safe=""), q(inst["reference"] or inst["slug"], safe=""))
871
879
  data = _fetch(url, cap=_CAP_DOWNLOAD)
872
880
  dest = _safe_extract(zipfile.ZipFile(io.BytesIO(data)), tmp / "clawhub")
873
- return _plugin_root(dest)
881
+ return _plugin_root(dest), "unverified"
874
882
 
875
883
 
876
884
  def _plugin_root(base):
@@ -961,9 +969,20 @@ def build_files(entry, root):
961
969
  return out, None, stripped
962
970
 
963
971
 
964
- def install_remote(entry_id):
965
- """安装外部目录插件:下载 → 纯技能检查 → 转换 → 复用 market 安装通道。
966
- 返回 (结果 dict, 错误)。"""
972
+ def _fetch_root(entry, tmp):
973
+ """按来源下载并解包,返回 (插件根目录, 完整性说明)。
974
+ verified=zip 带 sha256 且校验通过;unverified=直链无哈希(https+白名单兜底);
975
+ git-ref/git-head=git 类来源按 ref/HEAD 取树。"""
976
+ kind = entry["install"]["kind"]
977
+ if kind == "zip":
978
+ return _download_zip(entry, tmp)
979
+ if kind == "clawhub":
980
+ return _download_clawhub(entry, tmp)
981
+ return _download_git_any(entry, tmp), \
982
+ ("git-ref" if entry["install"].get("ref") else "git-head")
983
+
984
+
985
+ def _resolve_entry(entry_id):
967
986
  entries = _entries_from_cache()
968
987
  entry = next((e for e in entries if e["id"] == entry_id), None)
969
988
  if not entry:
@@ -972,35 +991,114 @@ def install_remote(entry_id):
972
991
  return None, entry["block_reason"] or "该插件不适配 CodeBee"
973
992
  if entry["install"]["kind"] == "unsupported":
974
993
  return None, "来源类型不支持(仅支持 zip 直链、git 子目录与 ClawHub)"
994
+ return entry, None
995
+
996
+
997
+ # 预览→确认安装两阶段之间的已检内容缓存:token 一次性、TTL 10 分钟、最多 8 份
998
+ # (files 是纯文本 dict、总量有 _CAP_TOTAL_TEXT 上限,缓存占用有界)。
999
+ _PREVIEW_TTL = 600
1000
+ _PREVIEW_MAX = 8
1001
+ _PREVIEW_CACHE = {}
1002
+
1003
+
1004
+ def _preview_purge():
1005
+ now = time.time()
1006
+ for k in [k for k, v in _PREVIEW_CACHE.items() if now - v["ts"] > _PREVIEW_TTL]:
1007
+ _PREVIEW_CACHE.pop(k, None)
1008
+ while len(_PREVIEW_CACHE) > _PREVIEW_MAX:
1009
+ _PREVIEW_CACHE.pop(min(_PREVIEW_CACHE, key=lambda k: _PREVIEW_CACHE[k]["ts"]))
1010
+
1011
+
1012
+ def _skill_names_from_files(files, entry):
1013
+ """从转换后的 files 键推技能名:market-<id>.md 是主技能(用条目名),
1014
+ market-<id>__<名>.md 是多技能包里的其余技能(用目录名)。"""
1015
+ names = []
1016
+ for k in files:
1017
+ if "/" in k or not k.endswith(".md") or not k.startswith("market-"):
1018
+ continue
1019
+ base = k[len("market-"):-3]
1020
+ names.append("__" in base and base.split("__", 1)[1] or entry["title"])
1021
+ return sorted(set(names))
1022
+
1023
+
1024
+ def preview_remote(entry_id):
1025
+ """安装前预览(先看后装):下载 → 剥离式检查 → 转换,但**不落盘安装**。
1026
+ 返回 (预览 dict, 错误)。dict 带 token,确认安装时把 token 传回
1027
+ install_remote 即安装这份已检内容,不重复下载。"""
1028
+ entry, err = _resolve_entry(entry_id)
1029
+ if err:
1030
+ return None, err
975
1031
  tmp = Path(tempfile.mkdtemp(prefix="codebee-mkt-"))
976
1032
  try:
977
- kind = entry["install"]["kind"]
978
- if kind == "zip":
979
- root = _download_zip(entry, tmp)
980
- elif kind == "clawhub":
981
- root = _download_clawhub(entry, tmp)
982
- else:
983
- root = _download_git_any(entry, tmp)
1033
+ root, integrity = _fetch_root(entry, tmp)
984
1034
  files, blocked, stripped = build_files(entry, root)
985
1035
  if blocked:
986
1036
  return None, blocked
987
- res, err = market.install_files(entry_id, entry["title"], files, extra={
988
- "remote": {"source": entry["source_id"], "name": entry["name"],
989
- "version": entry["version"], "homepage": entry["homepage"]},
990
- })
991
- if err:
992
- return None, err
993
- res["skills"] = sum(1 for k in files if k.endswith(".md") and "/" not in k)
994
- res["stripped"] = sorted(stripped)
995
- return res, None
1037
+ _preview_purge()
1038
+ token = secrets.token_hex(12)
1039
+ _PREVIEW_CACHE[token] = {"entry_id": entry_id, "files": files,
1040
+ "stripped": stripped, "ts": time.time()}
1041
+ file_list = sorted(files.items(), key=lambda kv: kv[0])
1042
+ return {
1043
+ "token": token, "id": entry_id, "title": entry["title"],
1044
+ "source": entry["source_name"], "version": entry["version"],
1045
+ "homepage": entry["homepage"], "integrity": integrity,
1046
+ "skills": _skill_names_from_files(files, entry),
1047
+ "files_total": len(files),
1048
+ "total_chars": sum(len(v) for v in files.values()),
1049
+ "file_list": [{"path": k, "chars": len(v)} for k, v in file_list[:60]],
1050
+ "stripped": sorted(stripped)[:40], "stripped_total": len(stripped),
1051
+ "expires_in": _PREVIEW_TTL,
1052
+ }, None
996
1053
  except ValueError as e:
997
1054
  return None, str(e)
998
- except Exception as e: # 网络/解包等意外错误也要兜成用户可读的一句话
1055
+ except Exception as e:
999
1056
  return None, "下载或解析插件失败: %s" % e
1000
1057
  finally:
1001
1058
  shutil.rmtree(tmp, ignore_errors=True)
1002
1059
 
1003
1060
 
1061
+ def install_remote(entry_id, token=""):
1062
+ """安装外部目录插件:下载 → 纯技能检查 → 转换 → 复用 market 安装通道。
1063
+ token 非空且命中预览缓存时直接安装那份已检内容(不重复下载);token
1064
+ 一次性(装成功即焚,防重放)。返回 (结果 dict, 错误)。"""
1065
+ entry, err = _resolve_entry(entry_id)
1066
+ if err:
1067
+ return None, err
1068
+ cached = None
1069
+ if token:
1070
+ _preview_purge()
1071
+ c = _PREVIEW_CACHE.get(str(token))
1072
+ if c and c["entry_id"] == entry_id:
1073
+ cached = c
1074
+ if cached:
1075
+ files, stripped = cached["files"], cached["stripped"]
1076
+ else:
1077
+ tmp = Path(tempfile.mkdtemp(prefix="codebee-mkt-"))
1078
+ try:
1079
+ root, _integrity = _fetch_root(entry, tmp)
1080
+ files, blocked, stripped = build_files(entry, root)
1081
+ if blocked:
1082
+ return None, blocked
1083
+ except ValueError as e:
1084
+ return None, str(e)
1085
+ except Exception as e: # 网络/解包等意外错误也要兜成用户可读的一句话
1086
+ return None, "下载或解析插件失败: %s" % e
1087
+ finally:
1088
+ shutil.rmtree(tmp, ignore_errors=True)
1089
+ res, err = market.install_files(entry_id, entry["title"], files, extra={
1090
+ "remote": {"source": entry["source_id"], "name": entry["name"],
1091
+ "version": entry["version"], "homepage": entry["homepage"]},
1092
+ })
1093
+ if err:
1094
+ return None, err
1095
+ if cached:
1096
+ _PREVIEW_CACHE.pop(str(token), None)
1097
+ res["skills"] = sum(1 for k in files if k.endswith(".md") and "/" not in k)
1098
+ res["stripped"] = sorted(stripped)
1099
+ return res, None
1100
+
1101
+
1004
1102
  def remove_remote(entry_id):
1005
1103
  """卸载外部插件:记账与标记齐全时走 market 既有卸载;否则报错。"""
1006
1104
  return market.remove(entry_id)
@@ -29,6 +29,7 @@ anthropic/openai 可注入到 CLI;google 仅登记(当前无对应 CLI 可
29
29
  from __future__ import annotations
30
30
 
31
31
  import copy
32
+ import hashlib
32
33
  import ipaddress
33
34
  import json
34
35
  import os
@@ -759,6 +760,34 @@ def providers():
759
760
  return _load().get("providers", [])
760
761
 
761
762
 
763
+ def connection_identity(provider_id, key_id="") -> dict:
764
+ """Resolve provider/credential identity without exposing credential values.
765
+
766
+ A provider definition (protocol + normalized host), provider instance (the
767
+ configured provider id), account/key identity and credential id are kept as
768
+ separate fields for run snapshots. The existing provider store remains the
769
+ source of truth; this helper only supplies stable, redacted references.
770
+ """
771
+ provider_id = str(provider_id or "").strip()
772
+ key_id = str(key_id or "").strip()
773
+ prov = next((p for p in providers() if str(p.get("id") or "") == provider_id), {})
774
+ from . import upstream
775
+ host = upstream.normalize_upstream(prov.get("base_url") or "")
776
+ protocol = str(prov.get("protocol") or "")[:24]
777
+ definition_key = "%s|%s" % (protocol, host)
778
+ definition_id = "conn-def-" + hashlib.sha256(definition_key.encode("utf-8")).hexdigest()[:16]
779
+ account_id = key_id or "provider-default"
780
+ return {
781
+ "connection_id": provider_id,
782
+ "definition_id": definition_id,
783
+ "instance_id": provider_id,
784
+ "account_id": account_id,
785
+ "credential_id": account_id,
786
+ "protocol": protocol,
787
+ "upstream": host,
788
+ }
789
+
790
+
762
791
  def bindings():
763
792
  with _LOCK:
764
793
  return _load().get("bindings", {})