codebee 0.1.22 → 0.1.23

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.
@@ -38,6 +38,7 @@ def _user_pack_dir():
38
38
 
39
39
  MAX_INJECT_CHARS = 9000 # 单次注入上限(防止提示词爆炸;七猫+番茄双平台包并存后上调)
40
40
  MAX_LESSONS_INJECT = 8 # 注入的自动教训条数上限
41
+ WILDCARD_PACK_CHAR_CAP = 2400 # wildcard(scope=*)包单包注入预算:市场通配技能动辄数万字,全文注入会挤掉项目教训
41
42
 
42
43
  # 自动教训的问题分类:闭集枚举,对齐评审维度。沉淀时由复盘官归类(兜底路径按评审
43
44
  # 维度关键词映射),UI 据此分类过滤查看。刻意保持小而稳,避免类别爆炸让过滤失去意义。
@@ -390,14 +391,14 @@ def relevance_top(lessons, task, limit):
390
391
  if not probe:
391
392
  return lessons[:limit]
392
393
 
393
- # 使用反馈闭环(pmb「量化记忆真实帮助」借鉴):被选中次数多的教训排前
394
- def rank(x):
395
- grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
396
- overlap = -len(probe & grams)
397
- lid = x.get("id") or ""
398
- return (overlap, -int(x.get("hits") or 0), lid)
399
-
400
- return sorted(lessons, key=rank)[:limit]
394
+ # 使用反馈闭环(pmb「量化记忆真实帮助」借鉴):被选中次数多的教训排前
395
+ def rank(x):
396
+ grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
397
+ overlap = -len(probe & grams)
398
+ lid = x.get("id") or ""
399
+ return (overlap, -int(x.get("hits") or 0), lid)
400
+
401
+ return sorted(lessons, key=rank)[:limit]
401
402
 
402
403
 
403
404
  def block_for(task, scope_override=None, *, stable_order=False):
