mosscode 0.1.0__py3-none-any.whl
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.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/config.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Endpoint configuration is independent of the service's vendor name."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from .context import ContextBudget
|
|
14
|
+
from .prompts import DEFAULT_SYSTEM_PROMPT, stable_prompt
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(ValueError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ALIASES = {
|
|
22
|
+
"anthropic": "anthropic-messages",
|
|
23
|
+
"openai": "openai-responses",
|
|
24
|
+
"openai-compat": "openai-chat-completions",
|
|
25
|
+
}
|
|
26
|
+
PROTOCOLS = set(ALIASES.values())
|
|
27
|
+
TEAMMATE_MODES = ("auto", "in-process", "windows-terminal", "tmux")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _validate_env_name(value: str | None) -> None:
|
|
31
|
+
if value is not None and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", value) is None:
|
|
32
|
+
raise ConfigError(
|
|
33
|
+
"api_key_env 请填写环境变量名称(例如 DEEPSEEK_API_KEY),不能填写密钥;直接填写密钥请使用 api_key。"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ProviderConfig:
|
|
39
|
+
name: str
|
|
40
|
+
protocol: str
|
|
41
|
+
model: str
|
|
42
|
+
base_url: str | None = None
|
|
43
|
+
api_key_env: str | None = field(default=None, repr=False)
|
|
44
|
+
api_key: str | None = field(default=None, repr=False)
|
|
45
|
+
auth_type: str = "api_key"
|
|
46
|
+
headers: dict[str, str] = field(default_factory=dict, repr=False)
|
|
47
|
+
thinking: dict[str, Any] | None = None
|
|
48
|
+
max_output_tokens: int = 16384
|
|
49
|
+
timeout: float = 60.0
|
|
50
|
+
max_retries: int = 4
|
|
51
|
+
stream_usage: bool = False
|
|
52
|
+
max_tokens_field: str = "max_tokens"
|
|
53
|
+
include_encrypted_reasoning: bool = True
|
|
54
|
+
prompt_cache: bool | None = None
|
|
55
|
+
context_window: int | None = None
|
|
56
|
+
compact_threshold: int | None = None
|
|
57
|
+
|
|
58
|
+
def budget(self) -> ContextBudget:
|
|
59
|
+
return ContextBudget.for_provider(self.context_window, self.max_output_tokens, self.compact_threshold)
|
|
60
|
+
|
|
61
|
+
def credential(self) -> str:
|
|
62
|
+
_validate_env_name(self.api_key_env)
|
|
63
|
+
value = os.environ.get(self.api_key_env, "") if self.api_key_env else self.api_key
|
|
64
|
+
if not value:
|
|
65
|
+
hint = (
|
|
66
|
+
f"请设置环境变量 {self.api_key_env}(api_key_env 只填写变量名;直接填写密钥请使用 api_key)"
|
|
67
|
+
if self.api_key_env
|
|
68
|
+
else "请配置 api_key_env(环境变量名)或 api_key(密钥)"
|
|
69
|
+
)
|
|
70
|
+
raise ConfigError(f"{self.name}:{hint}。")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Settings:
|
|
76
|
+
providers: tuple[ProviderConfig, ...]
|
|
77
|
+
default_provider: str | None = None
|
|
78
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT
|
|
79
|
+
max_iterations: int = 200
|
|
80
|
+
source: Path | None = None
|
|
81
|
+
shell: str = "auto"
|
|
82
|
+
auto_compact: bool = True
|
|
83
|
+
persist_sessions: bool = True
|
|
84
|
+
memory: bool = True
|
|
85
|
+
auto_memory: bool = True
|
|
86
|
+
memory_provider: str | None = None # Background extraction; None uses the current provider.
|
|
87
|
+
agent_auto_background: float = 120.0 # Seconds before a foreground sub-agent moves on; 0 never.
|
|
88
|
+
agent_max_background: int = 4 # Running background tasks at most.
|
|
89
|
+
worktree_links: tuple[str, ...] = () # Paths relative to the repository linked into each worktree.
|
|
90
|
+
worktree_stale_hours: float = 24.0 # Unused sub-agent worktrees older than this go; 0 never.
|
|
91
|
+
team_max_members: int = 4 # Members of one team at most (1–8).
|
|
92
|
+
teammate_mode: str = "auto" # auto / in-process / windows-terminal / tmux.
|
|
93
|
+
coordinator_mode: bool = False # Also needs MOSSCODE_COORDINATOR_MODE.
|
|
94
|
+
|
|
95
|
+
def select(self, name: str | None = None) -> ProviderConfig:
|
|
96
|
+
selected = name or self.default_provider or self.providers[0].name
|
|
97
|
+
for provider in self.providers:
|
|
98
|
+
if provider.name == selected:
|
|
99
|
+
return provider
|
|
100
|
+
raise ConfigError(f"未知 Provider:{selected}。")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _positive_int(value: Any, label: str) -> int:
|
|
104
|
+
if type(value) is not int or value <= 0:
|
|
105
|
+
raise ConfigError(f"{label} 必须是正整数。")
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _links(raw: Any) -> tuple[str, ...]:
|
|
110
|
+
"""Relative paths inside the repository."""
|
|
111
|
+
problem = "worktree_links 需为相对仓库根的路径列表(不能是绝对路径,不能含 ..)。"
|
|
112
|
+
if not isinstance(raw, list):
|
|
113
|
+
raise ConfigError(problem)
|
|
114
|
+
links = []
|
|
115
|
+
for item in raw:
|
|
116
|
+
if not isinstance(item, str) or not item.strip():
|
|
117
|
+
raise ConfigError(problem)
|
|
118
|
+
text = item.strip().replace("\\", "/").strip("/")
|
|
119
|
+
parts = text.split("/")
|
|
120
|
+
if (
|
|
121
|
+
not text
|
|
122
|
+
or Path(item.strip()).is_absolute()
|
|
123
|
+
or item.strip().startswith(("/", "\\"))
|
|
124
|
+
or ":" in text
|
|
125
|
+
or ".." in parts
|
|
126
|
+
or "." in parts
|
|
127
|
+
or parts[0] == ".git"
|
|
128
|
+
or parts[:2] == [".mosscode", "worktrees"]
|
|
129
|
+
):
|
|
130
|
+
raise ConfigError(problem)
|
|
131
|
+
if text not in links:
|
|
132
|
+
links.append(text)
|
|
133
|
+
return tuple(links)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _provider(raw: Any) -> ProviderConfig:
|
|
137
|
+
if not isinstance(raw, dict):
|
|
138
|
+
raise ConfigError("providers 的每一项必须是配置对象。")
|
|
139
|
+
allowed = set(ProviderConfig.__dataclass_fields__)
|
|
140
|
+
if set(raw) - allowed:
|
|
141
|
+
raise ConfigError("Provider 包含未知字段,请对照 config.example.yaml。")
|
|
142
|
+
for key in ("name", "protocol", "model"):
|
|
143
|
+
if not isinstance(raw.get(key), str) or not raw[key].strip():
|
|
144
|
+
raise ConfigError(f"Provider 的 {key} 必须是非空字符串。")
|
|
145
|
+
data = dict(raw)
|
|
146
|
+
data["protocol"] = ALIASES.get(data["protocol"], data["protocol"])
|
|
147
|
+
if data["protocol"] not in PROTOCOLS:
|
|
148
|
+
raise ConfigError("protocol 必须是 anthropic-messages、openai-responses 或 openai-chat-completions。")
|
|
149
|
+
url = data.get("base_url")
|
|
150
|
+
if url is not None:
|
|
151
|
+
if not isinstance(url, str):
|
|
152
|
+
raise ConfigError("base_url 必须是 HTTP(S) URL。")
|
|
153
|
+
parsed = urlsplit(url)
|
|
154
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password:
|
|
155
|
+
raise ConfigError("base_url 必须是没有嵌入凭据的 HTTP(S) URL。")
|
|
156
|
+
for key in ("api_key", "api_key_env"):
|
|
157
|
+
if key in data and (not isinstance(data[key], str) or not data[key].strip()):
|
|
158
|
+
raise ConfigError(f"{key} 必须是非空字符串。")
|
|
159
|
+
if data.get("api_key") and data.get("api_key_env"):
|
|
160
|
+
raise ConfigError("api_key 和 api_key_env 只选择一种。")
|
|
161
|
+
_validate_env_name(data.get("api_key_env"))
|
|
162
|
+
headers = data.get("headers", {})
|
|
163
|
+
if not isinstance(headers, dict) or any(
|
|
164
|
+
not isinstance(k, str) or not isinstance(v, str) for k, v in headers.items()
|
|
165
|
+
):
|
|
166
|
+
raise ConfigError("headers 必须是字符串键值对象。")
|
|
167
|
+
data["max_output_tokens"] = _positive_int(
|
|
168
|
+
data.get("max_output_tokens", ProviderConfig.max_output_tokens), "max_output_tokens"
|
|
169
|
+
)
|
|
170
|
+
retries = data.get("max_retries", ProviderConfig.max_retries)
|
|
171
|
+
if type(retries) is not int or retries < 0:
|
|
172
|
+
raise ConfigError("max_retries 必须是非负整数。")
|
|
173
|
+
timeout = data.get("timeout", 60.0)
|
|
174
|
+
if type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout <= 0:
|
|
175
|
+
raise ConfigError("timeout 必须是有限的正数秒数。")
|
|
176
|
+
if data.get("auth_type", "api_key") not in {"api_key", "bearer"}:
|
|
177
|
+
raise ConfigError("auth_type 必须是 api_key 或 bearer。")
|
|
178
|
+
if type(data.get("stream_usage", False)) is not bool:
|
|
179
|
+
raise ConfigError("stream_usage 必须是布尔值。")
|
|
180
|
+
if type(data.get("include_encrypted_reasoning", True)) is not bool:
|
|
181
|
+
raise ConfigError("include_encrypted_reasoning 必须是布尔值。")
|
|
182
|
+
if data.get("prompt_cache") is not None and type(data["prompt_cache"]) is not bool:
|
|
183
|
+
raise ConfigError("prompt_cache 必须是布尔值或 null。")
|
|
184
|
+
if data.get("max_tokens_field", "max_tokens") not in {"max_tokens", "max_completion_tokens"}:
|
|
185
|
+
raise ConfigError("max_tokens_field 必须是 max_tokens 或 max_completion_tokens。")
|
|
186
|
+
thinking = data.get("thinking")
|
|
187
|
+
if thinking is True:
|
|
188
|
+
thinking = {"type": "adaptive"}
|
|
189
|
+
elif thinking is False:
|
|
190
|
+
thinking = {"type": "disabled"}
|
|
191
|
+
if thinking is not None:
|
|
192
|
+
if data["protocol"] != "anthropic-messages":
|
|
193
|
+
raise ConfigError("本章 thinking 配置用于 Anthropic Messages。")
|
|
194
|
+
if not isinstance(thinking, dict) or thinking.get("type") not in {"adaptive", "enabled", "disabled"}:
|
|
195
|
+
raise ConfigError("thinking.type 必须是 adaptive、enabled 或 disabled。")
|
|
196
|
+
if set(thinking) - {"type", "budget_tokens", "effort"}:
|
|
197
|
+
raise ConfigError("thinking 包含未知字段。")
|
|
198
|
+
if thinking["type"] == "enabled":
|
|
199
|
+
budget = _positive_int(thinking.get("budget_tokens"), "thinking.budget_tokens")
|
|
200
|
+
if not 1024 <= budget < data["max_output_tokens"]:
|
|
201
|
+
raise ConfigError("手动思考预算需至少 1024 且小于 max_output_tokens。")
|
|
202
|
+
elif "budget_tokens" in thinking:
|
|
203
|
+
raise ConfigError("budget_tokens 仅用于手动 enabled 模式。")
|
|
204
|
+
if "effort" in thinking and (thinking["type"] != "adaptive" or not isinstance(thinking["effort"], str)):
|
|
205
|
+
raise ConfigError("effort 需用于 adaptive 模式并为字符串。")
|
|
206
|
+
data["thinking"] = thinking
|
|
207
|
+
for key in ("context_window", "compact_threshold"):
|
|
208
|
+
if data.get(key) is not None:
|
|
209
|
+
data[key] = _positive_int(data[key], key)
|
|
210
|
+
config = ProviderConfig(**data)
|
|
211
|
+
problem = config.budget().problem()
|
|
212
|
+
if problem:
|
|
213
|
+
raise ConfigError(f"Provider {config.name}:{problem}")
|
|
214
|
+
return config
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def config_candidates(cwd: Path | None = None, home: Path | None = None) -> tuple[Path, ...]:
|
|
218
|
+
"""A config.yaml in the working directory is a project's own file, not ours."""
|
|
219
|
+
cwd = Path.cwd() if cwd is None else cwd
|
|
220
|
+
home = Path.home() if home is None else home
|
|
221
|
+
project, user = cwd / ".mosscode" / "config.yaml", home / ".mosscode" / "config.yaml"
|
|
222
|
+
same = os.path.normcase(os.path.abspath(project)) == os.path.normcase(os.path.abspath(user))
|
|
223
|
+
return (user,) if same else (project, user) # Started in the home folder, both are one file.
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def find_config(cwd: Path | None = None, home: Path | None = None) -> Path:
|
|
227
|
+
"""Project file, then the user file; the first one wins."""
|
|
228
|
+
candidates = config_candidates(cwd, home)
|
|
229
|
+
for path in candidates:
|
|
230
|
+
if path.is_file():
|
|
231
|
+
return path
|
|
232
|
+
raise ConfigError(
|
|
233
|
+
"找不到配置文件,已查找:"
|
|
234
|
+
+ "、".join(str(p) for p in candidates)
|
|
235
|
+
+ "。运行 moss setup 创建配置,或用 --config 指定。"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def render_config(provider: dict[str, Any]) -> str:
|
|
240
|
+
"""The file `moss setup` writes, with one provider made the default."""
|
|
241
|
+
body = yaml.safe_dump(
|
|
242
|
+
{"default_provider": provider["name"], "providers": [provider]}, sort_keys=False, allow_unicode=True
|
|
243
|
+
)
|
|
244
|
+
return (
|
|
245
|
+
"# MossCode 配置,由 moss setup 生成。\n"
|
|
246
|
+
"# 字段说明见 config.example.yaml;追加 Provider:在 providers 下再写一项。\n" + body
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def load_settings(path: str | Path) -> Settings:
|
|
251
|
+
try:
|
|
252
|
+
raw = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
253
|
+
except OSError:
|
|
254
|
+
raise ConfigError("无法读取配置文件,请检查 --config 路径。") from None
|
|
255
|
+
except (yaml.YAMLError, UnicodeError):
|
|
256
|
+
raise ConfigError("配置文件需为有效的 UTF-8 YAML。") from None
|
|
257
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("providers"), list) or not raw["providers"]:
|
|
258
|
+
raise ConfigError("配置需包含非空 providers 列表。")
|
|
259
|
+
if set(raw) - {
|
|
260
|
+
"providers",
|
|
261
|
+
"default_provider",
|
|
262
|
+
"system_prompt",
|
|
263
|
+
"max_iterations",
|
|
264
|
+
"shell",
|
|
265
|
+
"auto_compact",
|
|
266
|
+
"persist_sessions",
|
|
267
|
+
"memory",
|
|
268
|
+
"auto_memory",
|
|
269
|
+
"memory_provider",
|
|
270
|
+
"agent_auto_background",
|
|
271
|
+
"agent_max_background",
|
|
272
|
+
"worktree_links",
|
|
273
|
+
"worktree_stale_hours",
|
|
274
|
+
"team_max_members",
|
|
275
|
+
"teammate_mode",
|
|
276
|
+
"coordinator_mode",
|
|
277
|
+
}:
|
|
278
|
+
raise ConfigError("配置顶层包含未知字段。")
|
|
279
|
+
providers = tuple(_provider(item) for item in raw["providers"])
|
|
280
|
+
if len({p.name for p in providers}) != len(providers):
|
|
281
|
+
raise ConfigError("Provider name 不能重复。")
|
|
282
|
+
default = raw.get("default_provider")
|
|
283
|
+
system = raw.get("system_prompt", Settings.__dataclass_fields__["system_prompt"].default)
|
|
284
|
+
if default is not None and (not isinstance(default, str) or not default.strip()):
|
|
285
|
+
raise ConfigError("default_provider 需为非空配置名称;不设置时多配置启动会显示选择列表。")
|
|
286
|
+
if not isinstance(system, str):
|
|
287
|
+
raise ConfigError("system_prompt 必须是字符串。")
|
|
288
|
+
iterations = _positive_int(raw.get("max_iterations", Settings.max_iterations), "max_iterations")
|
|
289
|
+
shell = raw.get("shell", Settings.shell)
|
|
290
|
+
if not isinstance(shell, str) or not shell.strip():
|
|
291
|
+
raise ConfigError("shell 需为 auto、bash、powershell 或 shell 可执行文件路径。")
|
|
292
|
+
switches = {}
|
|
293
|
+
for key in ("auto_compact", "persist_sessions", "memory", "auto_memory"):
|
|
294
|
+
switches[key] = raw.get(key, True)
|
|
295
|
+
if type(switches[key]) is not bool:
|
|
296
|
+
raise ConfigError(f"{key} 必须是布尔值。")
|
|
297
|
+
memory_provider = raw.get("memory_provider")
|
|
298
|
+
if memory_provider is not None and memory_provider not in {p.name for p in providers}:
|
|
299
|
+
raise ConfigError("memory_provider 必须是已配置的 Provider 名称。")
|
|
300
|
+
auto_background = raw.get("agent_auto_background", Settings.agent_auto_background)
|
|
301
|
+
if type(auto_background) not in {int, float} or not 0 <= auto_background <= 3600:
|
|
302
|
+
raise ConfigError("agent_auto_background 需为 0–3600 之间的秒数(0 表示不自动转入后台)。")
|
|
303
|
+
max_background = _positive_int(
|
|
304
|
+
raw.get("agent_max_background", Settings.agent_max_background), "agent_max_background"
|
|
305
|
+
)
|
|
306
|
+
links = _links(raw.get("worktree_links", []))
|
|
307
|
+
stale = raw.get("worktree_stale_hours", Settings.worktree_stale_hours)
|
|
308
|
+
if type(stale) not in {int, float} or not 0 <= stale <= 8760:
|
|
309
|
+
raise ConfigError("worktree_stale_hours 需为 0–8760 之间的小时数(0 表示不自动清理)。")
|
|
310
|
+
members = raw.get("team_max_members", Settings.team_max_members)
|
|
311
|
+
if type(members) is not int or not 1 <= members <= 8:
|
|
312
|
+
raise ConfigError("team_max_members 需为 1–8 之间的整数。")
|
|
313
|
+
teammate_mode = raw.get("teammate_mode", Settings.teammate_mode)
|
|
314
|
+
if teammate_mode not in TEAMMATE_MODES:
|
|
315
|
+
raise ConfigError("teammate_mode 需为 " + "、".join(TEAMMATE_MODES) + " 之一。")
|
|
316
|
+
coordinator = raw.get("coordinator_mode", Settings.coordinator_mode)
|
|
317
|
+
if type(coordinator) is not bool:
|
|
318
|
+
raise ConfigError("coordinator_mode 必须是布尔值。")
|
|
319
|
+
settings = Settings(
|
|
320
|
+
providers,
|
|
321
|
+
default,
|
|
322
|
+
stable_prompt(system),
|
|
323
|
+
iterations,
|
|
324
|
+
Path(path),
|
|
325
|
+
shell.strip(),
|
|
326
|
+
memory_provider=memory_provider,
|
|
327
|
+
agent_auto_background=float(auto_background),
|
|
328
|
+
agent_max_background=max_background,
|
|
329
|
+
worktree_links=links,
|
|
330
|
+
worktree_stale_hours=float(stale),
|
|
331
|
+
team_max_members=members,
|
|
332
|
+
teammate_mode=teammate_mode,
|
|
333
|
+
coordinator_mode=coordinator,
|
|
334
|
+
**switches,
|
|
335
|
+
)
|
|
336
|
+
settings.select()
|
|
337
|
+
return settings
|
mosscode/context.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Approximate context accounting: no tokenizer, anchored on the last reported usage."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .messages import Message, Usage
|
|
8
|
+
|
|
9
|
+
DEFAULT_CONTEXT_WINDOW = 128_000
|
|
10
|
+
SUMMARY_RESERVE = 20_000 # Room for a structured summary; normal replies use max_output_tokens.
|
|
11
|
+
AUTO_MARGIN = 13_000 # Absorbs one round of growth plus estimation error between checks.
|
|
12
|
+
MANUAL_MARGIN = 3_000
|
|
13
|
+
MIN_THRESHOLD = 16_000
|
|
14
|
+
MESSAGE_OVERHEAD = 4
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def estimate_text(text: str) -> int:
|
|
18
|
+
"""ASCII at 4 characters per token, every other character (CJK etc.) as one token: errs high."""
|
|
19
|
+
if not text:
|
|
20
|
+
return 0
|
|
21
|
+
ascii_count = len(text.encode("ascii", "ignore"))
|
|
22
|
+
return math.ceil(ascii_count / 4) + len(text) - ascii_count
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def estimate_message(message: Message) -> int:
|
|
26
|
+
total = MESSAGE_OVERHEAD + sum(estimate_text(block.text) for block in message.content)
|
|
27
|
+
total += sum(estimate_text(call.name) + estimate_text(call.arguments) for call in message.tool_calls)
|
|
28
|
+
return total + sum(estimate_text(result.model_text()) for result in message.tool_results)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def estimate_messages(messages: Sequence[Message]) -> int:
|
|
32
|
+
return sum(estimate_message(m) for m in messages)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ContextBudget:
|
|
37
|
+
"""Thresholds for one provider. The window comes from configuration, never from a model table."""
|
|
38
|
+
|
|
39
|
+
window: int
|
|
40
|
+
max_output_tokens: int
|
|
41
|
+
compact_threshold: int | None = None
|
|
42
|
+
configured: bool = True
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def for_provider(
|
|
46
|
+
cls, context_window: int | None, max_output_tokens: int, compact_threshold: int | None = None
|
|
47
|
+
) -> "ContextBudget":
|
|
48
|
+
return cls(
|
|
49
|
+
context_window or DEFAULT_CONTEXT_WINDOW, max_output_tokens, compact_threshold, context_window is not None
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def effective(self) -> int:
|
|
54
|
+
# Services usually require input + max output to fit the window.
|
|
55
|
+
return self.window - max(self.max_output_tokens, SUMMARY_RESERVE)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def window_threshold(self) -> int:
|
|
59
|
+
return self.effective - AUTO_MARGIN
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def auto_threshold(self) -> int:
|
|
63
|
+
limit = self.window_threshold
|
|
64
|
+
return min(limit, self.compact_threshold) if self.compact_threshold else limit
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def manual_limit(self) -> int:
|
|
68
|
+
return self.effective - MANUAL_MARGIN
|
|
69
|
+
|
|
70
|
+
def problem(self) -> str | None:
|
|
71
|
+
if self.window_threshold < MIN_THRESHOLD:
|
|
72
|
+
return (
|
|
73
|
+
f"context_window({self.window})减去输出预留和安全余量后不足 {MIN_THRESHOLD},"
|
|
74
|
+
"请调大 context_window 或调小 max_output_tokens。"
|
|
75
|
+
)
|
|
76
|
+
if self.compact_threshold is not None and not MIN_THRESHOLD <= self.compact_threshold <= self.window_threshold:
|
|
77
|
+
return f"compact_threshold 需在 {MIN_THRESHOLD} 与 {self.window_threshold} 之间。"
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ContextMeter:
|
|
82
|
+
"""Size of the next request: last reported input + output, plus an estimate of what came after."""
|
|
83
|
+
|
|
84
|
+
def __init__(self):
|
|
85
|
+
self.anchor: int | None = None
|
|
86
|
+
self.anchor_length = 0
|
|
87
|
+
|
|
88
|
+
def record(self, usage: Usage, history_length: int) -> None:
|
|
89
|
+
if usage.input_tokens is None:
|
|
90
|
+
self.anchor = None
|
|
91
|
+
return
|
|
92
|
+
self.anchor = usage.input_tokens + (usage.output_tokens or 0)
|
|
93
|
+
self.anchor_length = history_length
|
|
94
|
+
|
|
95
|
+
def invalidate(self) -> None:
|
|
96
|
+
self.anchor = None
|
|
97
|
+
|
|
98
|
+
def estimate(self, history: Sequence[Message], extra: str = "", fixed: Callable[[], str] = lambda: "") -> int:
|
|
99
|
+
if self.anchor is not None and self.anchor_length <= len(history):
|
|
100
|
+
return self.anchor + estimate_messages(history[self.anchor_length :]) + estimate_text(extra)
|
|
101
|
+
# No usable anchor: count the whole request, including system prompt and tool definitions.
|
|
102
|
+
return estimate_text(fixed()) + estimate_messages(history) + estimate_text(extra)
|