codebee 0.1.101 → 0.1.103

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 (134) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -21
  3. package/README.md +3 -6
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -100
  12. package/app/core/acp_client.py +457 -426
  13. package/app/core/aiflavor.py +197 -197
  14. package/app/core/analytics.py +38 -38
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -30
  19. package/app/core/backend_profiles.py +188 -188
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +102 -102
  23. package/app/core/board.py +21 -17
  24. package/app/core/bookmeta.py +1036 -1036
  25. package/app/core/bookmeta_catalog.py +180 -180
  26. package/app/core/branching.py +137 -137
  27. package/app/core/builtin_agent.py +13 -4
  28. package/app/core/capability.py +144 -144
  29. package/app/core/catalog.py +28 -6
  30. package/app/core/chaptersafe.py +32 -32
  31. package/app/core/checkpoints.py +117 -31
  32. package/app/core/cleanup.py +303 -303
  33. package/app/core/command_auth.py +302 -302
  34. package/app/core/compaction.py +201 -201
  35. package/app/core/contracts.py +255 -255
  36. package/app/core/covergen.py +356 -356
  37. package/app/core/defectretro.py +43 -43
  38. package/app/core/diagnostics.py +114 -114
  39. package/app/core/dispatch.py +1 -1
  40. package/app/core/dispatch_log.py +128 -128
  41. package/app/core/env_scrub.py +85 -85
  42. package/app/core/errorlog.py +137 -137
  43. package/app/core/eval_matrix.py +91 -82
  44. package/app/core/evaluation.py +174 -174
  45. package/app/core/failure_notify.py +290 -290
  46. package/app/core/flow_graph.py +33 -33
  47. package/app/core/goal_service.py +167 -167
  48. package/app/core/health.py +389 -389
  49. package/app/core/history.py +38 -38
  50. package/app/core/hooks.py +162 -162
  51. package/app/core/knowledge_pipeline.py +86 -86
  52. package/app/core/manager.py +2385 -2385
  53. package/app/core/mcp_client.py +7 -2
  54. package/app/core/mocks.py +64 -64
  55. package/app/core/notify.py +201 -201
  56. package/app/core/novel_quality.py +181 -181
  57. package/app/core/openapi.py +217 -209
  58. package/app/core/operations.py +228 -228
  59. package/app/core/paihang.py +145 -145
  60. package/app/core/pipeline.py +6 -3
  61. package/app/core/policy.py +9 -0
  62. package/app/core/portguard.py +111 -111
  63. package/app/core/presence.py +77 -77
  64. package/app/core/preview.py +293 -293
  65. package/app/core/project_memory.py +206 -206
  66. package/app/core/prompt_guard.py +78 -78
  67. package/app/core/publish/__init__.py +7 -7
  68. package/app/core/publish/auto.py +659 -562
  69. package/app/core/publish/chaprows.py +138 -138
  70. package/app/core/publish/fanqie.py +16 -10
  71. package/app/core/publish/flow.py +29 -1
  72. package/app/core/publish/flows-fanqie-calibrated.json +442 -442
  73. package/app/core/publish/manager.py +1185 -1177
  74. package/app/core/publish/qimao.py +193 -193
  75. package/app/core/publish/volumes.py +108 -108
  76. package/app/core/publish/ws.py +139 -139
  77. package/app/core/quality_gate.py +201 -201
  78. package/app/core/redact.py +38 -38
  79. package/app/core/repeat_guard.py +129 -129
  80. package/app/core/retrieval.py +137 -137
  81. package/app/core/sandbox_runtime.py +95 -0
  82. package/app/core/selfupdate.py +384 -384
  83. package/app/core/session_log.py +161 -161
  84. package/app/core/sessions.py +312 -312
  85. package/app/core/settings.py +215 -215
  86. package/app/core/settings_schema.py +282 -282
  87. package/app/core/share_page.py +114 -114
  88. package/app/core/skill_scan.py +139 -139
  89. package/app/core/skillpacks/market/character-bible.md +66 -66
  90. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  91. package/app/core/skillpacks/market/git-workflow.md +57 -57
  92. package/app/core/skillpacks/market/release-notes.md +72 -72
  93. package/app/core/skillpacks/market/weekly-report.md +71 -71
  94. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  95. package/app/core/step_runner.py +132 -132
  96. package/app/core/store.py +13 -3
  97. package/app/core/story_tracking.py +275 -275
  98. package/app/core/task_compile.py +199 -199
  99. package/app/core/telemetry.py +292 -292
  100. package/app/core/tlsctx.py +64 -64
  101. package/app/core/token_meter.py +147 -147
  102. package/app/core/tool_outputs.py +107 -107
  103. package/app/core/tracing.py +207 -207
  104. package/app/core/upstream.py +41 -41
  105. package/app/core/usage.py +1038 -1038
  106. package/app/core/volumes.py +389 -389
  107. package/app/core/webhooks.py +89 -89
  108. package/app/core/wxdigest.py +865 -865
  109. package/app/core/zentao.py +2692 -2692
  110. package/app/main.py +1 -0
  111. package/app/mcp_server.py +244 -244
  112. package/app/output/_createbook_body.json +1 -0
  113. package/app/output/_probe_fq_click.py +50 -0
  114. package/app/output/_probe_fq_form.py +42 -0
  115. package/app/output/_probe_fq_modal.py +55 -0
  116. package/app/output/_probe_fq_points.py +71 -0
  117. package/app/output/_probe_fq_submit.py +34 -0
  118. package/app/output/_probe_fq_watch.py +62 -0
  119. package/app/output/workbench-clean-preview.jpg +0 -0
  120. package/app/output/workbench-reference-render.png +0 -0
  121. package/app/output/workbench-shell.png +0 -0
  122. package/app/pick_dialog.py +110 -110
  123. package/app/ui/app.js +165 -29
  124. package/app/ui/artdiff.js +74 -74
  125. package/app/ui/hive3d.js +491 -223
  126. package/app/ui/i18n.js +18 -1
  127. package/app/ui/icons/bee.svg +79 -79
  128. package/app/ui/index.html +24 -4
  129. package/app/ui/manifest.json +16 -16
  130. package/app/ui/qrcode.js +2297 -2297
  131. package/app/ui/style.css +548 -18
  132. package/bin/tutti.js +147 -147
  133. package/package.json +1 -1
  134. package/tools/wx_reader.py +261 -261