@@ -408,9 +409,14 @@ def block_for(task, scope_override=None, *, stable_order=False):
408
409
  stable_order=True(docs/migration/07-token-cost.md T1.2'):教训按 id 排序
409
410
  而非 hits——hits 在任务中途变化会让技能块字节级不稳定,打碎供应商的
410
411
  前缀缓存(同一任务 8 章应看到完全相同的技能块)。内容不变,只稳排序。
412
+
413
+ 预算纪律(2026-09-21 巡检实锤引入):wildcard(scope=*)包动辄数万字,
414
+ 39 个全文注入会先把 9000 字全局上限吃光,项目教训排在末尾被整段截掉。
415
+ 两道预算:①wildcard 包单包限额(定向命中的包不受限);②教训保底——
416
+ 包区最多吃到「全局上限 − 教训长度」,教训永远完整注入。
411
417
  """
412
418
  scope = scope_override or task.get("type") or "*"
413
- parts, used, lesson_ids = [], [], []
419
+ parts, used, lesson_ids = [], [], []
414
420
 
415
421
  for p in all_packs():
416
422
  if scope not in p["scopes"] and "*" not in p["scopes"]:
@@ -420,6 +426,9 @@ def block_for(task, scope_override=None, *, stable_order=False):
420
426
  txt = pack_text(p).strip()
421
427
  if not txt and not p.get("persona"):
422
428
  continue
429
+ # wildcard 包(仅靠 * 命中,非定向)单包限预算;定向命中不限,走全局
430
+ if scope not in p["scopes"] and len(txt) > WILDCARD_PACK_CHAR_CAP:
431
+ txt = txt[:WILDCARD_PACK_CHAR_CAP] + "\n…(本包超出通配注入预算已截断,完整内容见技能库)"
423
432
  # 3B:persona 独立成块(角色设定与规范正文分开,模型更易区分 obey 层级)
424
433
  if p.get("persona"):
425
434
  parts.append("### 【角色设定:%s】\n%s" % (p["name"], str(p["persona"]).strip()))
@@ -429,23 +438,32 @@ def block_for(task, scope_override=None, *, stable_order=False):
429
438
 
430
439
  lessons = relevance_top(list_lessons(scope, only_enabled=True), task,
431
440
  MAX_LESSONS_INJECT)
441
+ lesson_part = ""
432
442
  if lessons:
433
443
  if stable_order:
434
444
  lessons.sort(key=lambda x: x.get("id") or "")
435
445
  lines = []
436
- for x in lessons:
437
- lines.append("- **%s**:%s" % (x["title"], x["content"]))
438
- used.append(x["id"])
439
- lesson_ids.append(x["id"])
440
- parts.append("### 【本项目已沉淀的教训(历史评审反复出现,务必规避)】\n" + "\n".join(lines))
441
-
442
- if not parts:
446
+ for x in lessons:
447
+ lines.append("- **%s**:%s" % (x["title"], x["content"]))
448
+ used.append(x["id"])
449
+ lesson_ids.append(x["id"])
450
+ lesson_part = ("### 【本项目已沉淀的教训(历史评审反复出现,务必规避)】\n"
451
+ + "\n".join(lines))
452
+
453
+ if not parts and not lesson_part:
443
454
  return "", []
444
- text = "## 经验库(写作/工程规范 + 历史教训,必须遵守)\n\n" + "\n\n".join(parts)
455
+
456
+ header = "## 经验库(写作/工程规范 + 历史教训,必须遵守)\n\n"
457
+ budget = max(600, MAX_INJECT_CHARS - len(lesson_part))
458
+ body = "\n\n".join(parts)
459
+ if len(body) > budget:
460
+ body = body[:budget] + "\n…(包区已按预算截断,优先保住项目教训)"
461
+ body = (body + "\n\n" + lesson_part) if (body and lesson_part) else (body or lesson_part)
462
+ text = header + body
445
463
  if len(text) > MAX_INJECT_CHARS:
446
464
  text = text[:MAX_INJECT_CHARS] + "\n…(已截断)"
447
- if lesson_ids:
448
- bump_hits(lesson_ids) # 包 id 不参与教训热度,命中数据只保留一份真源
465
+ if lesson_ids:
466
+ bump_hits(lesson_ids) # 包 id 不参与教训热度,命中数据只保留一份真源
449
467
  return text, used
450
468
 
451
469
 
@@ -2,6 +2,8 @@
2
2
  """任务编译器:把用户任务和预置流程编译成统一、可审计的运行规格。"""
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Mapping
6
+
5
7
  from . import dispatch, flows
6
8
 
7
9
  SCHEMA_VERSION = 1
@@ -41,7 +43,7 @@ def _capabilities(task, dimension, engine):
41
43
 
42
44
  def compile_task(task):
43
45
  """返回统一任务规格;输入缺失或字段异常时保持可编排的安全兜底。"""
44
- raw = dict(task or {})
46
+ raw = dict(task) if isinstance(task, Mapping) else {}
45
47
  ttype = str(raw.get("type") or "direct").strip().lower()
46
48
  flow = flows.get_flow(ttype) or {}
47
49
  engine = str(raw.get("engine") or flow.get("engine") or
@@ -53,6 +55,8 @@ def compile_task(task):
53
55
  rubric = raw.get("rubric") or flow.get("rubric") or []
54
56
  if isinstance(rubric, str):
55
57
  rubric = [x.strip() for x in rubric.replace(",", ",").split(",") if x.strip()]
58
+ elif not isinstance(rubric, (list, tuple)):
59
+ rubric = flow.get("rubric") or []
56
60
  rubric = [str(x).strip() for x in rubric if str(x).strip()][:8]
57
61
  deliverable = str(raw.get("manuscript") or flow.get("manuscript") or "").strip()
58
62
  serial = raw.get("serial") if isinstance(raw.get("serial"), dict) else None
package/app/main.py CHANGED
@@ -296,6 +296,18 @@ class Handler(BaseHTTPRequestHandler):
296
296
  # 一键反馈 Issue 的预填摘要(标题+正文,全程脱敏,用户亲手提交)
297
297
  from core import telemetry
298
298
  return self._json(200, telemetry.issue_report(days=30))
299
+ if path == "/api/ports":
300
+ # 端口占用诊断(借鉴 leftopen):谁在听、PID/进程/项目归属、
301
+ # 是否仅本机。?port=N 只看单端口。只读,不碰任何进程。
302
+ from core import portscan
303
+ q = parse_qs(urlparse(self.path).query)
304
+ ports = portscan.listening_ports()
305
+ focus = (q.get("port") or [""])[0]
306
+ if focus.isdigit():
307
+ ports = [p for p in ports if p["port"] == int(focus)]
308
+ for p in ports:
309
+ p["self"] = p.get("pid") == os.getpid()
310
+ return self._json(200, {"ports": ports})
299
311
  m = re.match(r"^/api/runs/([^/]+)$", path)
300
312
  if m:
301
313
  run = store.get_run(m.group(1))
@@ -560,6 +572,22 @@ class Handler(BaseHTTPRequestHandler):
560
572
  return self._json(200, {"ok": True, "health": health.snapshot()})
561
573
  if path == "/api/attachments":
562
574
  return self._api_add_attachment()
575
+ if path == "/api/ports/close":
576
+ # 温和关闭端口占用进程(借鉴 leftopen):SIGTERM only、关前重验
577
+ # PID 绑定;系统进程/自身服务在 portscan 内拒关。设备控制权守卫
578
+ # 已在上方统一生效(不在豁免清单里)。
579
+ from core import portscan
580
+ body = self._body()
581
+ try:
582
+ cport = int(body.get("port") or 0)
583
+ except (TypeError, ValueError):
584
+ return self._json(400, {"error": "port 必须是数字"})
585
+ if not (1 <= cport <= 65535):
586
+ return self._json(400, {"error": "port 越界"})
587
+ ok, msg = portscan.close_port(cport)
588
+ return self._json(200 if ok else 409,
589
+ {"ok": ok, "message": msg,
590
+ "error": None if ok else msg})
563
591
  if path == "/api/dir/save":
564
592
  # 「查看文件」弹窗编辑保存(本机 + 控制权 + 防穿越 + mtime 冲突检测)
565
593
  return self._api_dir_save()
@@ -2412,6 +2440,20 @@ def main():
2412
2440
  hint = ("(Windows 排查:netstat -ano | findstr :%d 找到 PID,"
2413
2441
  "tasklist /FI \"PID eq <PID>\" 看是谁;旧进程杀掉或换 --port)"
2414
2442
  % args.port)
2443
+ # 端口占用自动指认(借鉴 leftopen 38★):直接报出 PID/进程/项目归属,
2444
+ # 用户不用再手跑 netstat+tasklist 两连。识别不出时回落上面的手工指路。
2445
+ try:
2446
+ from core import portscan as _ps
2447
+ for _h in _ps.listening_ports():
2448
+ if _h.get("port") != args.port:
2449
+ continue
2450
+ _who = _h.get("process") or "未知进程"
2451
+ _proj = (",项目 %s" % _h["project"]) if _h.get("project") else ""
2452
+ hint = ("占用者:PID %d(%s%s);旧进程杀掉或换 --port 重启"
2453
+ % (_h.get("pid") or 0, _who, _proj))
2454
+ break
2455
+ except Exception:
2456
+ pass
2415
2457
  raise SystemExit("[CodeBee] 端口 %d 已被占用,无法启动:%s %s"
2416
2458
  % (args.port, e, hint))
2417
2459
 
package/app/ui/app.js CHANGED
@@ -3420,6 +3420,7 @@ function drawTaskDetail(key, runs) {
3420
3420
  '<span class="stat">tokens <b>' + sum("tokens") + "</b></span>" +
3421
3421
  (latest.error ? '<span class="stat err">' + errTag(latest.error) + esc(latest.error.slice(0, 200)) + "</span>" : "");
3422
3422
  $("rd-plan").classList.add("hidden");
3423
+ renderRouting(latest);
3423
3424
  let html = "";
3424
3425
  ordered.forEach((r, i) => {
3425
3426
  const runNo = runs.length - i;
@@ -3800,6 +3801,7 @@ async function renderRunDetail() {
3800
3801
  '<span class="stat tasksum hidden" id="rd-meta-task"></span>';
3801
3802
  if (S.detailSide) fillMetaTask(S.detailSide.stats || {}); // 缓存命中:轮询重画不闪丢累计组
3802
3803
  renderPlan(run);
3804
+ renderRouting(run);
3803
3805
  // 详情页优先展示最近一步,排查运行中的任务时无需滚到底部;
3804
3806
  // 蜂巢泳道仍按原始流程顺序呈现,避免破坏阶段语义。
3805
3807
  $("rd-steps").innerHTML = (run.steps || []).slice().reverse().map((s) =>
@@ -5443,6 +5445,75 @@ function renderPlan(run) {
5443
5445
  '<span class="role">' + esc(s.title || "") + "</span>" +
5444
5446
  '<span class="sum">' + esc(s.detail || "") + "</span></div>").join("") + "</div>" + routeHtml;
5445
5447
  }
5448
+
5449
+ /* 运行详情的统一调度审计:任务画像和实际选路来自同一次编译结果。
5450
+ * 旧运行没有 task_spec/route_plan 时保持隐藏,避免把历史数据伪装成当前决策。 */
5451
+ function renderRouting(run) {
5452
+ let box = $("rd-routing");
5453
+ if (!box) {
5454
+ const steps = $("rd-steps");
5455
+ if (!steps || !steps.parentNode) return;
5456
+ box = document.createElement("div");
5457
+ box.id = "rd-routing";
5458
+ box.className = "rd-routing hidden";
5459
+ steps.parentNode.insertBefore(box, steps);
5460
+ }
5461
+ const spec = run && run.task_spec;
5462
+ const plan = run && run.route_plan;
5463
+ if (!spec || !plan) { box.classList.add("hidden"); box.innerHTML = ""; return; }
5464
+ const type = spec.type || "direct";
5465
+ const difficulty = spec.difficulty || "default";
5466
+ const dimension = spec.dimension || "reasoning";
5467
+ const caps = Array.isArray(spec.capabilities) ? spec.capabilities.filter(Boolean) : [];
5468
+ const typeLabel = ((flowById(type) || {}).name) || type;
5469
+ const difficultyLabel = t(({ easy: "简单", default: "标准", hard: "困难" })[difficulty] || difficulty);
5470
+ const dimensionLabel = t(({ coding: "编码", writing: "写作", reasoning: "推理", vision: "视觉" })[dimension] || dimension);
5471
+ const capLabels = {
5472
+ coding: "编码", writing: "写作", reasoning: "推理", vision: "视觉",
5473
+ filesystem: "文件读写", verification: "验证", long_context: "长上下文",
5474
+ continuity: "连续性", attachments: "附件",
5475
+ };
5476
+ const capsText = caps.length ? caps.map((x) => t(capLabels[x] || x)).join(t("、")) : t("通用能力");
5477
+ const selectedLabel = (entry) => entry && (entry.label || entry.agent_id || "") || t("未选定");
5478
+ const roleBlock = (key, title) => {
5479
+ const route = plan[key] || {};
5480
+ const candidates = Array.isArray(route.candidates) ? route.candidates : [];
5481
+ if (!route.selected && !candidates.length) return "";
5482
+ const selected = candidates.find((x) => x.agent_id === route.selected) || candidates[0];
5483
+ const participants = Array.isArray(route.participants) ? route.participants : [];
5484
+ const activeIds = participants.length ? participants.slice() : (route.selected ? [route.selected] : []);
5485
+ if (route.selected && activeIds.includes(route.selected)) {
5486
+ activeIds.splice(activeIds.indexOf(route.selected), 1);
5487
+ activeIds.unshift(route.selected);
5488
+ }
5489
+ const activeLabels = activeIds.map((id) => {
5490
+ const item = candidates.find((x) => x.agent_id === id);
5491
+ return selectedLabel(item || { agent_id: id });
5492
+ }).filter(Boolean);
5493
+ const fallback = Array.isArray(route.fallback) ? route.fallback : [];
5494
+ const fallbackLabels = fallback.map((id) => {
5495
+ const item = candidates.find((x) => x.agent_id === id);
5496
+ return selectedLabel(item || { agent_id: id });
5497
+ }).filter(Boolean);
5498
+ const rows = candidates.map((x) =>
5499
+ '<div class="rd-route-row"><span class="rd-route-score">' + esc(Number(x.score || 0).toFixed(1)) +
5500
+ '</span><span class="rd-route-agent">' + esc(selectedLabel(x)) + '</span><span class="rd-route-reason">' +
5501
+ esc(x.reason || "") + '</span></div>').join("");
5502
+ return '<div class="rd-route-role"><div class="rd-route-role-head"><b>' + esc(title) +
5503
+ '</b><span>' + esc(activeLabels.join(" + ") || selectedLabel(selected)) + '</span></div>' +
5504
+ (route.selection_reason ? '<div class="rd-route-selection">' + esc(route.selection_reason) + '</div>' : "") +
5505
+ (fallbackLabels.length ? '<div class="rd-route-fallback">' + esc(t("降级:")) + esc(fallbackLabels.join(" → ")) + '</div>' : "") +
5506
+ (rows ? '<details class="rd-route-candidates"><summary>' + esc(t("查看候选评分")) + '</summary>' + rows + '</details>' : "") +
5507
+ '</div>';
5508
+ };
5509
+ const roles = [roleBlock("implement", t("执行")), roleBlock("review", t("评审"))].filter(Boolean).join("");
5510
+ if (!roles) { box.classList.add("hidden"); box.innerHTML = ""; return; }
5511
+ box.classList.remove("hidden");
5512
+ box.innerHTML = '<div class="rd-routing-head"><span class="rd-routing-title">' + esc(t("任务画像与调度")) +
5513
+ '</span><span class="rd-routing-spec">' + esc(t("{0} · {1} · {2}", t(typeLabel), difficultyLabel, dimensionLabel)) + '</span></div>' +
5514
+ '<div class="rd-routing-caps"><span>' + esc(t("需要:")) + '</span>' + esc(capsText) + '</div>' +
5515
+ '<div class="rd-route-roles">' + roles + '</div>';
5516
+ }
5446
5517
  /* 日志框顶部状态条:运行中 → ● 实时(呼吸点 + 最后刷新时间);结束 → 灰字已结束。
5447
5518
  * 没有它,用户分不清"日志在刷但 CLI 暂无输出"和"刷新坏了"——两者长得一模一样。 */
5448
5519
  function logLiveBadge(live) {
@@ -9348,6 +9419,64 @@ async function reportIssue() {
9348
9419
  }
9349
9420
  }
9350
9421
 
9422
+ /* 端口占用诊断(借鉴 leftopen):谁占着端口、属于哪个项目;可温和关闭 */
9423
+ async function scanPorts() {
9424
+ const box = $("ports-list");
9425
+ if (!box) return;
9426
+ const b = $("ports-scan");
9427
+ if (b) b.disabled = true;
9428
+ try {
9429
+ const r = await api("/api/ports");
9430
+ const ports = r.ports || [];
9431
+ if (!ports.length) { box.innerHTML = '<span class="hint">' + esc(t("没有发现监听端口")) + "</span>"; return; }
9432
+ box.innerHTML = "";
9433
+ const tbl = document.createElement("table");
9434
+ tbl.style.width = "100%";
9435
+ tbl.style.fontSize = "12px";
9436
+ tbl.style.borderCollapse = "collapse";
9437
+ const th = (s) => '<th style="text-align:left;padding:4px 8px;opacity:.7">' + esc(s) + "</th>";
9438
+ tbl.innerHTML = "<thead><tr>" + th(t("端口")) + th(t("进程")) + th(t("项目")) + th("") + "</tr></thead>";
9439
+ const tb = document.createElement("tbody");
9440
+ for (const p of ports) {
9441
+ const tr = document.createElement("tr");
9442
+ const td = (s) => '<td style="padding:4px 8px;border-top:1px solid var(--border)">' + s + "</td>";
9443
+ tr.innerHTML =
9444
+ td(p.port + (p.local_only ? ' <span class="pill">' + esc(t("仅本机")) + "</span>" : "") + (p.self ? ' <span class="pill">' + esc(t("本服务")) + "</span>" : "")) +
9445
+ td(esc((p.process || "?") + (p.pid ? " · pid " + p.pid : ""))) +
9446
+ td(p.project ? esc(p.project) : "-") +
9447
+ "<td></td>";
9448
+ const act = tr.lastElementChild;
9449
+ if (!p.self && p.pid) {
9450
+ const btn = document.createElement("button");
9451
+ btn.className = "ghost small";
9452
+ btn.textContent = t("关闭");
9453
+ btn.onclick = () => closePort(p.port);
9454
+ act.appendChild(btn);
9455
+ }
9456
+ tb.appendChild(tr);
9457
+ }
9458
+ tbl.appendChild(tb);
9459
+ box.appendChild(tbl);
9460
+ } catch (e) {
9461
+ box.innerHTML = '<span class="hint">' + esc(t("扫描失败:") + (e.message || e)) + "</span>";
9462
+ } finally {
9463
+ if (b) b.disabled = false;
9464
+ }
9465
+ }
9466
+
9467
+ async function closePort(port) {
9468
+ if (!confirm(t("向占用端口 %1 的进程发送温和关闭信号?(系统进程会被拒绝)").replace("%1", port))) return;
9469
+ try {
9470
+ const r = await api("/api/ports/close", { method: "POST", body: JSON.stringify({ port }) });
9471
+ toast(r.message || (r.ok ? t("已发送关闭信号") : t("未能关闭")), !r.ok);
9472
+ scanPorts();
9473
+ } catch (e) {
9474
+ toast(e.message || t("关闭失败"), true);
9475
+ }
9476
+ }
9477
+
9478
+
9479
+
9351
9480
  async function suCheck() {
9352
9481
  const b = $("su-check");
9353
9482
  if (b) b.disabled = true;
package/app/ui/i18n.js CHANGED
@@ -1867,6 +1867,23 @@
1867
1867
  "一键反馈 Issue": "Report Issue",
1868
1868
  "已打开 GitHub 反馈页,内容已预填,可直接提交(也可附上诊断包 zip)": "GitHub issue page opened with the report pre-filled — submit it, and feel free to attach the diagnostics zip",
1869
1869
  "反馈摘要生成失败:": "Failed to build issue summary: ",
1870
+ "端口占用": "Port Usage",
1871
+ "扫描本机端口": "Scan local ports",
1872
+ "谁占着端口、属于哪个项目一目了然;「仅本机」的端口外网不可达。关闭只发温和信号,系统进程与本服务自身拒绝关闭。": "See at a glance which process holds each port and which project it belongs to; \"local only\" ports are unreachable from outside. Close sends a gentle termination signal only — system processes and this service itself are refused.",
1873
+ "端口": "Port",
1874
+ "进程": "Process",
1875
+ "项目": "Project",
1876
+ "仅本机": "local only",
1877
+ "本服务": "this service",
1878
+ "关闭": "Close",
1879
+ "没有发现监听端口": "No listening ports found",
1880
+ "扫描失败:": "Scan failed: ",
1881
+ "向占用端口 %1 的进程发送温和关闭信号?(系统进程会被拒绝)": "Send a gentle close signal to the process holding port %1? (system processes are refused)",
1882
+ "已发送关闭信号": "Close signal sent",
1883
+ "未能关闭": "Could not close",
1884
+ "关闭失败": "Close failed",
1885
+
1886
+
1870
1887
 
1871
1888
  // —— 禅道 Bug 自动修复 ——
1872
1889
  "禅道": "ZenTao",
@@ -2261,6 +2278,23 @@
2261
2278
  "旧版浏览器缓存残留": "Legacy browser cache remains",
2262
2279
  "历史备份包": "Old backup archives",
2263
2280
  "服务日志瘦身": "Service log trim",
2281
+ "任务画像与调度": "Task profile & routing",
2282
+ "通用能力": "general capabilities",
2283
+ "未选定": "Not selected",
2284
+ "执行": "Implement",
2285
+ "降级:": "Fallback: ",
2286
+ "查看候选评分": "View candidate scores",
2287
+ "需要:": "Needs: ",
2288
+ "简单": "Easy",
2289
+ "标准": "Standard",
2290
+ "困难": "Hard",
2291
+ "编码": "Coding",
2292
+ "写作": "Writing",
2293
+ "推理": "Reasoning",
2294
+ "视觉": "Vision",
2295
+ "文件读写": "File access",
2296
+ "长上下文": "Long context",
2297
+ "连续性": "Continuity",
2264
2298
  "发布浏览器缓存": "Publishing browser cache",
2265
2299
  "保留运行记录与结果报告,只删过程中的原始日志": "Keeps run records and result reports; only raw process logs are deleted",
2266
2300
  "发布过程留档截图,超期即清": "Screenshots kept as publishing evidence, removed once expired",