codebee 0.1.95 → 0.1.97

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 (144) hide show
  1. package/CHANGELOG.md +733 -720
  2. package/LICENSE +21 -21
  3. package/README.md +516 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +118 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +246 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +2055 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +648 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mcp_client.py +200 -29
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +181 -189
  49. package/app/core/openapi.py +177 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +319 -59
  53. package/app/core/planner.py +17 -11
  54. package/app/core/policy.py +84 -0
  55. package/app/core/portguard.py +111 -111
  56. package/app/core/presence.py +77 -77
  57. package/app/core/preview.py +293 -293
  58. package/app/core/project_memory.py +139 -0
  59. package/app/core/prompt_guard.py +78 -78
  60. package/app/core/publish/__init__.py +7 -7
  61. package/app/core/publish/auto.py +106 -22
  62. package/app/core/publish/browser.py +694 -655
  63. package/app/core/publish/chaprows.py +138 -0
  64. package/app/core/publish/fanqie.py +212 -165
  65. package/app/core/publish/flow.py +832 -640
  66. package/app/core/publish/flows-fanqie-calibrated.json +361 -336
  67. package/app/core/publish/ledger.py +51 -2
  68. package/app/core/publish/manager.py +1150 -1118
  69. package/app/core/publish/qimao.py +176 -176
  70. package/app/core/publish/volumes.py +108 -0
  71. package/app/core/publish/ws.py +139 -139
  72. package/app/core/quality_gate.py +194 -194
  73. package/app/core/redact.py +38 -38
  74. package/app/core/repeat_guard.py +129 -129
  75. package/app/core/retrieval.py +137 -137
  76. package/app/core/router.py +398 -398
  77. package/app/core/runner.py +2239 -2238
  78. package/app/core/selfupdate.py +384 -384
  79. package/app/core/session_log.py +161 -161
  80. package/app/core/sessions.py +312 -312
  81. package/app/core/settings.py +33 -5
  82. package/app/core/settings_schema.py +282 -271
  83. package/app/core/share_page.py +114 -114
  84. package/app/core/skill_scan.py +139 -139
  85. package/app/core/skillpacks/fanqie-novel.md +8 -6
  86. package/app/core/skillpacks/market/character-bible.md +66 -66
  87. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  88. package/app/core/skillpacks/market/git-workflow.md +57 -57
  89. package/app/core/skillpacks/market/release-notes.md +72 -72
  90. package/app/core/skillpacks/market/weekly-report.md +71 -71
  91. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  92. package/app/core/skillpacks/qimao-signing.md +14 -11
  93. package/app/core/skills.py +32 -21
  94. package/app/core/step_runner.py +132 -132
  95. package/app/core/store.py +24 -0
  96. package/app/core/story_tracking.py +275 -275
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tool_outputs.py +107 -0
  102. package/app/core/tracing.py +207 -144
  103. package/app/core/upstream.py +41 -41
  104. package/app/core/usage.py +1038 -1038
  105. package/app/core/volumes.py +389 -389
  106. package/app/core/webhooks.py +89 -0
  107. package/app/core/wxdigest.py +865 -858
  108. package/app/core/zentao.py +2692 -2692
  109. package/app/main.py +179 -10
  110. package/app/mcp_server.py +244 -244
  111. package/app/pick_dialog.py +110 -110
  112. package/app/ui/app.js +563 -21
  113. package/app/ui/artdiff.js +74 -74
  114. package/app/ui/assets/community-qr.png +0 -0
  115. package/app/ui/assets/donate-alipay.jpg +0 -0
  116. package/app/ui/assets/donate-qr.jpg +0 -0
  117. package/app/ui/assets/workbench-reference.png +0 -0
  118. package/app/ui/board.css +451 -666
  119. package/app/ui/board.html +165 -162
  120. package/app/ui/board.js +509 -305
  121. package/app/ui/hive3d.js +242 -0
  122. package/app/ui/i18n.js +125 -9
  123. package/app/ui/icons/bee.svg +79 -79
  124. package/app/ui/index.html +60 -3
  125. package/app/ui/manifest.json +16 -16
  126. package/app/ui/qrcode.js +2297 -2297
  127. package/app/ui/style.css +189 -9
  128. package/bin/tutti.js +147 -147
  129. package/package.json +49 -48
  130. package/tools/wx_reader.py +261 -261
  131. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  132. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  133. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  134. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  135. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  136. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  137. package/app/.playwright-cli/run-detail-after.png +0 -0
  138. package/app/output/_createbook_body.json +0 -1
  139. package/app/output/_probe_fq_click.py +0 -50
  140. package/app/output/_probe_fq_form.py +0 -42
  141. package/app/output/_probe_fq_modal.py +0 -55
  142. package/app/output/_probe_fq_points.py +0 -71
  143. package/app/output/_probe_fq_submit.py +0 -34
  144. package/app/output/_probe_fq_watch.py +0 -62