@@ -1,64 +1,64 @@
1
- # -*- coding: utf-8 -*-
2
- """HTTPS 证书校验上下文:补齐 macOS 上 Python 缺失的 CA 源(不关校验)。
3
-
4
- 现象:用官方 pkg 装 Node 的 Mac 上往往再装 python.org 的 Python,它不读
5
- 系统钥匙串,默认验证路径下没有根证书,所有 HTTPS 请求报
6
- CERTIFICATE_VERIFY_FAILED(unable to get local issuer certificate)。
7
-
8
- 修法是给默认上下文**追加**可用 CA 源,校验语义只增不减:
9
- 1. certifi(装了就用,跨平台最全);
10
- 2. /etc/ssl/cert.pem(macOS 系统自带 CA 束,Catalina 起就有)。
11
- 两个都拿不到时返回默认上下文——报错与旧行为一致,绝不静默关校验。
12
- """
13
- import ssl
14
- import sys
15
-
16
-
17
- def cafiles():
18
- """候选 CA 束路径;坏路径/不存在由 load_verify_locations 抛错后被吞。"""
19
- out = []
20
- try:
21
- import certifi
22
- out.append(certifi.where())
23
- except Exception:
24
- pass
25
- if sys.platform == "darwin":
26
- out.append("/etc/ssl/cert.pem")
27
- return out
28
-
29
-
30
- def _build():
31
- ctx = ssl.create_default_context()
32
- for f in cafiles():
33
- try:
34
- ctx.load_verify_locations(cafile=f)
35
- except Exception:
36
- pass
37
- return ctx
38
-
39
-
40
- _CTX = None
41
-
42
-
43
- def context():
44
- """带兜底 CA 源的 ssl.SSLContext(进程内缓存;校验语义与默认一致)。"""
45
- global _CTX
46
- if _CTX is None:
47
- _CTX = _build()
48
- return _CTX
49
-
50
-
51
- def humanize(err_text):
52
- """证书验证失败 → 可行动的人话提示(非证书类错误原样返回)。
53
-
54
- 兜底 CA 仍验证失败只剩两类真实原因:本机代理对 HTTPS 做 TLS 拦截
55
- (其根证书只在系统钥匙串/浏览器里,Python 的静态 CA 束不认),或极端
56
- 环境连 /etc/ssl/cert.pem 都没有。SSL_CERT_FILE 是 OpenSSL 标准逃生门,
57
- create_default_context() 本就吃它,无需额外代码。"""
58
- text = str(err_text or "")
59
- if "CERTIFICATE_VERIFY_FAILED" not in text:
60
- return text
61
- return (text + " —— 证书验证仍失败:① 若开着代理/安全软件且开启了 HTTPS 拦截,"
62
- "关掉拦截,或把其根证书导出后设环境变量 SSL_CERT_FILE 指向它再启动;"
63
- "② 确认系统存在 /etc/ssl/cert.pem;③ 刚升级过 CodeBee 的话,"
64
- "旧服务进程可能还在跑——关掉终端窗口重开")
1
+ # -*- coding: utf-8 -*-
2
+ """HTTPS 证书校验上下文:补齐 macOS 上 Python 缺失的 CA 源(不关校验)。
3
+
4
+ 现象:用官方 pkg 装 Node 的 Mac 上往往再装 python.org 的 Python,它不读
5
+ 系统钥匙串,默认验证路径下没有根证书,所有 HTTPS 请求报
6
+ CERTIFICATE_VERIFY_FAILED(unable to get local issuer certificate)。
7
+
8
+ 修法是给默认上下文**追加**可用 CA 源,校验语义只增不减:
9
+ 1. certifi(装了就用,跨平台最全);
10
+ 2. /etc/ssl/cert.pem(macOS 系统自带 CA 束,Catalina 起就有)。
11
+ 两个都拿不到时返回默认上下文——报错与旧行为一致,绝不静默关校验。
12
+ """
13
+ import ssl
14
+ import sys
15
+
16
+
17
+ def cafiles():
18
+ """候选 CA 束路径;坏路径/不存在由 load_verify_locations 抛错后被吞。"""
19
+ out = []
20
+ try:
21
+ import certifi
22
+ out.append(certifi.where())
23
+ except Exception:
24
+ pass
25
+ if sys.platform == "darwin":
26
+ out.append("/etc/ssl/cert.pem")
27
+ return out
28
+
29
+
30
+ def _build():
31
+ ctx = ssl.create_default_context()
32
+ for f in cafiles():
33
+ try:
34
+ ctx.load_verify_locations(cafile=f)
35
+ except Exception:
36
+ pass
37
+ return ctx
38
+
39
+
40
+ _CTX = None
41
+
42
+
43
+ def context():
44
+ """带兜底 CA 源的 ssl.SSLContext(进程内缓存;校验语义与默认一致)。"""
45
+ global _CTX
46
+ if _CTX is None:
47
+ _CTX = _build()
48
+ return _CTX
49
+
50
+
51
+ def humanize(err_text):
52
+ """证书验证失败 → 可行动的人话提示(非证书类错误原样返回)。
53
+
54
+ 兜底 CA 仍验证失败只剩两类真实原因:本机代理对 HTTPS 做 TLS 拦截
55
+ (其根证书只在系统钥匙串/浏览器里,Python 的静态 CA 束不认),或极端
56
+ 环境连 /etc/ssl/cert.pem 都没有。SSL_CERT_FILE 是 OpenSSL 标准逃生门,
57
+ create_default_context() 本就吃它,无需额外代码。"""
58
+ text = str(err_text or "")
59
+ if "CERTIFICATE_VERIFY_FAILED" not in text:
60
+ return text
61
+ return (text + " —— 证书验证仍失败:① 若开着代理/安全软件且开启了 HTTPS 拦截,"
62
+ "关掉拦截,或把其根证书导出后设环境变量 SSL_CERT_FILE 指向它再启动;"
63
+ "② 确认系统存在 /etc/ssl/cert.pem;③ 刚升级过 CodeBee 的话,"
64
+ "旧服务进程可能还在跑——关掉终端窗口重开")
@@ -1,148 +1,148 @@
1
- # -*- coding: utf-8 -*-
2
- """Token 压力估算:按 run 累计 usage,给出「已用/容量」压力比。
3
-
4
- 设计稿:docs/migration/02-context-compaction.md §1C。
5
- 参考 dsh packages/llm/token-meter/src/index.ts:滑动窗口 + usage 锚点 + 失败重定价。
6
-
7
- Tutti 简化实现:每 run 一个累计窗口(deque 有上限),usage 来自
8
- runner._parse_codex_jsonl / _parse_claude_json 已解析的字段;
9
- 容量查 DEFAULT_CAPACITY 表(可被 data/model_capacity.json 覆盖)。
10
- """
11
- from __future__ import annotations
12
-
13
- import json
14
- import logging
15
- import threading
16
- import time
17
- from collections import deque
18
- from pathlib import Path
19
-
20
- log = logging.getLogger(__name__)
21
-
22
- # 默认模型容量(token)。未命中 key 时用 "" 兜底行。
23
- DEFAULT_CAPACITY = {
24
- "": 128_000,
25
- "gpt-5": 400_000,
26
- "gpt-5.5": 400_000,
27
- "deepseek-chat": 128_000,
28
- "deepseek-reasoner": 64_000,
29
- "qwen3-max": 256_000,
30
- "qwen3.8-flash": 256_000,
31
- }
32
-
33
- # 压缩触发阈值(设计稿 §1B 默认 0.8,可被 orchestration.json 覆盖)
34
- DEFAULT_PRESSURE_THRESHOLD = 0.8
35
-
36
- # 容量覆盖文件(热改立即生效)
37
- _CAPACITY_FILE = Path(__file__).resolve().parents[2] / "data" / "model_capacity.json"
38
-
39
-
40
- def _load_capacity():
41
- """默认表 + 可选覆盖文件合并。文件缺失/坏 JSON 静默用默认。"""
42
- cap = dict(DEFAULT_CAPACITY)
43
- try:
44
- if _CAPACITY_FILE.is_file():
45
- data = json.loads(_CAPACITY_FILE.read_text(encoding="utf-8"))
46
- if isinstance(data, dict):
47
- cap.update({str(k): int(v) for k, v in data.items()})
48
- except Exception:
49
- pass
50
- return cap
51
-
52
-
53
- class TokenMeter:
54
- """每 run 独立累计窗口;线程安全。"""
55
-
56
- def __init__(self, capacity=None, *, window_size=200):
57
- self._capacity_override = capacity
58
- self._window_size = window_size
59
- self._lock = threading.Lock()
60
- # run_id -> deque[(ts, input, output, reasoning, cached)]
61
- self._windows: dict = {}
62
- # run_id -> 最后一次见到的 model 名(pressure_ratio 未显式传 model 时用)
63
- self._last_model: dict = {}
64
- self._cap_cache = None
65
- self._cap_mtime = 0.0
66
-
67
- def _capacity(self):
68
- """容量表(带 mtime 缓存,热改 data/model_capacity.json 立即生效)。"""
69
- try:
70
- mtime = _CAPACITY_FILE.stat().st_mtime
71
- except OSError:
72
- mtime = 0.0
73
- if self._cap_cache is None or mtime != self._cap_mtime:
74
- if self._capacity_override is not None:
75
- self._cap_cache = dict(self._capacity_override)
76
- else:
77
- self._cap_cache = _load_capacity()
78
- self._cap_mtime = mtime
79
- return self._cap_cache
80
-
81
- def accumulate(self, run_id: str, usage, model: str = ""):
82
- """记录一次调用的 usage。usage 字段缺失按 0 处理;usage=None 跳过。
83
-
84
- cached 单独记录但不计入 used()——缓存读不占新一轮上下文压力
85
- (与 runner._parse_claude_json 的口径一致:cached 是省钱的部分)。
86
- """
87
- if not usage:
88
- return
89
- with self._lock:
90
- if model:
91
- self._last_model[run_id] = model
92
- window = self._windows.setdefault(run_id, deque(maxlen=self._window_size))
93
- window.append((
94
- time.time(),
95
- int(usage.get("input") or 0),
96
- int(usage.get("output") or 0),
97
- int(usage.get("reasoning") or 0),
98
- int(usage.get("cached") or 0),
99
- ))
100
-
101
- def used(self, run_id: str) -> int:
102
- """窗口内累计(input + output + reasoning),不含 cached。"""
103
- with self._lock:
104
- window = self._windows.get(run_id)
105
- if not window:
106
- return 0
107
- return sum(i + o + r for _, i, o, r, _ in window)
108
-
109
- def cached(self, run_id: str) -> int:
110
- with self._lock:
111
- window = self._windows.get(run_id)
112
- if not window:
113
- return 0
114
- return sum(c for _, _, _, _, c in window)
115
-
116
- def pressure_ratio(self, run_id: str, model: str = "") -> float:
117
- """已用 / 容量。model 未传时用该 run 最后一次见到的模型名。"""
118
- cap_table = self._capacity()
119
- with self._lock:
120
- m = model or self._last_model.get(run_id, "")
121
- cap = cap_table.get(m, cap_table.get("", 128_000))
122
- return self.used(run_id) / max(cap, 1)
123
-
124
- def capacity(self, model: str = "") -> int:
125
- """模型上下文容量(未命中用默认行)。"""
126
- cap_table = self._capacity()
127
- return cap_table.get(model or "", cap_table.get("", 128_000))
128
-
129
- def last_context(self, run_id: str) -> int:
130
- """最近一次调用的 input+output+reasoning——下个请求要重发的上下文锚点。
131
-
132
- used() 是窗口累计(多次调用叠加,系统性偏大,适合做响应式预算闸);
133
- 事前预检判断「这个请求会不会被拒」要用最近一次的真实上下文体量,
134
- 两者口径不同不可混用(借鉴 freebuff base-chat 每步容量重估)。"""
135
- with self._lock:
136
- window = self._windows.get(run_id)
137
- if not window:
138
- return 0
139
- _, i, o, r, _ = window[-1]
140
- return i + o + r
141
-
142
- def reset(self, run_id: str):
143
- with self._lock:
144
- self._windows.pop(run_id, None)
145
- self._last_model.pop(run_id, None)
146
-
147
-
1
+ # -*- coding: utf-8 -*-
2
+ """Token 压力估算:按 run 累计 usage,给出「已用/容量」压力比。
3
+
4
+ 设计稿:docs/migration/02-context-compaction.md §1C。
5
+ 参考 dsh packages/llm/token-meter/src/index.ts:滑动窗口 + usage 锚点 + 失败重定价。
6
+
7
+ Tutti 简化实现:每 run 一个累计窗口(deque 有上限),usage 来自
8
+ runner._parse_codex_jsonl / _parse_claude_json 已解析的字段;
9
+ 容量查 DEFAULT_CAPACITY 表(可被 data/model_capacity.json 覆盖)。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import threading
16
+ import time
17
+ from collections import deque
18
+ from pathlib import Path
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ # 默认模型容量(token)。未命中 key 时用 "" 兜底行。
23
+ DEFAULT_CAPACITY = {
24
+ "": 128_000,
25
+ "gpt-5": 400_000,
26
+ "gpt-5.5": 400_000,
27
+ "deepseek-chat": 128_000,
28
+ "deepseek-reasoner": 64_000,
29
+ "qwen3-max": 256_000,
30
+ "qwen3.8-flash": 256_000,
31
+ }
32
+
33
+ # 压缩触发阈值(设计稿 §1B 默认 0.8,可被 orchestration.json 覆盖)
34
+ DEFAULT_PRESSURE_THRESHOLD = 0.8
35
+
36
+ # 容量覆盖文件(热改立即生效)
37
+ _CAPACITY_FILE = Path(__file__).resolve().parents[2] / "data" / "model_capacity.json"
38
+
39
+
40
+ def _load_capacity():
41
+ """默认表 + 可选覆盖文件合并。文件缺失/坏 JSON 静默用默认。"""
42
+ cap = dict(DEFAULT_CAPACITY)
43
+ try:
44
+ if _CAPACITY_FILE.is_file():
45
+ data = json.loads(_CAPACITY_FILE.read_text(encoding="utf-8"))
46
+ if isinstance(data, dict):
47
+ cap.update({str(k): int(v) for k, v in data.items()})
48
+ except Exception:
49
+ pass
50
+ return cap
51
+
52
+
53
+ class TokenMeter:
54
+ """每 run 独立累计窗口;线程安全。"""
55
+
56
+ def __init__(self, capacity=None, *, window_size=200):
57
+ self._capacity_override = capacity
58
+ self._window_size = window_size
59
+ self._lock = threading.Lock()
60
+ # run_id -> deque[(ts, input, output, reasoning, cached)]
61
+ self._windows: dict = {}
62
+ # run_id -> 最后一次见到的 model 名(pressure_ratio 未显式传 model 时用)
63
+ self._last_model: dict = {}
64
+ self._cap_cache = None
65
+ self._cap_mtime = 0.0
66
+
67
+ def _capacity(self):
68
+ """容量表(带 mtime 缓存,热改 data/model_capacity.json 立即生效)。"""
69
+ try:
70
+ mtime = _CAPACITY_FILE.stat().st_mtime
71
+ except OSError:
72
+ mtime = 0.0
73
+ if self._cap_cache is None or mtime != self._cap_mtime:
74
+ if self._capacity_override is not None:
75
+ self._cap_cache = dict(self._capacity_override)
76
+ else:
77
+ self._cap_cache = _load_capacity()
78
+ self._cap_mtime = mtime
79
+ return self._cap_cache
80
+
81
+ def accumulate(self, run_id: str, usage, model: str = ""):
82
+ """记录一次调用的 usage。usage 字段缺失按 0 处理;usage=None 跳过。
83
+
84
+ cached 单独记录但不计入 used()——缓存读不占新一轮上下文压力
85
+ (与 runner._parse_claude_json 的口径一致:cached 是省钱的部分)。
86
+ """
87
+ if not usage:
88
+ return
89
+ with self._lock:
90
+ if model:
91
+ self._last_model[run_id] = model
92
+ window = self._windows.setdefault(run_id, deque(maxlen=self._window_size))
93
+ window.append((
94
+ time.time(),
95
+ int(usage.get("input") or 0),
96
+ int(usage.get("output") or 0),
97
+ int(usage.get("reasoning") or 0),
98
+ int(usage.get("cached") or 0),
99
+ ))
100
+
101
+ def used(self, run_id: str) -> int:
102
+ """窗口内累计(input + output + reasoning),不含 cached。"""
103
+ with self._lock:
104
+ window = self._windows.get(run_id)
105
+ if not window:
106
+ return 0
107
+ return sum(i + o + r for _, i, o, r, _ in window)
108
+
109
+ def cached(self, run_id: str) -> int:
110
+ with self._lock:
111
+ window = self._windows.get(run_id)
112
+ if not window:
113
+ return 0
114
+ return sum(c for _, _, _, _, c in window)
115
+
116
+ def pressure_ratio(self, run_id: str, model: str = "") -> float:
117
+ """已用 / 容量。model 未传时用该 run 最后一次见到的模型名。"""
118
+ cap_table = self._capacity()
119
+ with self._lock:
120
+ m = model or self._last_model.get(run_id, "")
121
+ cap = cap_table.get(m, cap_table.get("", 128_000))
122
+ return self.used(run_id) / max(cap, 1)
123
+
124
+ def capacity(self, model: str = "") -> int:
125
+ """模型上下文容量(未命中用默认行)。"""
126
+ cap_table = self._capacity()
127
+ return cap_table.get(model or "", cap_table.get("", 128_000))
128
+
129
+ def last_context(self, run_id: str) -> int:
130
+ """最近一次调用的 input+output+reasoning——下个请求要重发的上下文锚点。
131
+
132
+ used() 是窗口累计(多次调用叠加,系统性偏大,适合做响应式预算闸);
133
+ 事前预检判断「这个请求会不会被拒」要用最近一次的真实上下文体量,
134
+ 两者口径不同不可混用(借鉴 freebuff base-chat 每步容量重估)。"""
135
+ with self._lock:
136
+ window = self._windows.get(run_id)
137
+ if not window:
138
+ return 0
139
+ _, i, o, r, _ = window[-1]
140
+ return i + o + r
141
+
142
+ def reset(self, run_id: str):
143
+ with self._lock:
144
+ self._windows.pop(run_id, None)
145
+ self._last_model.pop(run_id, None)
146
+
147
+
148
148
  token_meter = TokenMeter() # 单例
@@ -1,107 +1,107 @@
1
- # -*- coding: utf-8 -*-
2
- """Bounded, run-scoped storage for large tool results omitted from model context."""
3
- from __future__ import annotations
4
-
5
- import hashlib
6
- import os
7
- import re
8
- import stat
9
- from pathlib import Path
10
-
11
- from . import paths
12
-
13
- _DIR = paths.DATA_DIR / "tool_outputs"
14
- _REF_RE = re.compile(r"^[a-f0-9]{32}$")
15
- MAX_RESULT_BYTES = 4 * 1024 * 1024
16
- MAX_RUN_BYTES = 32 * 1024 * 1024
17
- MAX_READ_CHARS = 20000
18
-
19
-
20
- def _run_dir(run_id):
21
- return _DIR / hashlib.sha256(str(run_id or "").encode("utf-8")).hexdigest()
22
-
23
-
24
- def save(run_id, call_id, content):
25
- """Store one output and return an opaque reference, or empty string on limits/errors."""
26
- if not str(run_id or "").strip():
27
- return ""
28
- raw = str(content or "").encode("utf-8", errors="replace")
29
- if len(raw) > MAX_RESULT_BYTES:
30
- return ""
31
- run_dir = _run_dir(run_id)
32
- ref = hashlib.sha256((str(call_id or "") + "\0").encode("utf-8") + raw).hexdigest()[:32]
33
- try:
34
- _DIR.mkdir(parents=True, exist_ok=True)
35
- run_dir.mkdir(mode=0o700, exist_ok=True)
36
- if run_dir.is_symlink() or (hasattr(run_dir, "is_junction") and run_dir.is_junction()):
37
- return ""
38
- target = run_dir / (ref + ".txt")
39
- if target.exists():
40
- return ref if target.is_file() and not target.is_symlink() else ""
41
- total = sum(p.stat().st_size for p in run_dir.glob("*.txt") if p.is_file())
42
- if total + len(raw) > MAX_RUN_BYTES:
43
- return ""
44
- flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
45
- flags |= getattr(os, "O_NOFOLLOW", 0)
46
- fd = os.open(str(target), flags, 0o600)
47
- try:
48
- if not stat.S_ISREG(os.fstat(fd).st_mode):
49
- return ""
50
- with os.fdopen(fd, "wb", closefd=False) as handle:
51
- handle.write(raw)
52
- handle.flush()
53
- finally:
54
- os.close(fd)
55
- return ref
56
- except (OSError, ValueError):
57
- return ""
58
-
59
-
60
- def read(run_id, ref, *, offset=0, limit=MAX_READ_CHARS):
61
- """Read a bounded slice, scoped to a single run and an opaque content reference."""
62
- if not str(run_id or "").strip() or not _REF_RE.fullmatch(str(ref or "")):
63
- return {"ok": False, "error": "工具结果引用无效"}
64
- run_dir = _run_dir(run_id)
65
- if run_dir.is_symlink() or (hasattr(run_dir, "is_junction") and run_dir.is_junction()):
66
- return {"ok": False, "error": "工具结果存储路径不安全"}
67
- target = run_dir / (str(ref) + ".txt")
68
- if target.is_symlink() or (hasattr(target, "is_junction") and target.is_junction()):
69
- return {"ok": False, "error": "工具结果存储路径不安全"}
70
- try:
71
- offset = max(0, int(offset or 0))
72
- limit = max(1, min(MAX_READ_CHARS, int(limit or MAX_READ_CHARS)))
73
- with target.open("rb") as handle:
74
- handle.seek(offset)
75
- raw = handle.read(limit * 4 + 4)
76
- text = raw.decode("utf-8", errors="replace")[:limit]
77
- end = handle.tell()
78
- handle.seek(0, os.SEEK_END)
79
- size = handle.tell()
80
- return {"ok": True, "text": text, "offset": offset,
81
- "next_offset": end if end < size else None, "size_bytes": size}
82
- except (OSError, ValueError, TypeError):
83
- return {"ok": False, "error": "找不到此运行中的工具结果引用"}
84
-
85
-
86
- def matches_compacted_output(run_id, call_id, ref, head, tail):
87
- """Verify a compacted preview belongs to this call and stored full output."""
88
- if not str(run_id or "").strip() or not _REF_RE.fullmatch(str(ref or "")):
89
- return False
90
- run_dir = _run_dir(run_id)
91
- if run_dir.is_symlink() or (hasattr(run_dir, "is_junction") and run_dir.is_junction()):
92
- return False
93
- target = run_dir / (str(ref) + ".txt")
94
- if target.is_symlink() or (hasattr(target, "is_junction") and target.is_junction()):
95
- return False
96
- try:
97
- if target.stat().st_size > MAX_RESULT_BYTES:
98
- return False
99
- raw = target.read_bytes()
100
- expected_ref = hashlib.sha256(
101
- (str(call_id or "") + "\0").encode("utf-8") + raw).hexdigest()[:32]
102
- if expected_ref != ref:
103
- return False
104
- return (raw.startswith(str(head or "").encode("utf-8", errors="replace"))
105
- and raw.endswith(str(tail or "").encode("utf-8", errors="replace")))
106
- except (OSError, ValueError):
107
- return False
1
+ # -*- coding: utf-8 -*-
2
+ """Bounded, run-scoped storage for large tool results omitted from model context."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import re
8
+ import stat
9
+ from pathlib import Path
10
+
11
+ from . import paths
12
+
13
+ _DIR = paths.DATA_DIR / "tool_outputs"
14
+ _REF_RE = re.compile(r"^[a-f0-9]{32}$")
15
+ MAX_RESULT_BYTES = 4 * 1024 * 1024
16
+ MAX_RUN_BYTES = 32 * 1024 * 1024
17
+ MAX_READ_CHARS = 20000
18
+
19
+
20
+ def _run_dir(run_id):
21
+ return _DIR / hashlib.sha256(str(run_id or "").encode("utf-8")).hexdigest()
22
+
23
+
24
+ def save(run_id, call_id, content):
25
+ """Store one output and return an opaque reference, or empty string on limits/errors."""
26
+ if not str(run_id or "").strip():
27
+ return ""
28
+ raw = str(content or "").encode("utf-8", errors="replace")
29
+ if len(raw) > MAX_RESULT_BYTES:
30
+ return ""
31
+ run_dir = _run_dir(run_id)
32
+ ref = hashlib.sha256((str(call_id or "") + "\0").encode("utf-8") + raw).hexdigest()[:32]
33
+ try:
34
+ _DIR.mkdir(parents=True, exist_ok=True)
35
+ run_dir.mkdir(mode=0o700, exist_ok=True)
36
+ if run_dir.is_symlink() or (hasattr(run_dir, "is_junction") and run_dir.is_junction()):
37
+ return ""
38
+ target = run_dir / (ref + ".txt")
39
+ if target.exists():
40
+ return ref if target.is_file() and not target.is_symlink() else ""
41
+ total = sum(p.stat().st_size for p in run_dir.glob("*.txt") if p.is_file())
42
+ if total + len(raw) > MAX_RUN_BYTES:
43
+ return ""
44
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
45
+ flags |= getattr(os, "O_NOFOLLOW", 0)
46
+ fd = os.open(str(target), flags, 0o600)
47
+ try:
48
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
49
+ return ""
50
+ with os.fdopen(fd, "wb", closefd=False) as handle:
51
+ handle.write(raw)
52
+ handle.flush()
53
+ finally:
54
+ os.close(fd)
55
+ return ref
56
+ except (OSError, ValueError):
57
+ return ""
58
+
59
+
60
+ def read(run_id, ref, *, offset=0, limit=MAX_READ_CHARS):
61
+ """Read a bounded slice, scoped to a single run and an opaque content reference."""
62
+ if not str(run_id or "").strip() or not _REF_RE.fullmatch(str(ref or "")):
63
+ return {"ok": False, "error": "工具结果引用无效"}
64
+ run_dir = _run_dir(run_id)
65
+ if run_dir.is_symlink() or (hasattr(run_dir, "is_junction") and run_dir.is_junction()):
66
+ return {"ok": False, "error": "工具结果存储路径不安全"}
67
+ target = run_dir / (str(ref) + ".txt")
68
+ if target.is_symlink() or (hasattr(target, "is_junction") and target.is_junction()):
69
+ return {"ok": False, "error": "工具结果存储路径不安全"}
70
+ try:
71
+ offset = max(0, int(offset or 0))
72
+ limit = max(1, min(MAX_READ_CHARS, int(limit or MAX_READ_CHARS)))
73
+ with target.open("rb") as handle:
74
+ handle.seek(offset)
75
+ raw = handle.read(limit * 4 + 4)
76
+ text = raw.decode("utf-8", errors="replace")[:limit]
77
+ end = handle.tell()
78
+ handle.seek(0, os.SEEK_END)
79
+ size = handle.tell()
80
+ return {"ok": True, "text": text, "offset": offset,
81
+ "next_offset": end if end < size else None, "size_bytes": size}
82
+ except (OSError, ValueError, TypeError):
83
+ return {"ok": False, "error": "找不到此运行中的工具结果引用"}
84
+
85
+
86
+ def matches_compacted_output(run_id, call_id, ref, head, tail):
87
+ """Verify a compacted preview belongs to this call and stored full output."""
88
+ if not str(run_id or "").strip() or not _REF_RE.fullmatch(str(ref or "")):
89
+ return False
90
+ run_dir = _run_dir(run_id)
91
+ if run_dir.is_symlink() or (hasattr(run_dir, "is_junction") and run_dir.is_junction()):
92
+ return False
93
+ target = run_dir / (str(ref) + ".txt")
94
+ if target.is_symlink() or (hasattr(target, "is_junction") and target.is_junction()):
95
+ return False
96
+ try:
97
+ if target.stat().st_size > MAX_RESULT_BYTES:
98
+ return False
99
+ raw = target.read_bytes()
100
+ expected_ref = hashlib.sha256(
101
+ (str(call_id or "") + "\0").encode("utf-8") + raw).hexdigest()[:32]
102
+ if expected_ref != ref:
103
+ return False
104
+ return (raw.startswith(str(head or "").encode("utf-8", errors="replace"))
105
+ and raw.endswith(str(tail or "").encode("utf-8", errors="replace")))
106
+ except (OSError, ValueError):
107
+ return False