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,319 @@
1
+ # -*- coding: utf-8 -*-
2
+ """智能体目录(catalog):本机可管理/可编排的 CLI 清单。
3
+
4
+ data/catalog.json 是唯一事实来源,首次运行自动生成;用户可直接编辑补充
5
+ 安装命令(install/upgrade)与编排模板(orch.argv_template),保存后点 UI 的
6
+ "重新加载"或重启生效。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import json
12
+ import re
13
+ import threading
14
+
15
+ from . import paths
16
+
17
+ # config.format 取值:
18
+ # toml-line : 按行正则读写 `model = "..."`(适合 codex config.toml)
19
+ # json : 整体 JSON 读写 "model" 键(适合 claude settings.json)
20
+ # jsonc : 正则读写 "model": "..."(适合 opencode.jsonc,显示为主)
21
+ # toml-section: 读写 TOML 指定 [section] 表下的键;model_key 写点号路径(表.键)
22
+ # (适合 grok 的 "models.default")
23
+ # yaml-line : 按行读写 YAML 嵌套标量;model_key 写点号路径(段.键)
24
+ # (适合 dsh 的 "agent-default-model.model")
25
+ # orch.kind 取值: codex | claude | opencode | qwen | aider | generic | null
26
+ # null = 仅管理,不参与编排(桌面端等)
27
+ # launch 字段(「一键打开」):kind=web 后台起服务并自动开浏览器(command 里
28
+ # 自带 --port 时必须与 port 一致,port 同时是就绪探测的依据);
29
+ # kind=console 新开终端窗口跑交互 TUI。command 是完整命令行(cmd /c 执行)。
30
+ DEFAULT_CATALOG = [
31
+ {
32
+ "id": "codex-cli", "name": "Codex CLI", "cli_group": "installed",
33
+ "note": "ChatGPT 官方编程智能体;本机走自定义 provider(gpt-5.5)",
34
+ "detect": {"cli": "codex"},
35
+ "orch": {"kind": "codex", "command": "codex"},
36
+ "config": {"path": "~/.codex/config.toml", "format": "toml-line", "model_key": "model"},
37
+ "install": "npm install -g @openai/codex",
38
+ "upgrade": "npm install -g @openai/codex@latest",
39
+ "default_enabled": True,
40
+ },
41
+ {
42
+ "id": "claude-code", "name": "Claude Code", "cli_group": "installed",
43
+ "note": "Anthropic 官方 CLI;无头调用自动注入 Git Bash 路径与输出上限",
44
+ "detect": {"cli": "claude"},
45
+ "orch": {"kind": "claude", "command": "claude"},
46
+ "config": {"path": "~/.claude/settings.json", "format": "json", "model_key": "model"},
47
+ "install": "winget install -e --id Anthropic.ClaudeCode",
48
+ "upgrade": "winget upgrade -e --id Anthropic.ClaudeCode",
49
+ "default_enabled": True,
50
+ },
51
+ {
52
+ "id": "opencode", "name": "OpenCode CLI", "cli_group": "installable",
53
+ "note": "无头模式:opencode run(stdin 传入提示词)",
54
+ "detect": {"cli": "opencode"},
55
+ "orch": {"kind": "opencode", "command": "opencode"},
56
+ "config": {"path": "~/.config/opencode/opencode.jsonc", "format": "jsonc", "model_key": "model"},
57
+ "install": "npm install -g opencode-ai",
58
+ "upgrade": "npm install -g opencode-ai@latest",
59
+ "default_enabled": False,
60
+ },
61
+ {
62
+ "id": "qwencode", "name": "QwenCode", "cli_group": "installable",
63
+ "note": "通义千问编码 CLI(gemini-cli 系);无头走 stdin;绑定凭据经打开注入"
64
+ "写 settings.json 的 env 段(OPENAI_*,qwen 存在即优先 openai 兼容通道)",
65
+ "detect": {"cli": "qwen"},
66
+ "orch": {"kind": "qwen", "command": "qwen"},
67
+ # settings.json 顶层 "model" 已是 legacy(qwen 忽略并告警,新格式是
68
+ # model.name)——模型落盘由打开注入器写 OPENAI_MODEL env 负责,
69
+ # 这里不再走 write_model
70
+ "config": {"path": "~/.qwen/settings.json", "format": None, "model_key": None},
71
+ "install": "npm install -g @qwen-code/qwen-code",
72
+ "upgrade": "npm install -g @qwen-code/qwen-code@latest",
73
+ "default_enabled": False,
74
+ },
75
+ {
76
+ "id": "aider", "name": "Aider", "cli_group": "installable",
77
+ "note": "Python 系结对编程 CLI;用 py -3.13 安装,模型经其配置/环境变量设置",
78
+ "detect": {"cli": "aider"},
79
+ "orch": {"kind": "aider", "command": "aider"},
80
+ "config": {"path": "~/.aider.conf.yml", "format": None, "model_key": None},
81
+ "install": "py -3.13 -m pip install -U aider-chat",
82
+ "upgrade": "py -3.13 -m pip install -U aider-chat",
83
+ "default_enabled": False,
84
+ },
85
+ {
86
+ "id": "openclaw", "name": "OpenClaw", "cli_group": "installable",
87
+ "note": "网关型个人 AI 智能体(原 Clawdbot);编排模板装好后需验证",
88
+ "detect": {"cli": "openclaw"},
89
+ "orch": {"kind": "generic", "command": "openclaw", "argv_template": ["{prompt}"]},
90
+ "config": {"path": "~/.openclaw/openclaw.json", "format": "json", "model_key": None},
91
+ "install": "npm install -g openclaw",
92
+ "upgrade": "npm install -g openclaw@latest",
93
+ "default_enabled": False,
94
+ },
95
+ {
96
+ "id": "kimi-code", "name": "Kimi Code", "cli_group": "installable",
97
+ "note": "月之暗面 Kimi 编码 CLI(TypeScript 版,需 Node ≥22.19);旧 Python 版 kimi-cli 正在下线",
98
+ "detect": {"cli": "kimi"},
99
+ "orch": {"kind": "generic", "command": "kimi", "argv_template": ["-p", "{prompt}"]},
100
+ "config": {"path": "~/.kimi/config.json", "format": "json", "model_key": None},
101
+ "install": "npm install -g @moonshot-ai/kimi-code",
102
+ "upgrade": "npm install -g @moonshot-ai/kimi-code@latest",
103
+ "default_enabled": False,
104
+ },
105
+ {
106
+ "id": "mimo-code", "name": "MiMo Code", "cli_group": "installable",
107
+ "note": "小米 MiMo Code(opencode 衍生);无头调用是子命令 mimo run \"提示词\",-p 在该 CLI 是 --password",
108
+ "detect": {"cli": "mimo"},
109
+ "orch": {"kind": "generic", "command": "mimo", "argv_template": ["run", "{prompt}"],
110
+ "resume_argv_template": ["run", "-s", "{session}"]},
111
+ "config": {"path": "~/.mimo/config.json", "format": "json", "model_key": None},
112
+ "install": "npm install -g @mimo-ai/cli",
113
+ "upgrade": "npm install -g @mimo-ai/cli@latest",
114
+ "default_enabled": False,
115
+ },
116
+ {
117
+ "id": "grok-build", "name": "Grok Build", "cli_group": "installable",
118
+ "note": "xAI 终端编码智能体;可执行名是 grok(不是 grok-build);"
119
+ "默认模型在 ~/.grok/config.toml 的 [models] default(JSON 版配置不存在)",
120
+ "detect": {"cli": "grok"},
121
+ "orch": {"kind": "generic", "command": "grok", "argv_template": ["-p", "{prompt}"]},
122
+ "config": {"path": "~/.grok/config.toml", "format": "toml-section",
123
+ "model_key": "models.default"},
124
+ "install": "npm install -g @xai-official/grok",
125
+ "upgrade": "npm install -g @xai-official/grok@latest",
126
+ "default_enabled": False,
127
+ },
128
+ {
129
+ "id": "pi", "name": "Pi", "cli_group": "installable",
130
+ "note": "Earendil Works 的 Pi 编码 CLI(需 Node ≥22.19);包名必须带 @earendil-works/ 前缀",
131
+ "detect": {"cli": "pi"},
132
+ "orch": {"kind": "generic", "command": "pi", "argv_template": ["-p", "{prompt}"]},
133
+ "config": {"path": "~/.pi/config.json", "format": "json", "model_key": None},
134
+ "install": "npm install -g --ignore-scripts @earendil-works/pi-coding-agent",
135
+ "upgrade": "npm install -g --ignore-scripts @earendil-works/pi-coding-agent@latest",
136
+ "default_enabled": False,
137
+ },
138
+ {
139
+ "id": "deepseek-harness", "name": "DeepSeek Harness", "cli_group": "installable",
140
+ "note": "DeepSeek 官方 agent harness(dsh,profile 插件架构);无头是「一次性任务」——"
141
+ "答完即退、无交互后续、不支持会话恢复;任务只走位置参数(超长提示词受 Windows "
142
+ "命令行上限约 32k 约束);模型写进 ~/.dsh/settings.yaml 的 agent-default-model.model;"
143
+ "密钥由「CLI 绑定」注入 DEEPSEEK_API_KEY(优先级最高);端点注入 DEEPSEEK_BASE_URL,"
144
+ "但若 settings.yaml 已固定 llm-deepseek.baseURL,则以它为准(settings 高于 env)",
145
+ "detect": {"cli": "dsh"},
146
+ "orch": {"kind": "generic", "command": "dsh",
147
+ "argv_template": ["--profile", "headless", "{prompt}"]},
148
+ "config": {"path": "~/.dsh/settings.yaml", "format": "yaml-line",
149
+ "model_key": "agent-default-model.model"},
150
+ "install": "npm install -g @deepseek-ai/dsh",
151
+ "upgrade": "npm install -g @deepseek-ai/dsh@latest",
152
+ "default_enabled": False,
153
+ },
154
+ ]
155
+
156
+ _LOCK = threading.RLock()
157
+ _CACHE = {"entries": None}
158
+
159
+ # generic 类 CLI 实测出会话恢复方式后在这里登记({session}/{prompt} 占位),
160
+ # load() 幂等补进已有 data/catalog.json——用户手改过的字段不覆盖。
161
+ ORCH_RESUME_PATCH = {
162
+ "mimo-code": ["run", "-s", "{session}"],
163
+ }
164
+
165
+ # 「一键打开」配置({id: launch 字段}):load() 幂等补进没有 launch 的条目,
166
+ # 用户在 data/catalog.json 里手写过的 launch 不覆盖。
167
+ LAUNCH_PATCH = { "codex-cli": {"kind": "console", "command": "codex"},
168
+ "claude-code": {"kind": "console", "command": "claude"},
169
+ "opencode": {"kind": "console", "command": "opencode"},
170
+ "qwencode": {"kind": "console", "command": "qwen"},
171
+ "aider": {"kind": "console", "command": "aider"},
172
+ "openclaw": {"kind": "console", "command": "openclaw"},
173
+ "kimi-code": {"kind": "console", "command": "kimi"},
174
+ "mimo-code": {"kind": "console", "command": "mimo"},
175
+ "grok-build": {"kind": "console", "command": "grok"},
176
+ "pi": {"kind": "console", "command": "pi"},
177
+ # dsh 自带浏览器 UI(dsh web);端口固定以便「已在运行就直接开页面」的复用
178
+ # 判断,18790 避开 CodeBee 自身与常用测试端口。--no-open 关掉 dsh 自己开浏览器
179
+ # 的行为(否则它会和 CodeBee 就绪后各开一个标签页)
180
+ "deepseek-harness": {"kind": "web",
181
+ "command": "dsh web --port 18790 --no-open", "port": 18790},
182
+ }
183
+
184
+
185
+ # config.format 修正({id: config 字段}):load() 幂等覆盖。qwen 的 settings.json
186
+ # 顶层 "model" 是 legacy(被忽略并告警)——老用户的 catalog.json 里 qwencode 还是
187
+ # format=json,不修正的话每次打开都会写无效字段;覆盖无风险(写了也不生效)。
188
+ CONFIG_PATCH = {
189
+ "qwencode": {"path": "~/.qwen/settings.json", "format": None, "model_key": None},
190
+ # grok 实测配置是 TOML(config.toml 的 [models] default);早期版本错标成
191
+ # config.json + format=json,文件永远不存在导致「保存默认模型」必然失败——
192
+ # 必须覆盖修正(写 config.json 无任何效果,覆盖无风险)。
193
+ "grok-build": {"path": "~/.grok/config.toml", "format": "toml-section",
194
+ "model_key": "models.default"},
195
+ }
196
+
197
+
198
+ def npm_pkg_name(cmd):
199
+ """从 npm 安装命令里取包名(支持 @scope/name@latest)。
200
+
201
+ 跳过包名之前的 flag:`npm install -g --ignore-scripts @scope/pkg` 必须取到
202
+ @scope/pkg,否则「检查更新」会拿 flag 当包名去查 registry。
203
+ """
204
+ m = re.search(r"npm\s+(?:install|i)\s+(.+)$", cmd or "")
205
+ if not m:
206
+ return None
207
+ for tok in m.group(1).split():
208
+ if tok.startswith("-"):
209
+ continue
210
+ if tok.startswith("@"):
211
+ m2 = re.match(r"(@[^/]+/[^@]+)", tok)
212
+ return m2.group(1) if m2 else None
213
+ return tok.split("@")[0]
214
+ return None
215
+
216
+
217
+ def derive_uninstall(cmd):
218
+ """从安装命令推导卸载命令(npm / winget / pip 三种本机渠道)。
219
+
220
+ 卸载命令不单独维护一份,避免与安装命令不同步;认不出渠道返回 None,
221
+ 此时条目可显式配置 uninstall 字段覆盖。
222
+ """
223
+ c = (cmd or "").strip()
224
+ m = re.search(r"npm\s+(?:install|i)\s+(.+)$", c)
225
+ if m:
226
+ pkg = npm_pkg_name(c)
227
+ return "npm uninstall -g %s" % pkg if pkg else None
228
+ m = re.search(r"winget\s+install\b(.*)$", c)
229
+ if m:
230
+ return ("winget uninstall" + m.group(1)).strip() or None
231
+ m = re.search(r"((?:py\s+-[\d.]+|python3?|pip3?)\s+(?:-m\s+)?pip\s+install)\s+(.+)$", c)
232
+ if m:
233
+ # 去掉 -U / --upgrade 等 flag,只留包名
234
+ pkgs = [t for t in m.group(2).split() if not t.startswith("-")]
235
+ if pkgs:
236
+ head = m.group(1).replace("pip install", "pip uninstall")
237
+ return "%s -y %s" % (head, pkgs[0])
238
+ return None
239
+
240
+
241
+ def uninstall_command(entry):
242
+ """该条目的卸载命令:catalog 显式配置优先,否则由 install/upgrade 推导。"""
243
+ explicit = (entry.get("uninstall") or "").strip()
244
+ if explicit:
245
+ return explicit
246
+ return derive_uninstall(entry.get("install") or entry.get("upgrade") or "")
247
+
248
+
249
+ def _apply_resume_patch(entries):
250
+ for e in entries:
251
+ tmpl = ORCH_RESUME_PATCH.get(e.get("id"))
252
+ orch = e.get("orch")
253
+ if tmpl and isinstance(orch, dict) and orch.get("kind") == "generic" \
254
+ and "resume_argv_template" not in orch:
255
+ orch["resume_argv_template"] = list(tmpl)
256
+
257
+
258
+ def _apply_launch_patch(entries):
259
+ for e in entries:
260
+ if e.get("id") in LAUNCH_PATCH and not e.get("launch"):
261
+ e["launch"] = dict(LAUNCH_PATCH[e["id"]])
262
+
263
+
264
+ def _apply_config_patch(entries):
265
+ for e in entries:
266
+ patch = CONFIG_PATCH.get(e.get("id"))
267
+ if patch and e.get("config") != patch:
268
+ e["config"] = dict(patch)
269
+
270
+
271
+ def _merge_new_defaults(entries):
272
+ """把内置默认里「新增的」条目补进已加载清单(同 id 已存在则原样保留)。
273
+
274
+ data/catalog.json 一旦生成就不会再重写,因此后来版本新增的智能体(如
275
+ deepseek-harness)不会自动出现在老用户的清单里;这里按 id 做幂等补齐。
276
+ 只补缺失的 id,不动已有条目,也不回写用户文件(与 _apply_resume_patch 一致)。
277
+
278
+ 深拷贝是必须的:_apply_resume_patch 会就地改 entry["orch"],浅拷贝会让
279
+ 这些改动写回 DEFAULT_CATALOG,进而污染 reset_to_default() 的产物。
280
+ """
281
+ have = {e.get("id") for e in entries}
282
+ for d in DEFAULT_CATALOG:
283
+ if d.get("id") not in have:
284
+ entries.append(copy.deepcopy(d))
285
+
286
+
287
+ def load(force=False):
288
+ with _LOCK:
289
+ if _CACHE["entries"] is not None and not force:
290
+ return _CACHE["entries"]
291
+ paths.ensure_dirs()
292
+ if not paths.CATALOG_FILE.exists():
293
+ paths.CATALOG_FILE.write_text(
294
+ json.dumps(DEFAULT_CATALOG, ensure_ascii=False, indent=2), encoding="utf-8")
295
+ try:
296
+ entries = json.loads(paths.CATALOG_FILE.read_text(encoding="utf-8"))
297
+ except Exception:
298
+ entries = copy.deepcopy(DEFAULT_CATALOG)
299
+ _merge_new_defaults(entries)
300
+ _apply_resume_patch(entries)
301
+ _apply_launch_patch(entries)
302
+ _apply_config_patch(entries)
303
+ _CACHE["entries"] = entries
304
+ return entries
305
+
306
+
307
+ def by_id(entry_id):
308
+ for e in load():
309
+ if e.get("id") == entry_id:
310
+ return e
311
+ return None
312
+
313
+
314
+ def reset_to_default():
315
+ """把 catalog.json 恢复为内置默认(用户改坏时的逃生门)。"""
316
+ with _LOCK:
317
+ paths.CATALOG_FILE.write_text(
318
+ json.dumps(DEFAULT_CATALOG, ensure_ascii=False, indent=2), encoding="utf-8")
319
+ _CACHE["entries"] = None
@@ -0,0 +1,186 @@
1
+ # -*- coding: utf-8 -*-
2
+ """三段式上下文压缩:工具结果剪枝 → LLM 摘要 → surface replace。
3
+
4
+ 设计稿:docs/migration/02-context-compaction.md §1B。
5
+ 参考 dsh:
6
+ packages/compaction/compaction-basic/src/region.ts:117-155(selectCompactableRange:
7
+ 跳过 system 头、保留尾预算、不切断 tool call/result 配对)
8
+ packages/compaction/compaction-tool-result-pruner/src/index.ts:83-122(头尾剪枝)
9
+ packages/compaction/compaction-basic/src/region.ts:173-275(压缩事务四件事件:
10
+ compaction/start → summary → user/message(replace) → end;失败仍写 end 保证日志可见)
11
+
12
+ 触发:maybe_compact() 检查 token_meter 压力比 ≥ 阈值才动手。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+
18
+ from .token_meter import token_meter, DEFAULT_PRESSURE_THRESHOLD
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ # 工具结果剪枝参数(字符数;dsh 默认 8192/4096,单行 marker)
23
+ PRUNE_HEAD = 8192
24
+ PRUNE_TAIL = 4096
25
+ PRUNE_MARKER = "\n…[中间内容已剪枝,原文见会话日志]…\n"
26
+
27
+ # 尾部保留预算(估算 token)
28
+ RETAIN_TAIL_TOKENS = 8000
29
+ # 单次压缩区域上限(事件数,防一次吞太多)
30
+ MAX_REGION_EVENTS = 50
31
+
32
+
33
+ def estimate_tokens(text: str) -> int:
34
+ """极简估算:4 字符/token(中文偏低估,偏保守触发)。"""
35
+ return max(1, len(text or "") // 4)
36
+
37
+
38
+ def _ev_text(ev) -> str:
39
+ return str(ev.data.get("content") or ev.data.get("stdout") or "")
40
+
41
+
42
+ def prune_text(text: str, *, head=PRUNE_HEAD, tail=PRUNE_TAIL, marker=PRUNE_MARKER) -> str:
43
+ """超长文本头尾剪枝(纯无 LLM 调用)。"""
44
+ if text is None:
45
+ return ""
46
+ if len(text) <= head + tail + len(marker):
47
+ return text
48
+ return text[:head] + marker + text[-tail:]
49
+
50
+
51
+ def select_range(session, *, retain_tail_tokens=RETAIN_TAIL_TOKENS,
52
+ max_region_events=MAX_REGION_EVENTS):
53
+ """选可压缩区域 [start_seq, end_seq](闭区间)。
54
+
55
+ 规则(仿 dsh selectCompactableRange):
56
+ 1. 跳过 system 头节点(从第一条 user_message 起);
57
+ 2. 尾部保留 retain_tail_tokens 预算不参与压缩(retain<=0 表示尾部不保留);
58
+ 3. 全部内容都在尾预算内 → 无可压缩区域,返回 (0, 0);
59
+ 4. 区域末端不能落在 tool 配对中间(回退到配对完成处);
60
+ 5. 区域不超过 max_region_events 个事件。
61
+ 返回 (start_seq, end_seq);无可压缩区域返回 (0, 0)。
62
+ """
63
+ events = session.events()
64
+ surface_events = [e for e in events if e.type in
65
+ ("system_message", "user_message", "assistant_message")]
66
+ if not surface_events:
67
+ return 0, 0
68
+ first_user_seq = next((e.seq for e in surface_events
69
+ if e.type == "user_message"), 0)
70
+ if not first_user_seq:
71
+ return 0, 0
72
+ # 尾部保留:倒推预算。tail_start_seq = 保留尾段的第一个 seq。
73
+ last_seq = surface_events[-1].seq
74
+ if retain_tail_tokens <= 0:
75
+ tail_start_seq = last_seq + 1 # 尾部不保留:全部可压
76
+ else:
77
+ tail_budget = 0
78
+ tail_start_seq = 0 # 0 表示"全部都在预算内"(无区域)
79
+ for ev in reversed(surface_events):
80
+ tail_budget += estimate_tokens(_ev_text(ev))
81
+ if tail_budget >= retain_tail_tokens:
82
+ tail_start_seq = ev.seq
83
+ break
84
+ if not tail_start_seq:
85
+ return 0, 0 # 预算从未被突破 → 无可压缩区域
86
+ # 候选区域 = [first_user_seq, tail_start_seq)
87
+ all_events = session.events()
88
+ by_seq = {e.seq: e for e in all_events}
89
+ end_seq = tail_start_seq - 1
90
+ # 不切断 tool 配对:末端若是 tool_call/tool_result,回退到最近的非 tool 事件
91
+ while end_seq >= first_user_seq:
92
+ ev = by_seq.get(end_seq)
93
+ if ev is None or ev.type not in ("tool_call", "tool_result"):
94
+ break
95
+ end_seq -= 1
96
+ # 区域事件数上限
97
+ region_events = [e for e in all_events
98
+ if first_user_seq <= e.seq <= end_seq]
99
+ if len(region_events) > max_region_events:
100
+ end_seq = region_events[max_region_events - 1].seq
101
+ # 再做一次配对回退
102
+ while end_seq >= first_user_seq:
103
+ ev = by_seq.get(end_seq)
104
+ if ev is None or ev.type not in ("tool_call", "tool_result"):
105
+ break
106
+ end_seq -= 1
107
+ if end_seq < first_user_seq:
108
+ return 0, 0
109
+ return first_user_seq, end_seq
110
+
111
+
112
+ _SUMMARY_SYSTEM = (
113
+ "你是会话压缩器。把以下对话历史压缩成简洁摘要,必须保留:\n"
114
+ "1. 关键决策与结论;2. 涉及的文件路径与命令;3. 未完成事项;\n"
115
+ "4. 错误信息原文。直接输出摘要正文,不要客套。"
116
+ )
117
+
118
+
119
+ def compact_region(session, start_seq: int, end_seq: int, llm_caller,
120
+ *, reason: str = "pressure"):
121
+ """执行一次压缩事务(四件事件,失败仍写 compaction_end 保证日志可见)。
122
+
123
+ llm_caller(messages: list[dict]) -> str:一次 LLM 调用,返回摘要文本。
124
+ """
125
+ tx = session.append("compaction_start",
126
+ {"start_seq": start_seq, "end_seq": end_seq, "reason": reason},
127
+ surface_op="shadow")
128
+ summary = ""
129
+ error = ""
130
+ try:
131
+ region_events = [e for e in session.events()
132
+ if start_seq <= e.seq <= end_seq]
133
+ # 第一段:工具结果先剪枝(省摘要调用的输入 token)
134
+ parts = []
135
+ for ev in region_events:
136
+ text = _ev_text(ev)
137
+ if ev.type == "tool_result":
138
+ text = prune_text(text)
139
+ if text.strip():
140
+ parts.append(f"[{ev.type}#{ev.seq}] {text}")
141
+ region_text = "\n\n".join(parts)
142
+ raw_tokens = estimate_tokens(region_text)
143
+ summary = llm_caller([
144
+ {"role": "system", "content": _SUMMARY_SYSTEM},
145
+ {"role": "user", "content": region_text},
146
+ ])
147
+ session.append("compaction_summary",
148
+ {"raw_tokens": raw_tokens,
149
+ "summary_tokens": estimate_tokens(summary),
150
+ "start_seq": start_seq, "end_seq": end_seq},
151
+ surface_op="shadow")
152
+ # surface replace:把区域折叠为一条 user 摘要消息
153
+ session.append("user_message", {
154
+ "content": f"[系统压缩摘要 {start_seq}-{end_seq},原因 {reason}]\n{summary}",
155
+ }, surface_op={"op": "replace", "start_seq": start_seq, "end_seq": end_seq})
156
+ except Exception as e:
157
+ error = repr(e)
158
+ log.warning("compact_region failed: %s", error)
159
+ session.append("compaction_summary", {"error": error}, surface_op="shadow")
160
+ finally:
161
+ session.append("compaction_end",
162
+ {"tx_seq": tx.seq, "error": error},
163
+ surface_op="shadow")
164
+ return not error
165
+
166
+
167
+ def maybe_compact(session, *, model: str = "", llm_caller=None,
168
+ threshold: float = DEFAULT_PRESSURE_THRESHOLD,
169
+ run_id: str = None,
170
+ retain_tail_tokens: int = RETAIN_TAIL_TOKENS) -> bool:
171
+ """压力比 ≥ 阈值时触发一次压缩。返回是否执行了压缩。
172
+
173
+ llm_caller 缺省直接跳过(没有摘要能力就不压)。
174
+ """
175
+ if llm_caller is None:
176
+ return False
177
+ rid = run_id or session.run_id
178
+ ratio = token_meter.pressure_ratio(rid, model=model)
179
+ if ratio < threshold:
180
+ return False
181
+ start, end = select_range(session, retain_tail_tokens=retain_tail_tokens)
182
+ if not start:
183
+ return False
184
+ log.info("compaction triggered run=%s ratio=%.2f region=[%d,%d]",
185
+ rid, ratio, start, end)
186
+ return compact_region(session, start, end, llm_caller, reason=f"pressure={ratio:.2f}")
@@ -0,0 +1,115 @@
1
+ # -*- coding: utf-8 -*-
2
+ """运行时不变量注册表:质量闸门从 pytest 测试期提升到运行时。
3
+
4
+ 设计稿:docs/migration/01-defense-patterns.md §5F。
5
+ 参考 dsh packages/runtime-diagnostics/invariants/src/index.ts:
6
+ enabled/package_allowlist/package_blocklist 三过滤;失败抛 InvariantError 含 packageName。
7
+
8
+ CodeBee 实施简化为单文件 + pytest 钩子(设计稿说明);register(name, fn) 注册断言,
9
+ run_for(source, ctx) 在指定 source 下跑所有断言并返回失败列表。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import threading
15
+ from typing import Optional
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ class InvariantError(Exception):
21
+ def __init__(self, source: str, check_name: str, msg: str):
22
+ super().__init__(f"[{source}/{check_name}] {msg}")
23
+ self.source = source
24
+ self.check_name = check_name
25
+
26
+
27
+ class InvariantRegistry:
28
+ """按 source 分组的运行时断言注册表。
29
+
30
+ source 是来源标识(如 "pipeline"/"store"),name 是断言名;
31
+ fn(ctx) -> Optional[str] 返回 None 表示通过,返回字符串即失败消息。
32
+ """
33
+
34
+ def __init__(self):
35
+ self._lock = threading.Lock()
36
+ self._checks: dict = {} # source -> {name: fn}
37
+
38
+ def register(self, source: str, name: str, fn):
39
+ with self._lock:
40
+ self._checks.setdefault(source, {})[name] = fn
41
+ log.debug("invariant registered: %s/%s", source, name)
42
+
43
+ def unregister(self, source: str, name: str = None):
44
+ """移除断言;name=None 时清空整个 source。"""
45
+ with self._lock:
46
+ if source not in self._checks:
47
+ return
48
+ if name is None:
49
+ del self._checks[source]
50
+ else:
51
+ self._checks[source].pop(name, None)
52
+
53
+ def run_for(self, source: str, ctx: dict, *, fail_fast: bool = False):
54
+ """在 source 下跑所有断言。
55
+
56
+ Returns:
57
+ [(source, name, message), ...] 失败列表(空 = 全过)。
58
+
59
+ Raises:
60
+ InvariantError: fail_fast=True 且首个失败。
61
+ """
62
+ fails = []
63
+ with self._lock:
64
+ checks = dict(self._checks.get(source, {}))
65
+ for name, fn in checks.items():
66
+ try:
67
+ msg = fn(ctx)
68
+ except Exception as e:
69
+ msg = f"check raised: {e!r}"
70
+ if msg:
71
+ fails.append((source, name, msg))
72
+ log.warning("invariant FAIL: %s/%s: %s", source, name, msg)
73
+ if fail_fast:
74
+ raise InvariantError(source, name, msg)
75
+ return fails
76
+
77
+ def list(self, source: Optional[str] = None):
78
+ """列出已注册的 source/name;source=None 时全部。"""
79
+ with self._lock:
80
+ if source is None:
81
+ return [(s, n) for s, m in self._checks.items() for n in m]
82
+ return [(source, n) for n in self._checks.get(source, {})]
83
+
84
+
85
+ invariants = InvariantRegistry() # 单例
86
+
87
+
88
+ # === 默认 invariants ===
89
+ def register_default_checks():
90
+ """注册 CodeBee 默认运行时断言。"""
91
+
92
+ def review_no_all_fail_zero(ctx):
93
+ """评审全部 0 分但 run 标 success → 不允许(已锁 test_quality_gates:62-75)。"""
94
+ if (ctx.get("review_scores")
95
+ and all(s == 0 for s in ctx["review_scores"])
96
+ and ctx.get("final_status") == "success"):
97
+ return "评审全部 0 分但 run 标 success"
98
+ return None
99
+
100
+ def step_count_consistency(ctx):
101
+ """实际 step 数与预期不一致。"""
102
+ if ctx.get("actual_steps") != ctx.get("expected_steps"):
103
+ return f"step count {ctx.get('actual_steps')} != expected {ctx.get('expected_steps')}"
104
+ return None
105
+
106
+ def ok_but_error_code(ctx):
107
+ """step 级:ok=True 却带非空 error_code —— 解析降级(如 codex 空输出
108
+ 回落 stdout 尾部)被当成功,值得告警复核。"""
109
+ if ctx.get("ok") and ctx.get("error_code"):
110
+ return "step 标记成功但携带 error_code=%s(疑似降级输出)" % ctx["error_code"]
111
+ return None
112
+
113
+ invariants.register("pipeline", "review_no_all_fail_zero", review_no_all_fail_zero)
114
+ invariants.register("pipeline", "step_count_consistency", step_count_consistency)
115
+ invariants.register("step", "ok_but_error_code", ok_but_error_code)