@@ -1,201 +1,201 @@
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
- summary_tokens = estimate_tokens(summary)
148
- session.append("compaction_summary",
149
- {"raw_tokens": raw_tokens,
150
- "summary_tokens": summary_tokens,
151
- "start_seq": start_seq, "end_seq": end_seq},
152
- surface_op="shadow")
153
- # 台账入账(A 专项:省了多少不再是黑箱)——摘要调用本身的真实消耗
154
- # (读 region、写 summary)此前完全隐形;saved=净省量与消耗并列成账。
155
- # record 自吞一切异常,统计绝不拖垮压缩事务。
156
- try:
157
- from . import usage as _usage
158
- _usage.record(source="compaction",
159
- run_id=str(getattr(session, "run_id", "") or ""),
160
- tool="compaction",
161
- usage={"input": raw_tokens,
162
- "output": summary_tokens,
163
- "saved": max(0, raw_tokens - summary_tokens)},
164
- ok=True)
165
- except Exception:
166
- pass
167
- # surface replace:把区域折叠为一条 user 摘要消息
168
- session.append("user_message", {
169
- "content": f"[系统压缩摘要 {start_seq}-{end_seq},原因 {reason}]\n{summary}",
170
- }, surface_op={"op": "replace", "start_seq": start_seq, "end_seq": end_seq})
171
- except Exception as e:
172
- error = repr(e)
173
- log.warning("compact_region failed: %s", error)
174
- session.append("compaction_summary", {"error": error}, surface_op="shadow")
175
- finally:
176
- session.append("compaction_end",
177
- {"tx_seq": tx.seq, "error": error},
178
- surface_op="shadow")
179
- return not error
180
-
181
-
182
- def maybe_compact(session, *, model: str = "", llm_caller=None,
183
- threshold: float = DEFAULT_PRESSURE_THRESHOLD,
184
- run_id: str = None,
185
- retain_tail_tokens: int = RETAIN_TAIL_TOKENS) -> bool:
186
- """压力比 ≥ 阈值时触发一次压缩。返回是否执行了压缩。
187
-
188
- llm_caller 缺省直接跳过(没有摘要能力就不压)。
189
- """
190
- if llm_caller is None:
191
- return False
192
- rid = run_id or session.run_id
193
- ratio = token_meter.pressure_ratio(rid, model=model)
194
- if ratio < threshold:
195
- return False
196
- start, end = select_range(session, retain_tail_tokens=retain_tail_tokens)
197
- if not start:
198
- return False
199
- log.info("compaction triggered run=%s ratio=%.2f region=[%d,%d]",
200
- rid, ratio, start, end)
201
- return compact_region(session, start, end, llm_caller, reason=f"pressure={ratio:.2f}")
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
+ summary_tokens = estimate_tokens(summary)
148
+ session.append("compaction_summary",
149
+ {"raw_tokens": raw_tokens,
150
+ "summary_tokens": summary_tokens,
151
+ "start_seq": start_seq, "end_seq": end_seq},
152
+ surface_op="shadow")
153
+ # 台账入账(A 专项:省了多少不再是黑箱)——摘要调用本身的真实消耗
154
+ # (读 region、写 summary)此前完全隐形;saved=净省量与消耗并列成账。
155
+ # record 自吞一切异常,统计绝不拖垮压缩事务。
156
+ try:
157
+ from . import usage as _usage
158
+ _usage.record(source="compaction",
159
+ run_id=str(getattr(session, "run_id", "") or ""),
160
+ tool="compaction",
161
+ usage={"input": raw_tokens,
162
+ "output": summary_tokens,
163
+ "saved": max(0, raw_tokens - summary_tokens)},
164
+ ok=True)
165
+ except Exception:
166
+ pass
167
+ # surface replace:把区域折叠为一条 user 摘要消息
168
+ session.append("user_message", {
169
+ "content": f"[系统压缩摘要 {start_seq}-{end_seq},原因 {reason}]\n{summary}",
170
+ }, surface_op={"op": "replace", "start_seq": start_seq, "end_seq": end_seq})
171
+ except Exception as e:
172
+ error = repr(e)
173
+ log.warning("compact_region failed: %s", error)
174
+ session.append("compaction_summary", {"error": error}, surface_op="shadow")
175
+ finally:
176
+ session.append("compaction_end",
177
+ {"tx_seq": tx.seq, "error": error},
178
+ surface_op="shadow")
179
+ return not error
180
+
181
+
182
+ def maybe_compact(session, *, model: str = "", llm_caller=None,
183
+ threshold: float = DEFAULT_PRESSURE_THRESHOLD,
184
+ run_id: str = None,
185
+ retain_tail_tokens: int = RETAIN_TAIL_TOKENS) -> bool:
186
+ """压力比 ≥ 阈值时触发一次压缩。返回是否执行了压缩。
187
+
188
+ llm_caller 缺省直接跳过(没有摘要能力就不压)。
189
+ """
190
+ if llm_caller is None:
191
+ return False
192
+ rid = run_id or session.run_id
193
+ ratio = token_meter.pressure_ratio(rid, model=model)
194
+ if ratio < threshold:
195
+ return False
196
+ start, end = select_range(session, retain_tail_tokens=retain_tail_tokens)
197
+ if not start:
198
+ return False
199
+ log.info("compaction triggered run=%s ratio=%.2f region=[%d,%d]",
200
+ rid, ratio, start, end)
201
+ return compact_region(session, start, end, llm_caller, reason=f"pressure={ratio:.2f}")