xg-cli 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.
- xg/__init__.py +3 -0
- xg/__main__.py +3 -0
- xg/adaptive/__init__.py +27 -0
- xg/adaptive/calibrate.py +191 -0
- xg/adaptive/feedback.py +170 -0
- xg/adaptive/learned_rules.py +284 -0
- xg/adaptive/signals.py +125 -0
- xg/adaptive/store.py +164 -0
- xg/agent/__init__.py +0 -0
- xg/agent/plan.py +631 -0
- xg/agent/react.py +268 -0
- xg/agent/team.py +1793 -0
- xg/assets/router.lgb +0 -0
- xg/assets/router_semantics.json +21278 -0
- xg/assets/router_semantics.onnx +0 -0
- xg/cli/__init__.py +0 -0
- xg/cli/app.py +1372 -0
- xg/cli/commands.py +921 -0
- xg/cli/completion.py +740 -0
- xg/cli/help.py +185 -0
- xg/cli/train.py +200 -0
- xg/config/__init__.py +0 -0
- xg/config/env_writer.py +121 -0
- xg/config/manager.py +446 -0
- xg/config/mcp.py +229 -0
- xg/config/provider_service.py +267 -0
- xg/config/providers.py +46 -0
- xg/config/settings.py +258 -0
- xg/config/skills.py +100 -0
- xg/config/smart_router_service.py +121 -0
- xg/config/web.py +140 -0
- xg/input_history/__init__.py +7 -0
- xg/input_history/models.py +28 -0
- xg/input_history/persistence.py +126 -0
- xg/input_history/policy.py +39 -0
- xg/input_history/prompt_toolkit.py +36 -0
- xg/input_history/store.py +118 -0
- xg/llm/__init__.py +0 -0
- xg/llm/client.py +49 -0
- xg/llm/factory.py +40 -0
- xg/llm/openai_compat.py +275 -0
- xg/llm/types.py +98 -0
- xg/mcp/__init__.py +4 -0
- xg/mcp/http.py +192 -0
- xg/mcp/manager.py +726 -0
- xg/mcp/models.py +86 -0
- xg/mcp/protocol.py +62 -0
- xg/mcp/resources.py +72 -0
- xg/mcp/schema.py +137 -0
- xg/mcp/stdio.py +210 -0
- xg/mcp/transport.py +66 -0
- xg/memory/__init__.py +15 -0
- xg/memory/context.py +327 -0
- xg/memory/manager.py +111 -0
- xg/memory/models.py +41 -0
- xg/memory/project.py +187 -0
- xg/memory/store.py +144 -0
- xg/router/__init__.py +124 -0
- xg/router/features.py +66 -0
- xg/router/keywords.py +50 -0
- xg/router/ml_router.py +178 -0
- xg/router/model_tiers.py +73 -0
- xg/router/postprocess.py +167 -0
- xg/router/rule_router.py +77 -0
- xg/router/semantic.py +138 -0
- xg/safety/__init__.py +0 -0
- xg/safety/audit.py +96 -0
- xg/safety/guards.py +106 -0
- xg/safety/hitl.py +73 -0
- xg/skill/__init__.py +9 -0
- xg/skill/errors.py +45 -0
- xg/skill/loader.py +42 -0
- xg/skill/models.py +57 -0
- xg/skill/parser.py +93 -0
- xg/skill/policy.py +40 -0
- xg/skill/prompt.py +45 -0
- xg/skill/registry.py +169 -0
- xg/tool/__init__.py +0 -0
- xg/tool/builtin.py +356 -0
- xg/tool/registry.py +228 -0
- xg/tui/__init__.py +34 -0
- xg/tui/app.py +612 -0
- xg/tui/controller.py +1296 -0
- xg/tui/diagrams/__init__.py +22 -0
- xg/tui/diagrams/layout.py +110 -0
- xg/tui/diagrams/markdown.py +39 -0
- xg/tui/diagrams/model.py +36 -0
- xg/tui/diagrams/parser.py +119 -0
- xg/tui/diagrams/renderer.py +551 -0
- xg/tui/i18n.py +169 -0
- xg/tui/messages.py +45 -0
- xg/tui/plan_renderables.py +147 -0
- xg/tui/reducer.py +1029 -0
- xg/tui/renderables.py +252 -0
- xg/tui/state.py +240 -0
- xg/tui/theme.tcss +208 -0
- xg/tui/widgets/__init__.py +1 -0
- xg/tui/widgets/action_card.py +94 -0
- xg/tui/widgets/agent_group_card.py +39 -0
- xg/tui/widgets/approval_modal.py +59 -0
- xg/tui/widgets/collapsible_card.py +40 -0
- xg/tui/widgets/command_suggestions.py +128 -0
- xg/tui/widgets/composer.py +151 -0
- xg/tui/widgets/config_panel.py +198 -0
- xg/tui/widgets/confirm_modal.py +31 -0
- xg/tui/widgets/footer.py +9 -0
- xg/tui/widgets/header.py +118 -0
- xg/tui/widgets/inspector.py +378 -0
- xg/tui/widgets/plan_modal.py +53 -0
- xg/tui/widgets/provider_form.py +141 -0
- xg/tui/widgets/queue_status.py +30 -0
- xg/tui/widgets/smart_router_form.py +95 -0
- xg/tui/widgets/transcript.py +354 -0
- xg/tui/workers.py +14 -0
- xg/web/__init__.py +21 -0
- xg/web/errors.py +57 -0
- xg/web/extract.py +127 -0
- xg/web/fetch.py +106 -0
- xg/web/markdown.py +77 -0
- xg/web/models.py +91 -0
- xg/web/providers.py +79 -0
- xg/web/search.py +118 -0
- xg/web/searxng.py +27 -0
- xg/web/serpapi.py +29 -0
- xg/web/url_policy.py +110 -0
- xg/web/zhipu.py +27 -0
- xg_cli-1.0.dist-info/METADATA +284 -0
- xg_cli-1.0.dist-info/RECORD +130 -0
- xg_cli-1.0.dist-info/WHEEL +4 -0
- xg_cli-1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Provider 配置服务:inline 斜杠命令与 TUI 面板共享的 CRUD 入口。
|
|
2
|
+
|
|
3
|
+
设计口径:
|
|
4
|
+
- config.json 是 provider 定义与 API Key 的唯一存储,不再写 .env。
|
|
5
|
+
- 写 config.json 复用 :class:`xg.config.manager.ConfigManager` 的分层读写。
|
|
6
|
+
- 所有写入在保存前经过校验(api_base 合法、default_model 必填、name 合法、
|
|
7
|
+
key 非占位值),并把错误前移到输入阶段。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from xg.config.manager import ConfigManager, _is_placeholder, mask_key
|
|
17
|
+
|
|
18
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
|
|
19
|
+
_ALLOWED_FIELDS = ("api_base", "default_model", "display_name")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class OpResult:
|
|
24
|
+
ok: bool
|
|
25
|
+
message: str
|
|
26
|
+
data: object | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_api_base(url: str) -> str | None:
|
|
30
|
+
"""校验 api_base;非法时返回错误提示,合法返回 None(对齐反引号坑 J6)。"""
|
|
31
|
+
if not url or url != url.strip():
|
|
32
|
+
return "api_base 不能为空或包含首尾空白"
|
|
33
|
+
if any(ch in url for ch in ("`", " ")):
|
|
34
|
+
return "api_base 含非法字符(如反引号或空格),请去掉后重试"
|
|
35
|
+
try:
|
|
36
|
+
parsed = urlsplit(url)
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
return f"api_base 无法解析为 URL: {exc}"
|
|
39
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
40
|
+
return "api_base 必须是合法的 http(s) URL(如 https://gateway.my.com/v1)"
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_name(name: str) -> str | None:
|
|
45
|
+
"""校验 provider 名称;非法时返回提示。"""
|
|
46
|
+
if not name:
|
|
47
|
+
return "provider 名称不能为空"
|
|
48
|
+
if not _NAME_RE.fullmatch(name):
|
|
49
|
+
return "provider 名称只能包含字母、数字、下划线或连字符,且不含空格"
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_model(model: str) -> str | None:
|
|
54
|
+
"""校验模型名;非法时返回提示(非法字符/空白)。"""
|
|
55
|
+
if not model or model != model.strip():
|
|
56
|
+
return "模型名不能为空或包含首尾空白"
|
|
57
|
+
if any(ch in model for ch in ("`", " ")):
|
|
58
|
+
return "模型名含非法字符(如反引号或空格),请去掉后重试"
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ProviderConfigService:
|
|
63
|
+
"""面向界面的 provider 管理服务(无内置预设,纯用户自定义)。"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, manager: ConfigManager, settings: object | None = None) -> None:
|
|
66
|
+
self.manager = manager
|
|
67
|
+
self.settings = settings
|
|
68
|
+
|
|
69
|
+
# ---------- 读取 ----------
|
|
70
|
+
|
|
71
|
+
def _active_provider(self) -> str:
|
|
72
|
+
if self.settings is not None and getattr(self.settings, "provider", ""):
|
|
73
|
+
return str(self.settings.provider)
|
|
74
|
+
try:
|
|
75
|
+
return self.manager.active().provider_name
|
|
76
|
+
except Exception:
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
def list(self) -> list[dict]:
|
|
80
|
+
"""返回每个 provider 的视图:脱敏、来源层、是否 base、key 是否已配置。"""
|
|
81
|
+
active = self._active_provider()
|
|
82
|
+
rows = []
|
|
83
|
+
for name in self.manager.provider_names():
|
|
84
|
+
provider = self.manager.resolve_provider(name)
|
|
85
|
+
if provider is None:
|
|
86
|
+
continue
|
|
87
|
+
raw = provider.api_key
|
|
88
|
+
configured = bool(raw) and not _is_placeholder(raw)
|
|
89
|
+
rows.append(
|
|
90
|
+
{
|
|
91
|
+
"name": name,
|
|
92
|
+
"display_name": provider.display_name,
|
|
93
|
+
"api_base": provider.api_base,
|
|
94
|
+
"default_model": provider.default_model,
|
|
95
|
+
"models": list(provider.models),
|
|
96
|
+
"has_key": configured,
|
|
97
|
+
"is_base": name == active,
|
|
98
|
+
"layer": self.manager.provider_layer(name) or "user",
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
return rows
|
|
102
|
+
|
|
103
|
+
def get(self, name: str) -> dict | None:
|
|
104
|
+
provider = self.manager.resolve_provider(name)
|
|
105
|
+
if provider is None:
|
|
106
|
+
return None
|
|
107
|
+
raw = provider.api_key
|
|
108
|
+
configured = bool(raw) and not _is_placeholder(raw)
|
|
109
|
+
return {
|
|
110
|
+
"name": name,
|
|
111
|
+
"display_name": provider.display_name,
|
|
112
|
+
"api_base": provider.api_base,
|
|
113
|
+
"default_model": provider.default_model,
|
|
114
|
+
"models": list(provider.models),
|
|
115
|
+
"api_key": raw if configured else "",
|
|
116
|
+
"api_key_masked": mask_key(raw if configured else ""),
|
|
117
|
+
"has_key": configured,
|
|
118
|
+
"is_base": name == self._active_provider(),
|
|
119
|
+
"layer": self.manager.provider_layer(name) or "user",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
def referenced_by(self, name: str) -> list[str]:
|
|
123
|
+
"""返回引用该 provider 的 SmartRouter 档位名(缺省空)。"""
|
|
124
|
+
tiers = (self.manager.smart_router_config().get("tiers") or {})
|
|
125
|
+
return [t for t, cfg in tiers.items() if cfg.get("provider") == name]
|
|
126
|
+
|
|
127
|
+
# ---------- 写入 ----------
|
|
128
|
+
|
|
129
|
+
def add(
|
|
130
|
+
self,
|
|
131
|
+
name: str,
|
|
132
|
+
api_base: str,
|
|
133
|
+
default_model: str,
|
|
134
|
+
display_name: str | None = None,
|
|
135
|
+
api_key: str | None = None,
|
|
136
|
+
) -> OpResult:
|
|
137
|
+
if self.manager.resolve_provider(name) is not None:
|
|
138
|
+
return OpResult(False, f"provider 已存在: {name},如需修改请用 update/set(J4)")
|
|
139
|
+
err = self._validate_fields(api_base, default_model)
|
|
140
|
+
if err:
|
|
141
|
+
return OpResult(False, err)
|
|
142
|
+
fields = {
|
|
143
|
+
"api_base": api_base.strip(),
|
|
144
|
+
"default_model": default_model.strip(),
|
|
145
|
+
"display_name": display_name.strip() if display_name else None,
|
|
146
|
+
}
|
|
147
|
+
layer = self.manager.upsert_provider(name, fields)
|
|
148
|
+
out = f"已添加 provider: {name}(写入 {layer} 配置)"
|
|
149
|
+
if api_key:
|
|
150
|
+
key_result = self.set_api_key(name, api_key)
|
|
151
|
+
if not key_result.ok:
|
|
152
|
+
out += f"{key_result.message}"
|
|
153
|
+
return OpResult(True, out, {"layer": layer})
|
|
154
|
+
|
|
155
|
+
def update(self, name: str, fields: dict) -> OpResult:
|
|
156
|
+
provider = self.manager.resolve_provider(name)
|
|
157
|
+
if provider is None:
|
|
158
|
+
return OpResult(False, f"未知 provider: {name},可用: {', '.join(self.manager.provider_names()) or '(无)'}")
|
|
159
|
+
unknown = [k for k in fields if k not in _ALLOWED_FIELDS]
|
|
160
|
+
if unknown:
|
|
161
|
+
return OpResult(False, f"不支持字段: {', '.join(unknown)};可用: {', '.join(_ALLOWED_FIELDS)}")
|
|
162
|
+
api_base = fields.get("api_base")
|
|
163
|
+
if api_base is not None:
|
|
164
|
+
err = validate_api_base(str(api_base))
|
|
165
|
+
if err:
|
|
166
|
+
return OpResult(False, err)
|
|
167
|
+
default_model = fields.get("default_model")
|
|
168
|
+
if default_model is not None and not str(default_model).strip():
|
|
169
|
+
return OpResult(False, "default_model 必填,不能为空(J7)")
|
|
170
|
+
normalized = {}
|
|
171
|
+
if api_base is not None:
|
|
172
|
+
normalized["api_base"] = str(api_base).strip()
|
|
173
|
+
if default_model is not None:
|
|
174
|
+
normalized["default_model"] = str(default_model).strip()
|
|
175
|
+
if "display_name" in fields:
|
|
176
|
+
normalized["display_name"] = (
|
|
177
|
+
str(fields["display_name"]).strip() if fields["display_name"] or fields["display_name"] == "" else None
|
|
178
|
+
)
|
|
179
|
+
layer = self.manager.upsert_provider(name, normalized)
|
|
180
|
+
return OpResult(True, f"已更新 provider: {name}(写入 {layer} 配置)")
|
|
181
|
+
|
|
182
|
+
def remove(self, name: str, *, yes: bool = False) -> OpResult:
|
|
183
|
+
provider = self.manager.resolve_provider(name)
|
|
184
|
+
if provider is None:
|
|
185
|
+
return OpResult(False, f"未知 provider: {name}")
|
|
186
|
+
if name == self._active_provider():
|
|
187
|
+
return OpResult(
|
|
188
|
+
False,
|
|
189
|
+
f"不能删除当前 base provider: {name}(否则没有可用 base)。请先 /provider switch <其它> 再删。",
|
|
190
|
+
)
|
|
191
|
+
referenced = self.referenced_by(name)
|
|
192
|
+
warnings = []
|
|
193
|
+
if referenced:
|
|
194
|
+
warnings.append(f"被 SmartRouter 档位引用:{', '.join(referenced)}(删除后这些档位会回落 base)")
|
|
195
|
+
if not yes:
|
|
196
|
+
head = ";".join(warnings) + "。" if warnings else "此操作会删除该 provider。"
|
|
197
|
+
return OpResult(False, f"{head} 确认请加 --yes(/provider remove {name} --yes)")
|
|
198
|
+
self.manager.delete_provider(name)
|
|
199
|
+
msg = f"已删除 provider: {name}"
|
|
200
|
+
if warnings:
|
|
201
|
+
msg += "(" + ";".join(warnings) + ")"
|
|
202
|
+
return OpResult(True, msg)
|
|
203
|
+
|
|
204
|
+
def switch(self, name: str, model: str | None = None) -> OpResult:
|
|
205
|
+
provider = self.manager.resolve_provider(name)
|
|
206
|
+
if provider is None:
|
|
207
|
+
return OpResult(False, f"未知 provider: {name},可用: {', '.join(self.manager.provider_names()) or '(无)'}")
|
|
208
|
+
target_model = (model or "").strip() or provider.default_model
|
|
209
|
+
self.manager.set_active(provider.name, target_model)
|
|
210
|
+
return OpResult(True, f"base 已切换: {provider.name} / {target_model}")
|
|
211
|
+
|
|
212
|
+
def set_api_key(self, name: str, key: str, *, overwrite: bool = False, yes: bool = False) -> OpResult:
|
|
213
|
+
provider = self.manager.resolve_provider(name)
|
|
214
|
+
if provider is None:
|
|
215
|
+
return OpResult(False, f"未知 provider: {name}")
|
|
216
|
+
if _is_placeholder(key):
|
|
217
|
+
return OpResult(False, "占位值(如 sk-xxx)视为未配置,已拒绝写入")
|
|
218
|
+
existing = provider.api_key
|
|
219
|
+
if existing and not _is_placeholder(existing) and not overwrite:
|
|
220
|
+
if not yes:
|
|
221
|
+
return OpResult(
|
|
222
|
+
False,
|
|
223
|
+
f"{name} 已配置 key {mask_key(existing)},覆盖请加 --yes(/provider key {name} <KEY> --yes)",
|
|
224
|
+
)
|
|
225
|
+
overwrite = True
|
|
226
|
+
self.manager.upsert_provider(name, {"api_key": key})
|
|
227
|
+
status = "已写入" if (existing and existing != key) or not existing else "未改动"
|
|
228
|
+
extra = f"(覆盖旧值 {mask_key(existing)})" if existing and existing != key else ""
|
|
229
|
+
return OpResult(True, f"{status} providers.{name}.api_key -> config.json,仅显示 {mask_key(key)}{extra}")
|
|
230
|
+
|
|
231
|
+
def add_model(self, name: str, model: str) -> OpResult:
|
|
232
|
+
"""把模型加入 provider 的模型列表(去重;写入生效层)。"""
|
|
233
|
+
provider = self.manager.resolve_provider(name)
|
|
234
|
+
if provider is None:
|
|
235
|
+
return OpResult(False, f"未知 provider: {name},可用: {', '.join(self.manager.provider_names()) or '(无)'}")
|
|
236
|
+
err = validate_model(model)
|
|
237
|
+
if err:
|
|
238
|
+
return OpResult(False, err)
|
|
239
|
+
model = model.strip()
|
|
240
|
+
current = list(provider.models)
|
|
241
|
+
if model in current:
|
|
242
|
+
return OpResult(False, f"模型已存在: {name} 的列表中已有 {model}")
|
|
243
|
+
current.append(model)
|
|
244
|
+
layer = self.manager.upsert_provider(name, {"models": current})
|
|
245
|
+
return OpResult(True, f"已为 {name} 添加模型: {model}(写入 {layer} 配置。可用: /model {model} 切换)")
|
|
246
|
+
|
|
247
|
+
def remove_model(self, name: str, model: str) -> OpResult:
|
|
248
|
+
"""把模型从 provider 的模型列表移除。"""
|
|
249
|
+
provider = self.manager.resolve_provider(name)
|
|
250
|
+
if provider is None:
|
|
251
|
+
return OpResult(False, f"未知 provider: {name},可用: {', '.join(self.manager.provider_names()) or '(无)'}")
|
|
252
|
+
model = model.strip()
|
|
253
|
+
if model not in provider.models:
|
|
254
|
+
return OpResult(False, f"模型不在列表: {name} 的模型列表中没有 {model}")
|
|
255
|
+
remaining = [m for m in provider.models if m != model]
|
|
256
|
+
layer = self.manager.upsert_provider(name, {"models": remaining})
|
|
257
|
+
return OpResult(True, f"已从 {name} 移除模型: {model}(写入 {layer} 配置)")
|
|
258
|
+
|
|
259
|
+
# ---------- 校验 ----------
|
|
260
|
+
|
|
261
|
+
def _validate_fields(self, api_base: str, default_model: str) -> str | None:
|
|
262
|
+
err = validate_api_base(str(api_base))
|
|
263
|
+
if err:
|
|
264
|
+
return err
|
|
265
|
+
if not str(default_model).strip():
|
|
266
|
+
return "default_model 必填,不能为空(J7)"
|
|
267
|
+
return None
|
xg/config/providers.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Provider 数据模型与注册表。
|
|
2
|
+
|
|
3
|
+
provider 不内置预设,全部由用户在配置(config.json 的 ``providers`` 节)
|
|
4
|
+
中自定义 name / api_base / default_model / api_key 等;API Key 与 provider
|
|
5
|
+
的一切配置都写入 config.json,不再走 ``.env``。能力参数(window / cache /
|
|
6
|
+
vision)为可选预设值。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Provider:
|
|
16
|
+
name: str # 用户自定义名称
|
|
17
|
+
display_name: str
|
|
18
|
+
api_base: str # base url(来自 config.json)
|
|
19
|
+
default_model: str
|
|
20
|
+
models: tuple[str, ...] = () # 该 provider 可切换的模型列表(config.json 的 providers.<name>.models)
|
|
21
|
+
api_key: str = "" # API Key(明文存于 config.json 的 providers.<name>.api_key)
|
|
22
|
+
api_key_env: str = "" # 兼容保留项:旧「环境变量读取」模式,现已不使用
|
|
23
|
+
context_window: int = 0 # 上下文窗口(token),用于预算控制
|
|
24
|
+
supports_cache: bool = False # 是否支持 prompt cache(预留)
|
|
25
|
+
supports_vision: bool = False # 是否支持图片输入(预留)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProviderRegistry:
|
|
29
|
+
"""用户自定义 provider 的注册表(无内置预设,初始为空)。"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, providers: list[Provider] | None = None) -> None:
|
|
32
|
+
self._providers: dict[str, Provider] = {
|
|
33
|
+
p.name: p for p in (providers if providers is not None else [])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def get(self, name: str) -> Provider | None:
|
|
37
|
+
return self._providers.get(name)
|
|
38
|
+
|
|
39
|
+
def all(self) -> list[Provider]:
|
|
40
|
+
return list(self._providers.values())
|
|
41
|
+
|
|
42
|
+
def names(self) -> list[str]:
|
|
43
|
+
return list(self._providers)
|
|
44
|
+
|
|
45
|
+
def register(self, provider: Provider) -> None:
|
|
46
|
+
self._providers[provider.name] = provider
|
xg/config/settings.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""运行时配置快照与加载。
|
|
2
|
+
|
|
3
|
+
Settings 是解析后的运行时快照(provider / model / key / 窗口等),
|
|
4
|
+
由 ConfigManager 按「默认 < 用户级 < 项目级 < 环境变量」合并产出。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from xg.config.manager import ConfigManager, ProviderNotConfigured
|
|
12
|
+
from xg.config.skills import SkillConfigManager
|
|
13
|
+
from xg.tui.i18n import UiLanguage, normalize_language
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Settings:
|
|
18
|
+
"""运行时配置快照。provider / model 可被 /model 命令运行时修改。"""
|
|
19
|
+
|
|
20
|
+
provider: str = ""
|
|
21
|
+
api_base: str = ""
|
|
22
|
+
api_key: str = ""
|
|
23
|
+
model: str = ""
|
|
24
|
+
context_window: int = 128_000
|
|
25
|
+
tool_steps: int = 20
|
|
26
|
+
# LLM transient request retry policy.
|
|
27
|
+
llm_retry_enabled: bool = True
|
|
28
|
+
llm_max_retries: int = 2
|
|
29
|
+
llm_retry_base_delay: float = 1.0
|
|
30
|
+
llm_retry_max_delay: float = 8.0
|
|
31
|
+
llm_retry_jitter: float = 0.25
|
|
32
|
+
llm_retry_total_timeout: float = 30.0
|
|
33
|
+
llm_respect_retry_after: bool = True
|
|
34
|
+
# token 预算阈值:messages 估算 token 超过 window * budget_ratio 时终止循环
|
|
35
|
+
budget_ratio: float = 0.8
|
|
36
|
+
# 第 5 期:自动压缩时保留的最近完整对话轮次
|
|
37
|
+
context_keep_recent_turns: int = 4
|
|
38
|
+
# 第 5 期:摘要输出动态预留上限
|
|
39
|
+
context_summary_max_tokens: int = 4096
|
|
40
|
+
# 第 5 期:自动注入长期记忆的字符上限
|
|
41
|
+
memory_prompt_max_chars: int = 8000
|
|
42
|
+
# 第 5 期:单个 XG.md/XG.local.md 的读取字符上限
|
|
43
|
+
project_memory_max_chars: int = 32000
|
|
44
|
+
# 工具输出超出该字符数则截断,防止撑爆上下文
|
|
45
|
+
max_tool_output_chars: int = 20_000
|
|
46
|
+
# 并行工具执行并发数(第 3 期)
|
|
47
|
+
max_parallel: int = 4
|
|
48
|
+
# 单工具执行超时(秒,第 3 期)
|
|
49
|
+
tool_timeout: float = 120.0
|
|
50
|
+
# HITL 审批默认开启(第 3 期)
|
|
51
|
+
hitl: bool = True
|
|
52
|
+
# 计划模式(第 4 期):子任务数上限
|
|
53
|
+
plan_max_subtasks: int = 12
|
|
54
|
+
# 计划模式:每个子任务的最大工具步数
|
|
55
|
+
plan_subtask_steps: int = 10
|
|
56
|
+
# 计划模式:计划级允许的子任务失败数(超出终止剩余批次)
|
|
57
|
+
plan_max_failures: int = 3
|
|
58
|
+
# 第 10 期:Team 协作限制
|
|
59
|
+
team_max_agents: int = 4
|
|
60
|
+
team_max_repairs: int = 2
|
|
61
|
+
# 第 V3 期:Plan/Team 任务自然语言断点续跑
|
|
62
|
+
# 单个任务最多自动恢复次数(防「失败→继续→失败」死循环消耗预算)
|
|
63
|
+
task_max_resumes: int = 3
|
|
64
|
+
# 恢复意图是否用 LLM 识别;关闭后仅用关键词白名单
|
|
65
|
+
resume_intent_llm: bool = True
|
|
66
|
+
# Reviewer 输出协议重试次数;与 API 重试和 Repairer 次数分离。
|
|
67
|
+
team_review_output_retries: int = 1
|
|
68
|
+
# Team Worker role-specific execution budgets. None keeps the legacy fallback.
|
|
69
|
+
team_researcher_steps: int | None = None
|
|
70
|
+
team_reviewer_steps: int | None = None
|
|
71
|
+
team_coder_steps: int | None = None
|
|
72
|
+
team_tester_steps: int | None = None
|
|
73
|
+
team_repairer_steps: int | None = None
|
|
74
|
+
team_synthesizer_steps: int | None = None
|
|
75
|
+
team_max_steps: int = 40
|
|
76
|
+
team_recovery_steps: int = 10
|
|
77
|
+
team_max_recoveries: int = 1
|
|
78
|
+
team_review: bool = True
|
|
79
|
+
# Textual TUI 最大刷新频率(第 6 期)
|
|
80
|
+
tui_refresh_fps: int = 20
|
|
81
|
+
# 第 7 期:MCP 客户端与资源限制
|
|
82
|
+
mcp_enabled: bool = True
|
|
83
|
+
mcp_startup_timeout: float = 15.0
|
|
84
|
+
mcp_request_timeout: float = 120.0
|
|
85
|
+
mcp_shutdown_timeout: float = 5.0
|
|
86
|
+
mcp_max_servers: int = 32
|
|
87
|
+
mcp_max_tools: int = 256
|
|
88
|
+
mcp_max_resources: int = 512
|
|
89
|
+
mcp_max_message_bytes: int = 2_097_152
|
|
90
|
+
mcp_resource_max_chars: int = 32_000
|
|
91
|
+
mcp_resource_total_chars: int = 64_000
|
|
92
|
+
mcp_log_lines: int = 200
|
|
93
|
+
# 第 8 期:只读 Web 能力限制
|
|
94
|
+
web_enabled: bool = True
|
|
95
|
+
web_search_provider: str = "none"
|
|
96
|
+
web_timeout: float = 15.0
|
|
97
|
+
web_max_results: int = 5
|
|
98
|
+
web_max_response_bytes: int = 2_097_152
|
|
99
|
+
web_fetch_max_chars: int = 32_000
|
|
100
|
+
web_max_redirects: int = 5
|
|
101
|
+
web_rate_limit_per_minute: int = 30
|
|
102
|
+
# 第 9 期:Skill 索引和按需加载限制
|
|
103
|
+
skills_enabled: bool = True
|
|
104
|
+
skills_max_index_items: int = 20
|
|
105
|
+
skills_max_index_chars: int = 4_096
|
|
106
|
+
skills_max_chars: int = 32_000
|
|
107
|
+
skills_max_reference_chars: int = 16_000
|
|
108
|
+
skills_max_loaded_chars: int = 64_000
|
|
109
|
+
# 输入历史:只影响本地 Composer/inline 输入,不进入 Agent 上下文
|
|
110
|
+
input_history_enabled: bool = True
|
|
111
|
+
input_history_persist: bool = True
|
|
112
|
+
input_history_max_entries: int = 100
|
|
113
|
+
input_history_max_chars: int = 8_000
|
|
114
|
+
input_history_max_bytes: int = 1_048_576
|
|
115
|
+
extra: dict[str, str] = field(default_factory=dict)
|
|
116
|
+
# Inspector UI language; this does not affect Agent prompts or responses.
|
|
117
|
+
ui_language: UiLanguage = "en"
|
|
118
|
+
# SmartRouter(phase-01):智能路由开关,默认关闭;开启后每轮输入自动路由换档。
|
|
119
|
+
# 路由结果只改内存中的 provider/model,不写回 active_provider/active_model。
|
|
120
|
+
smart_router_enabled: bool = False
|
|
121
|
+
# 开启前的手动模型快照 (provider, model),/smartRouter off 时恢复;未开启时为 None。
|
|
122
|
+
smart_router_saved: tuple[str, str] | None = None
|
|
123
|
+
# base provider 未配置时置 True(active() 触发 ProviderNotConfigured),供启动提示。
|
|
124
|
+
provider_missing: bool = False
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def token_budget(self) -> int:
|
|
128
|
+
return int(self.context_window * self.budget_ratio)
|
|
129
|
+
|
|
130
|
+
def estimate_tokens(self, text: str) -> int:
|
|
131
|
+
"""字符近似估算:ASCII 约 4 字符/token,非 ASCII(如中文)约 1 字符/token。"""
|
|
132
|
+
if not text:
|
|
133
|
+
return 0
|
|
134
|
+
ascii_chars = sum(1 for ch in text if ord(ch) < 128)
|
|
135
|
+
non_ascii = len(text) - ascii_chars
|
|
136
|
+
return ascii_chars // 4 + non_ascii
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_settings(manager: ConfigManager | None = None) -> Settings:
|
|
140
|
+
"""加载配置并产出运行时快照。"""
|
|
141
|
+
manager = manager or ConfigManager()
|
|
142
|
+
try:
|
|
143
|
+
active = manager.active()
|
|
144
|
+
provider_missing = False
|
|
145
|
+
except ProviderNotConfigured:
|
|
146
|
+
active = None
|
|
147
|
+
provider_missing = True
|
|
148
|
+
skill_config = SkillConfigManager(
|
|
149
|
+
user_dir=manager.user_dir,
|
|
150
|
+
project_root=manager.project_dir.parent,
|
|
151
|
+
env=manager.env,
|
|
152
|
+
).load()
|
|
153
|
+
return Settings(
|
|
154
|
+
provider=active.provider_name if active else "",
|
|
155
|
+
api_base=active.api_base if active else "",
|
|
156
|
+
api_key=active.api_key if active else "",
|
|
157
|
+
model=active.model if active else "",
|
|
158
|
+
context_window=active.context_window if active else 128_000,
|
|
159
|
+
provider_missing=provider_missing,
|
|
160
|
+
tool_steps=_get_int(manager.env, "XG_TOOL_STEPS", 20),
|
|
161
|
+
llm_retry_enabled=manager.env.get("XG_LLM_RETRY_ENABLED", "on").lower() not in ("off", "0", "false"),
|
|
162
|
+
llm_max_retries=max(0, _get_int(manager.env, "XG_LLM_MAX_RETRIES", 2)),
|
|
163
|
+
llm_retry_base_delay=max(0.0, _get_float(manager.env, "XG_LLM_RETRY_BASE_DELAY", 1.0)),
|
|
164
|
+
llm_retry_max_delay=max(0.0, _get_float(manager.env, "XG_LLM_RETRY_MAX_DELAY", 8.0)),
|
|
165
|
+
llm_retry_jitter=_clamp_float(_get_float(manager.env, "XG_LLM_RETRY_JITTER", 0.25), 0.0, 1.0),
|
|
166
|
+
llm_retry_total_timeout=max(0.0, _get_float(manager.env, "XG_LLM_RETRY_TOTAL_TIMEOUT", 30.0)),
|
|
167
|
+
llm_respect_retry_after=manager.env.get("XG_LLM_RESPECT_RETRY_AFTER", "on").lower() not in ("off", "0", "false"),
|
|
168
|
+
budget_ratio=_clamp_float(_get_float(manager.env, "XG_CONTEXT_BUDGET_RATIO", 0.8), 0.5, 0.9),
|
|
169
|
+
context_keep_recent_turns=max(0, _get_int(manager.env, "XG_CONTEXT_KEEP_RECENT_TURNS", 4)),
|
|
170
|
+
context_summary_max_tokens=max(512, _get_int(manager.env, "XG_CONTEXT_SUMMARY_MAX_TOKENS", 4096)),
|
|
171
|
+
memory_prompt_max_chars=max(256, _get_int(manager.env, "XG_MEMORY_PROMPT_MAX_CHARS", 8000)),
|
|
172
|
+
project_memory_max_chars=max(256, _get_int(manager.env, "XG_PROJECT_MEMORY_MAX_CHARS", 32000)),
|
|
173
|
+
max_parallel=_get_int(manager.env, "XG_MAX_PARALLEL", 4),
|
|
174
|
+
tool_timeout=_get_float(manager.env, "XG_TOOL_TIMEOUT", 120.0),
|
|
175
|
+
hitl=manager.env.get("XG_HITL", "on").lower() not in ("off", "0", "false"),
|
|
176
|
+
plan_max_subtasks=_get_int(manager.env, "XG_PLAN_MAX_SUBTASKS", 12),
|
|
177
|
+
plan_subtask_steps=_get_int(manager.env, "XG_PLAN_SUBTASK_STEPS", 10),
|
|
178
|
+
plan_max_failures=_get_int(manager.env, "XG_PLAN_MAX_FAILURES", 3),
|
|
179
|
+
task_max_resumes=max(1, _get_int(manager.env, "XG_TASK_MAX_RESUMES", 3)),
|
|
180
|
+
resume_intent_llm=manager.env.get("XG_RESUME_INTENT_LLM", "on").lower() not in ("off", "0", "false"),
|
|
181
|
+
team_max_agents=max(1, _get_int(manager.env, "XG_TEAM_MAX_AGENTS", 4)),
|
|
182
|
+
team_max_repairs=max(0, _get_int(manager.env, "XG_TEAM_MAX_REPAIRS", 2)),
|
|
183
|
+
team_review_output_retries=max(0, _get_int(manager.env, "XG_TEAM_REVIEW_OUTPUT_RETRIES", 1)),
|
|
184
|
+
team_researcher_steps=_get_optional_positive_int(manager.env, "XG_TEAM_RESEARCHER_STEPS"),
|
|
185
|
+
team_reviewer_steps=_get_optional_positive_int(manager.env, "XG_TEAM_REVIEWER_STEPS"),
|
|
186
|
+
team_coder_steps=_get_optional_positive_int(manager.env, "XG_TEAM_CODER_STEPS"),
|
|
187
|
+
team_tester_steps=_get_optional_positive_int(manager.env, "XG_TEAM_TESTER_STEPS"),
|
|
188
|
+
team_repairer_steps=_get_optional_positive_int(manager.env, "XG_TEAM_REPAIRER_STEPS"),
|
|
189
|
+
team_synthesizer_steps=_get_optional_positive_int(manager.env, "XG_TEAM_SYNTHESIZER_STEPS"),
|
|
190
|
+
team_max_steps=max(1, _get_int(manager.env, "XG_TEAM_MAX_STEPS", 40)),
|
|
191
|
+
team_recovery_steps=max(1, _get_int(manager.env, "XG_TEAM_RECOVERY_STEPS", 10)),
|
|
192
|
+
team_max_recoveries=max(0, _get_int(manager.env, "XG_TEAM_MAX_RECOVERIES", 1)),
|
|
193
|
+
team_review=manager.env.get("XG_TEAM_REVIEW", "on").lower() not in ("off", "0", "false"),
|
|
194
|
+
tui_refresh_fps=max(5, min(60, _get_int(manager.env, "XG_TUI_REFRESH_FPS", 20))),
|
|
195
|
+
ui_language=normalize_language(manager.get_ui_language()),
|
|
196
|
+
mcp_enabled=manager.env.get("XG_MCP_ENABLED", "on").lower() not in ("off", "0", "false"),
|
|
197
|
+
mcp_startup_timeout=max(0.1, _get_float(manager.env, "XG_MCP_STARTUP_TIMEOUT", 15.0)),
|
|
198
|
+
mcp_request_timeout=max(0.1, _get_float(manager.env, "XG_MCP_REQUEST_TIMEOUT", 120.0)),
|
|
199
|
+
mcp_shutdown_timeout=max(0.1, _get_float(manager.env, "XG_MCP_SHUTDOWN_TIMEOUT", 5.0)),
|
|
200
|
+
mcp_max_servers=max(1, _get_int(manager.env, "XG_MCP_MAX_SERVERS", 32)),
|
|
201
|
+
mcp_max_tools=max(1, _get_int(manager.env, "XG_MCP_MAX_TOOLS", 256)),
|
|
202
|
+
mcp_max_resources=max(1, _get_int(manager.env, "XG_MCP_MAX_RESOURCES", 512)),
|
|
203
|
+
mcp_max_message_bytes=max(1024, _get_int(manager.env, "XG_MCP_MAX_MESSAGE_BYTES", 2_097_152)),
|
|
204
|
+
mcp_resource_max_chars=max(256, _get_int(manager.env, "XG_MCP_RESOURCE_MAX_CHARS", 32_000)),
|
|
205
|
+
mcp_resource_total_chars=max(256, _get_int(manager.env, "XG_MCP_RESOURCE_TOTAL_CHARS", 64_000)),
|
|
206
|
+
mcp_log_lines=max(10, _get_int(manager.env, "XG_MCP_LOG_LINES", 200)),
|
|
207
|
+
web_enabled=manager.env.get("XG_WEB_ENABLED", "on").lower() not in ("off", "0", "false"),
|
|
208
|
+
web_search_provider=manager.env.get("XG_WEB_SEARCH_PROVIDER", "none").lower() or "none",
|
|
209
|
+
web_timeout=max(0.1, _get_float(manager.env, "XG_WEB_TIMEOUT", 15.0)),
|
|
210
|
+
web_max_results=max(1, min(10, _get_int(manager.env, "XG_WEB_MAX_RESULTS", 5))),
|
|
211
|
+
web_max_response_bytes=max(1024, _get_int(manager.env, "XG_WEB_MAX_RESPONSE_BYTES", 2_097_152)),
|
|
212
|
+
web_fetch_max_chars=max(256, _get_int(manager.env, "XG_WEB_FETCH_MAX_CHARS", 32_000)),
|
|
213
|
+
web_max_redirects=max(0, _get_int(manager.env, "XG_WEB_MAX_REDIRECTS", 5)),
|
|
214
|
+
web_rate_limit_per_minute=max(1, _get_int(manager.env, "XG_WEB_RATE_LIMIT_PER_MINUTE", 30)),
|
|
215
|
+
skills_enabled=skill_config.enabled,
|
|
216
|
+
skills_max_index_items=skill_config.max_index_items,
|
|
217
|
+
skills_max_index_chars=skill_config.max_index_chars,
|
|
218
|
+
skills_max_chars=skill_config.max_skill_chars,
|
|
219
|
+
skills_max_reference_chars=skill_config.max_reference_chars,
|
|
220
|
+
skills_max_loaded_chars=skill_config.max_loaded_chars,
|
|
221
|
+
input_history_enabled=manager.env.get("XG_INPUT_HISTORY_ENABLED", "on").lower() not in ("off", "0", "false"),
|
|
222
|
+
input_history_persist=manager.env.get("XG_INPUT_HISTORY_PERSIST", "on").lower() not in ("off", "0", "false"),
|
|
223
|
+
input_history_max_entries=max(1, _get_int(manager.env, "XG_INPUT_HISTORY_MAX_ENTRIES", 100)),
|
|
224
|
+
input_history_max_chars=max(256, _get_int(manager.env, "XG_INPUT_HISTORY_MAX_CHARS", 8_000)),
|
|
225
|
+
input_history_max_bytes=max(4_096, _get_int(manager.env, "XG_INPUT_HISTORY_MAX_BYTES", 1_048_576)),
|
|
226
|
+
smart_router_enabled=bool(manager.smart_router_config().get("enabled", False)),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _get_float(env: dict[str, str], name: str, default: float) -> float:
|
|
231
|
+
raw = env.get(name, "")
|
|
232
|
+
try:
|
|
233
|
+
return float(raw) if raw else default
|
|
234
|
+
except ValueError:
|
|
235
|
+
return default
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _get_int(env: dict[str, str], name: str, default: int) -> int:
|
|
239
|
+
raw = env.get(name, "")
|
|
240
|
+
try:
|
|
241
|
+
return int(raw) if raw else default
|
|
242
|
+
except ValueError:
|
|
243
|
+
return default
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _get_optional_positive_int(env: dict[str, str], name: str) -> int | None:
|
|
247
|
+
raw = env.get(name, "")
|
|
248
|
+
if not raw:
|
|
249
|
+
return None
|
|
250
|
+
try:
|
|
251
|
+
value = int(raw)
|
|
252
|
+
except ValueError:
|
|
253
|
+
return None
|
|
254
|
+
return value if value > 0 else None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _clamp_float(value: float, low: float, high: float) -> float:
|
|
258
|
+
return max(low, min(high, value))
|
xg/config/skills.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""User/project Skill configuration and environment overrides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from xg.skill.models import SkillConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SKILLS_CONFIG_FILE = "skills.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _merge(base: dict, override: dict) -> dict:
|
|
17
|
+
result = dict(base)
|
|
18
|
+
for key, value in override.items():
|
|
19
|
+
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
20
|
+
result[key] = _merge(result[key], value)
|
|
21
|
+
else:
|
|
22
|
+
result[key] = value
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SkillConfigManager:
|
|
27
|
+
def __init__(self, *, user_dir: str | Path | None = None,
|
|
28
|
+
project_root: str | Path | None = None,
|
|
29
|
+
env: dict[str, str] | None = None) -> None:
|
|
30
|
+
self.user_dir = Path(user_dir) if user_dir else Path.home() / ".xg"
|
|
31
|
+
self.project_root = (Path(project_root) if project_root else Path.cwd()).resolve()
|
|
32
|
+
self.user_config_path = self.user_dir / SKILLS_CONFIG_FILE
|
|
33
|
+
self.project_config_path = self.project_root / ".xg" / SKILLS_CONFIG_FILE
|
|
34
|
+
self.env = env if env is not None else os.environ
|
|
35
|
+
self.errors: list[str] = []
|
|
36
|
+
|
|
37
|
+
def _read(self, path: Path) -> dict:
|
|
38
|
+
if not path.is_file():
|
|
39
|
+
return {}
|
|
40
|
+
try:
|
|
41
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
42
|
+
except (OSError, json.JSONDecodeError):
|
|
43
|
+
self.errors.append(f"{path}: JSON 配置读取失败")
|
|
44
|
+
return {}
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
self.errors.append(f"{path}: 顶层必须是 JSON 对象")
|
|
47
|
+
return {}
|
|
48
|
+
return data
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _bool(value: Any, default: bool) -> bool:
|
|
52
|
+
if isinstance(value, bool):
|
|
53
|
+
return value
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
return value.lower() not in {"off", "false", "0", "no"}
|
|
56
|
+
return default
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _int(value: Any, default: int, minimum: int = 1) -> int:
|
|
60
|
+
try:
|
|
61
|
+
return max(minimum, int(value))
|
|
62
|
+
except (TypeError, ValueError):
|
|
63
|
+
return default
|
|
64
|
+
|
|
65
|
+
def load(self) -> SkillConfig:
|
|
66
|
+
self.errors.clear()
|
|
67
|
+
user = self._read(self.user_config_path)
|
|
68
|
+
project = self._read(self.project_config_path)
|
|
69
|
+
raw = _merge(user, project)
|
|
70
|
+
env = self.env
|
|
71
|
+
return SkillConfig(
|
|
72
|
+
enabled=self._bool(env.get("XG_SKILLS_ENABLED", raw.get("enabled", True)), True),
|
|
73
|
+
default_enabled=self._bool(raw.get("default_enabled", True), True),
|
|
74
|
+
max_index_items=self._int(env.get("XG_SKILLS_MAX_INDEX_ITEMS", raw.get("max_index_items", 20)), 20),
|
|
75
|
+
max_index_chars=self._int(env.get("XG_SKILLS_MAX_INDEX_CHARS", raw.get("max_index_chars", 4_096)), 4_096),
|
|
76
|
+
max_skill_chars=self._int(env.get("XG_SKILLS_MAX_CHARS", raw.get("max_skill_chars", 32_000)), 32_000),
|
|
77
|
+
max_reference_chars=self._int(env.get("XG_SKILLS_MAX_REFERENCE_CHARS", raw.get("max_reference_chars", 16_000)), 16_000),
|
|
78
|
+
max_loaded_chars=self._int(env.get("XG_SKILLS_MAX_LOADED_CHARS", raw.get("max_loaded_chars", 64_000)), 64_000),
|
|
79
|
+
max_references=self._int(raw.get("max_references", 8), 8),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def enabled_overrides(self) -> dict[str, bool]:
|
|
83
|
+
raw = _merge(self._read(self.user_config_path), self._read(self.project_config_path))
|
|
84
|
+
enabled = raw.get("enabled", {})
|
|
85
|
+
return {str(name): bool(value) for name, value in enabled.items()} if isinstance(enabled, dict) else {}
|
|
86
|
+
|
|
87
|
+
def set_enabled(self, name: str, enabled: bool, *, project: bool = True) -> None:
|
|
88
|
+
path = self.project_config_path if project else self.user_config_path
|
|
89
|
+
data = self._read(path)
|
|
90
|
+
mapping = data.setdefault("enabled", {})
|
|
91
|
+
if not isinstance(mapping, dict):
|
|
92
|
+
mapping = {}
|
|
93
|
+
data["enabled"] = mapping
|
|
94
|
+
mapping[name] = bool(enabled)
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_skill_config(*, user_dir=None, project_root=None, env=None) -> SkillConfig:
|
|
100
|
+
return SkillConfigManager(user_dir=user_dir, project_root=project_root, env=env).load()
|