echo-agent 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.
Files changed (219) hide show
  1. echo_agent/__init__.py +5 -0
  2. echo_agent/__main__.py +538 -0
  3. echo_agent/_bundled/skills/development/plan/SKILL.md +54 -0
  4. echo_agent/_bundled/skills/development/skill-creator/SKILL.md +270 -0
  5. echo_agent/_bundled/skills/development/skill-creator/scripts/init_skill.py +226 -0
  6. echo_agent/_bundled/skills/development/skill-creator/scripts/package_skill.py +146 -0
  7. echo_agent/_bundled/skills/development/skill-creator/scripts/quick_validate.py +222 -0
  8. echo_agent/_bundled/skills/productivity/summarize/SKILL.md +67 -0
  9. echo_agent/_bundled/skills/productivity/weather/SKILL.md +49 -0
  10. echo_agent/_bundled/skills/research/arxiv/SKILL.md +232 -0
  11. echo_agent/_bundled/skills/research/arxiv/scripts/search_arxiv.py +115 -0
  12. echo_agent/a2a/__init__.py +5 -0
  13. echo_agent/a2a/client.py +66 -0
  14. echo_agent/a2a/models.py +98 -0
  15. echo_agent/a2a/protocol.py +85 -0
  16. echo_agent/a2a/server.py +71 -0
  17. echo_agent/agent/__init__.py +0 -0
  18. echo_agent/agent/approval_gate.py +326 -0
  19. echo_agent/agent/compression/__init__.py +14 -0
  20. echo_agent/agent/compression/assembler.py +45 -0
  21. echo_agent/agent/compression/boundary.py +141 -0
  22. echo_agent/agent/compression/compressor.py +181 -0
  23. echo_agent/agent/compression/engine.py +88 -0
  24. echo_agent/agent/compression/pruner.py +150 -0
  25. echo_agent/agent/compression/summarizer.py +181 -0
  26. echo_agent/agent/compression/types.py +41 -0
  27. echo_agent/agent/compression/validator.py +96 -0
  28. echo_agent/agent/consolidation.py +96 -0
  29. echo_agent/agent/context.py +403 -0
  30. echo_agent/agent/executors/__init__.py +0 -0
  31. echo_agent/agent/executors/base.py +211 -0
  32. echo_agent/agent/executors/factory.py +34 -0
  33. echo_agent/agent/executors/remote.py +193 -0
  34. echo_agent/agent/loop.py +891 -0
  35. echo_agent/agent/multi_agent/__init__.py +15 -0
  36. echo_agent/agent/multi_agent/audit.py +19 -0
  37. echo_agent/agent/multi_agent/error_messages.py +35 -0
  38. echo_agent/agent/multi_agent/error_types.py +36 -0
  39. echo_agent/agent/multi_agent/models.py +37 -0
  40. echo_agent/agent/multi_agent/registry.py +41 -0
  41. echo_agent/agent/multi_agent/runtime.py +201 -0
  42. echo_agent/agent/pipeline/__init__.py +14 -0
  43. echo_agent/agent/pipeline/context_stage.py +219 -0
  44. echo_agent/agent/pipeline/inference_stage.py +433 -0
  45. echo_agent/agent/pipeline/response_stage.py +146 -0
  46. echo_agent/agent/pipeline/types.py +40 -0
  47. echo_agent/agent/planning/__init__.py +4 -0
  48. echo_agent/agent/planning/models.py +83 -0
  49. echo_agent/agent/planning/planner.py +57 -0
  50. echo_agent/agent/planning/reflection.py +54 -0
  51. echo_agent/agent/planning/strategies.py +183 -0
  52. echo_agent/agent/tools/__init__.py +167 -0
  53. echo_agent/agent/tools/base.py +149 -0
  54. echo_agent/agent/tools/circuit_breaker.py +82 -0
  55. echo_agent/agent/tools/clarify.py +42 -0
  56. echo_agent/agent/tools/code_exec.py +147 -0
  57. echo_agent/agent/tools/cronjob.py +93 -0
  58. echo_agent/agent/tools/delegate.py +393 -0
  59. echo_agent/agent/tools/filesystem.py +180 -0
  60. echo_agent/agent/tools/image_gen.py +65 -0
  61. echo_agent/agent/tools/knowledge.py +81 -0
  62. echo_agent/agent/tools/memory.py +198 -0
  63. echo_agent/agent/tools/message.py +39 -0
  64. echo_agent/agent/tools/notify.py +35 -0
  65. echo_agent/agent/tools/patch.py +178 -0
  66. echo_agent/agent/tools/process.py +139 -0
  67. echo_agent/agent/tools/registry.py +185 -0
  68. echo_agent/agent/tools/search.py +99 -0
  69. echo_agent/agent/tools/session_search.py +76 -0
  70. echo_agent/agent/tools/shell.py +164 -0
  71. echo_agent/agent/tools/skill_install.py +255 -0
  72. echo_agent/agent/tools/skills.py +177 -0
  73. echo_agent/agent/tools/task.py +104 -0
  74. echo_agent/agent/tools/todo.py +148 -0
  75. echo_agent/agent/tools/tts.py +77 -0
  76. echo_agent/agent/tools/vision.py +71 -0
  77. echo_agent/agent/tools/web.py +208 -0
  78. echo_agent/agent/tools/workflow.py +89 -0
  79. echo_agent/bus/__init__.py +11 -0
  80. echo_agent/bus/events.py +193 -0
  81. echo_agent/bus/queue.py +158 -0
  82. echo_agent/bus/rate_limiter.py +51 -0
  83. echo_agent/channels/__init__.py +0 -0
  84. echo_agent/channels/base.py +185 -0
  85. echo_agent/channels/cli.py +149 -0
  86. echo_agent/channels/cron.py +44 -0
  87. echo_agent/channels/dingtalk.py +195 -0
  88. echo_agent/channels/discord.py +359 -0
  89. echo_agent/channels/email.py +168 -0
  90. echo_agent/channels/feishu.py +240 -0
  91. echo_agent/channels/manager.py +417 -0
  92. echo_agent/channels/matrix.py +281 -0
  93. echo_agent/channels/qqbot.py +638 -0
  94. echo_agent/channels/qqbot_media.py +482 -0
  95. echo_agent/channels/slack.py +297 -0
  96. echo_agent/channels/telegram.py +275 -0
  97. echo_agent/channels/webhook.py +106 -0
  98. echo_agent/channels/wecom.py +152 -0
  99. echo_agent/channels/weixin.py +603 -0
  100. echo_agent/channels/whatsapp.py +138 -0
  101. echo_agent/cli/__init__.py +0 -0
  102. echo_agent/cli/colors.py +42 -0
  103. echo_agent/cli/evolution_cmd.py +299 -0
  104. echo_agent/cli/i18n/__init__.py +123 -0
  105. echo_agent/cli/i18n/en.py +275 -0
  106. echo_agent/cli/i18n/zh.py +275 -0
  107. echo_agent/cli/plugins_cmd.py +205 -0
  108. echo_agent/cli/prompt.py +102 -0
  109. echo_agent/cli/service.py +156 -0
  110. echo_agent/cli/setup.py +1111 -0
  111. echo_agent/cli/status.py +93 -0
  112. echo_agent/config/__init__.py +8 -0
  113. echo_agent/config/default.yaml +199 -0
  114. echo_agent/config/loader.py +125 -0
  115. echo_agent/config/schema.py +652 -0
  116. echo_agent/evaluation/__init__.py +4 -0
  117. echo_agent/evaluation/dataset.py +66 -0
  118. echo_agent/evaluation/metrics.py +70 -0
  119. echo_agent/evaluation/reporter.py +42 -0
  120. echo_agent/evaluation/runner.py +143 -0
  121. echo_agent/evolution/__init__.py +38 -0
  122. echo_agent/evolution/engine.py +335 -0
  123. echo_agent/evolution/evolver.py +397 -0
  124. echo_agent/evolution/gate.py +413 -0
  125. echo_agent/evolution/recorder.py +288 -0
  126. echo_agent/evolution/scheduler.py +133 -0
  127. echo_agent/evolution/store.py +331 -0
  128. echo_agent/evolution/tools.py +110 -0
  129. echo_agent/evolution/types.py +270 -0
  130. echo_agent/gateway/__init__.py +7 -0
  131. echo_agent/gateway/auth.py +178 -0
  132. echo_agent/gateway/editor.py +121 -0
  133. echo_agent/gateway/health.py +51 -0
  134. echo_agent/gateway/hooks.py +86 -0
  135. echo_agent/gateway/media.py +137 -0
  136. echo_agent/gateway/rate_limiter.py +72 -0
  137. echo_agent/gateway/router.py +86 -0
  138. echo_agent/gateway/server.py +570 -0
  139. echo_agent/gateway/session_context.py +57 -0
  140. echo_agent/gateway/session_policy.py +47 -0
  141. echo_agent/gateway/static/index.html +432 -0
  142. echo_agent/knowledge/__init__.py +5 -0
  143. echo_agent/knowledge/index.py +308 -0
  144. echo_agent/mcp/__init__.py +3 -0
  145. echo_agent/mcp/client.py +158 -0
  146. echo_agent/mcp/manager.py +161 -0
  147. echo_agent/mcp/oauth.py +208 -0
  148. echo_agent/mcp/security.py +79 -0
  149. echo_agent/mcp/tool_adapter.py +73 -0
  150. echo_agent/mcp/transport.py +353 -0
  151. echo_agent/memory/__init__.py +0 -0
  152. echo_agent/memory/consolidator.py +273 -0
  153. echo_agent/memory/contradiction.py +287 -0
  154. echo_agent/memory/forgetting.py +114 -0
  155. echo_agent/memory/retrieval.py +184 -0
  156. echo_agent/memory/reviewer.py +192 -0
  157. echo_agent/memory/store.py +706 -0
  158. echo_agent/memory/tiers.py +243 -0
  159. echo_agent/memory/types.py +168 -0
  160. echo_agent/memory/vectors.py +148 -0
  161. echo_agent/models/__init__.py +0 -0
  162. echo_agent/models/credential_pool.py +86 -0
  163. echo_agent/models/inference.py +98 -0
  164. echo_agent/models/provider.py +208 -0
  165. echo_agent/models/providers/__init__.py +209 -0
  166. echo_agent/models/providers/anthropic_provider.py +164 -0
  167. echo_agent/models/providers/bedrock_provider.py +261 -0
  168. echo_agent/models/providers/format_utils.py +198 -0
  169. echo_agent/models/providers/gemini_provider.py +159 -0
  170. echo_agent/models/providers/openai_provider.py +253 -0
  171. echo_agent/models/providers/openrouter_provider.py +38 -0
  172. echo_agent/models/rate_limiter.py +75 -0
  173. echo_agent/models/router.py +325 -0
  174. echo_agent/models/tokenizer.py +111 -0
  175. echo_agent/observability/__init__.py +0 -0
  176. echo_agent/observability/monitor.py +209 -0
  177. echo_agent/observability/spans.py +75 -0
  178. echo_agent/observability/telemetry.py +86 -0
  179. echo_agent/permissions/__init__.py +0 -0
  180. echo_agent/permissions/allowlist.py +97 -0
  181. echo_agent/permissions/manager.py +460 -0
  182. echo_agent/plugins/__init__.py +30 -0
  183. echo_agent/plugins/context.py +145 -0
  184. echo_agent/plugins/errors.py +23 -0
  185. echo_agent/plugins/hooks.py +126 -0
  186. echo_agent/plugins/loader.py +251 -0
  187. echo_agent/plugins/manager.py +216 -0
  188. echo_agent/plugins/manifest.py +70 -0
  189. echo_agent/runtime_paths.py +25 -0
  190. echo_agent/scheduler/__init__.py +0 -0
  191. echo_agent/scheduler/delivery.py +63 -0
  192. echo_agent/scheduler/service.py +398 -0
  193. echo_agent/security/__init__.py +11 -0
  194. echo_agent/security/capabilities.py +54 -0
  195. echo_agent/security/guards.py +265 -0
  196. echo_agent/security/path_policy.py +212 -0
  197. echo_agent/security/risk_classifier.py +75 -0
  198. echo_agent/security/smart_approval.py +60 -0
  199. echo_agent/security/tool_policy.py +159 -0
  200. echo_agent/session/__init__.py +0 -0
  201. echo_agent/session/manager.py +404 -0
  202. echo_agent/skills/__init__.py +0 -0
  203. echo_agent/skills/manager.py +279 -0
  204. echo_agent/skills/reviewer.py +163 -0
  205. echo_agent/skills/store.py +358 -0
  206. echo_agent/storage/__init__.py +0 -0
  207. echo_agent/storage/backend.py +111 -0
  208. echo_agent/storage/sqlite.py +523 -0
  209. echo_agent/tasks/__init__.py +20 -0
  210. echo_agent/tasks/manager.py +108 -0
  211. echo_agent/tasks/models.py +180 -0
  212. echo_agent/tasks/workflow.py +182 -0
  213. echo_agent/utils/__init__.py +0 -0
  214. echo_agent/utils/async_io.py +80 -0
  215. echo_agent/utils/text.py +91 -0
  216. echo_agent-0.1.0.dist-info/METADATA +286 -0
  217. echo_agent-0.1.0.dist-info/RECORD +219 -0
  218. echo_agent-0.1.0.dist-info/WHEEL +4 -0
  219. echo_agent-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1111 @@
1
+ """Interactive setup wizard for Echo Agent.
2
+
3
+ The wizard is structured into ten sections, each runnable independently:
4
+
5
+ 1. Language — auto-detected, overridable
6
+ 2. Model & Provider — LLM provider + default model
7
+ 3. Permissions — approval mode (smart/manual/off)
8
+ 4. Sandbox — execution backend (local/sandbox/container/remote)
9
+ 5. Agent Behavior — max_iterations / compression / session reset
10
+ 6. Tools — profile + optional integrations (web/tts/mcp/...)
11
+ 7. Channels — messaging integrations + allowlist
12
+ 8. Gateway — Web/WS API exposure
13
+ 9. Observability — log level + OpenTelemetry export
14
+ 10. Evolution — self-evolving skill harness (off by default)
15
+
16
+ Followed by a Capability Check ("doctor") + summary.
17
+
18
+ Locale is auto-detected from the OS, but ``--lang`` overrides it and the
19
+ user-selected locale is persisted to ``ui.locale`` so subsequent runs are
20
+ consistent.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ from pathlib import Path
27
+ from typing import Any, Callable
28
+
29
+ from echo_agent.cli.colors import (
30
+ Colors,
31
+ color,
32
+ print_error,
33
+ print_info,
34
+ print_success,
35
+ print_warning,
36
+ )
37
+ from echo_agent.cli.i18n import detect_locale, get_locale, set_locale, t
38
+ from echo_agent.cli.prompt import (
39
+ is_interactive,
40
+ prompt,
41
+ prompt_checklist,
42
+ prompt_choice,
43
+ prompt_yes_no,
44
+ )
45
+ from echo_agent.config.loader import find_local_config_file, resolve_config_file, save_config
46
+ from echo_agent.runtime_paths import default_config_path
47
+
48
+
49
+ # ── Provider / channel presets ────────────────────────────────────────────────
50
+
51
+ PROVIDERS: list[tuple[str, str, list[str]]] = [
52
+ ("openai", "OpenAI", [
53
+ "gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o1", "o3-mini",
54
+ ]),
55
+ ("anthropic", "Anthropic", [
56
+ "claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-4-5-20251001",
57
+ ]),
58
+ ("gemini", "Google Gemini", [
59
+ "gemini-2.5-pro", "gemini-2.5-flash",
60
+ ]),
61
+ ("openrouter", "OpenRouter", [
62
+ "openai/gpt-4o", "anthropic/claude-sonnet-4-20250514", "google/gemini-2.5-pro",
63
+ ]),
64
+ ("bedrock", "AWS Bedrock", [
65
+ "anthropic.claude-sonnet-4-20250514-v1:0", "anthropic.claude-haiku-4-5-20251001-v1:0",
66
+ ]),
67
+ ("custom", "Custom (OpenAI-compatible)", []),
68
+ ]
69
+
70
+ CHANNEL_DEFS: list[tuple[str, str, list[tuple[str, str]]]] = [
71
+ ("telegram", "Telegram", [("token", "Bot token")]),
72
+ ("discord", "Discord", [("token", "Bot token")]),
73
+ ("slack", "Slack", [("bot_token", "Bot token"), ("app_token", "App token")]),
74
+ ("dingtalk", "DingTalk", [("app_key", "App key"), ("app_secret", "App secret"), ("robot_code", "Robot code")]),
75
+ ("feishu", "Feishu / Lark", [("app_id", "App ID"), ("app_secret", "App secret")]),
76
+ ("wecom", "WeCom", [("corp_id", "Corp ID"), ("agent_id", "Agent ID"), ("secret", "Secret")]),
77
+ ("weixin", "WeChat", []),
78
+ ("qqbot", "QQ Bot", [("app_id", "App ID"), ("app_secret", "App secret")]),
79
+ ("email", "Email", [("imap_host", "IMAP host"), ("smtp_host", "SMTP host"), ("username", "Username"), ("password", "Password")]),
80
+ ("matrix", "Matrix", [("homeserver", "Homeserver URL"), ("user_id", "User ID"), ("access_token", "Access token")]),
81
+ ]
82
+
83
+
84
+ # ── Banner & helpers ──────────────────────────────────────────────────────────
85
+
86
+ def _print_banner() -> None:
87
+ title = t("banner.title")
88
+ subtitle = t("banner.subtitle")
89
+ exit_hint = t("banner.exit_hint")
90
+ width = max(len(title), len(subtitle), len(exit_hint), 50)
91
+ inner = width + 4
92
+ print()
93
+ print(color(" ┌" + "─" * inner + "┐", Colors.CYAN))
94
+ print(color(f" │ {title.center(width)} │", Colors.CYAN))
95
+ print(color(" ├" + "─" * inner + "┤", Colors.CYAN))
96
+ print(color(f" │ {subtitle.ljust(width)} │", Colors.CYAN))
97
+ print(color(f" │ {exit_hint.ljust(width)} │", Colors.CYAN))
98
+ print(color(" └" + "─" * inner + "┘", Colors.CYAN))
99
+
100
+
101
+ def _print_section_header(key: str) -> None:
102
+ label = t(f"section.{key}")
103
+ print()
104
+ print(color(f" ◆ {label}", Colors.CYAN, Colors.BOLD))
105
+ print(color(" " + "─" * (len(label) + 2), Colors.DIM))
106
+
107
+
108
+ def _ensure_dict(parent: dict, key: str) -> dict:
109
+ value = parent.get(key)
110
+ if not isinstance(value, dict):
111
+ value = {}
112
+ parent[key] = value
113
+ return value
114
+
115
+
116
+ # ── Section 1: Language ───────────────────────────────────────────────────────
117
+
118
+ def setup_language(config: dict) -> None:
119
+ _print_section_header("language")
120
+ auto_label = t("language.english") if get_locale() == "en" else t("language.chinese")
121
+ print_info(t("language.auto_detected", label=auto_label))
122
+ choices = [t("language.english"), t("language.chinese")]
123
+ default = 0 if get_locale() == "en" else 1
124
+ idx = prompt_choice(t("language.prompt"), choices, default=default)
125
+ chosen = "en" if idx == 0 else "zh"
126
+ set_locale(chosen)
127
+ _ensure_dict(config, "ui")["locale"] = chosen
128
+ print_success(t("language.saved"))
129
+
130
+
131
+ # ── Section 2: Model & Provider ──────────────────────────────────────────────
132
+
133
+ def setup_model(config: dict) -> None:
134
+ _print_section_header("model")
135
+ print_info(t("model.intro"))
136
+ print()
137
+
138
+ models_block = _ensure_dict(config, "models")
139
+ existing_providers = models_block.get("providers", []) or []
140
+ existing_provider = existing_providers[0] if existing_providers else {}
141
+ existing_name = existing_provider.get("name", "")
142
+ existing_key = existing_provider.get("apiKey", "") or existing_provider.get("api_key", "")
143
+ existing_base = existing_provider.get("apiBase", "") or existing_provider.get("api_base", "")
144
+ existing_model = models_block.get("defaultModel", "") or models_block.get("default_model", "")
145
+
146
+ provider_default = 0
147
+ for i, (key, _label, _models) in enumerate(PROVIDERS):
148
+ if key == existing_name or (existing_name == "openai" and key == "custom" and existing_base):
149
+ provider_default = i
150
+ break
151
+
152
+ provider_names = [p[1] for p in PROVIDERS]
153
+ idx = prompt_choice(t("model.select_provider"), provider_names, default=provider_default)
154
+ provider_key, provider_label, preset_models = PROVIDERS[idx]
155
+
156
+ api_key = ""
157
+ api_base = ""
158
+ if provider_key == "bedrock":
159
+ print_info(t("model.bedrock_hint"))
160
+ elif provider_key == "custom":
161
+ api_base = prompt(f" {t('model.api_base')}", default=existing_base)
162
+ if existing_key:
163
+ api_key = prompt(f" {t('model.api_key_kept')}", password=True)
164
+ if not api_key:
165
+ api_key = existing_key
166
+ else:
167
+ api_key = prompt(f" {t('model.api_key')}", password=True)
168
+ else:
169
+ if existing_key and existing_name == provider_key:
170
+ api_key = prompt(f" {provider_label} {t('model.api_key_kept')}", password=True)
171
+ if not api_key:
172
+ api_key = existing_key
173
+ else:
174
+ api_key = prompt(f" {provider_label} {t('model.api_key')}", password=True)
175
+ if not api_key:
176
+ print_warning(t("model.api_key_missing"))
177
+
178
+ if preset_models:
179
+ custom_idx = len(preset_models)
180
+ model_default = custom_idx
181
+ if existing_model in preset_models:
182
+ model_default = preset_models.index(existing_model)
183
+ model_choices = preset_models + [t("model.model_custom")]
184
+ model_idx = prompt_choice(t("model.model_select"), model_choices, default=model_default)
185
+ if model_idx == custom_idx:
186
+ default_model = prompt(f" {t('model.model_name')}", default=existing_model)
187
+ else:
188
+ default_model = preset_models[model_idx]
189
+ else:
190
+ default_model = ""
191
+ while not default_model:
192
+ default_model = prompt(f" {t('model.model_name')}", default=existing_model)
193
+ if not default_model:
194
+ print_warning(t("model.model_required_custom"))
195
+
196
+ while not default_model:
197
+ print_warning(t("model.model_required"))
198
+ default_model = prompt(f" {t('model.model_name')}", default=existing_model)
199
+
200
+ actual_name = provider_key if provider_key != "custom" else "openai"
201
+ provider_entry: dict[str, Any] = {"name": actual_name}
202
+ if api_key:
203
+ provider_entry["apiKey"] = api_key
204
+ if api_base:
205
+ provider_entry["apiBase"] = api_base
206
+ if preset_models:
207
+ provider_entry["models"] = preset_models
208
+
209
+ config["models"] = {
210
+ "defaultModel": default_model,
211
+ "providers": [provider_entry],
212
+ }
213
+ print_success(t("model.saved", provider=provider_label, model=default_model))
214
+
215
+
216
+ # ── Section 3: Permissions & Approval ────────────────────────────────────────
217
+
218
+ def setup_permissions(config: dict) -> None:
219
+ _print_section_header("permissions")
220
+ print_info(t("permissions.intro"))
221
+ print()
222
+
223
+ perms = _ensure_dict(config, "permissions")
224
+ approval = _ensure_dict(perms, "approval")
225
+ current_mode = approval.get("mode", "smart")
226
+
227
+ mode_keys = ["smart", "manual", "off"]
228
+ mode_labels = [t(f"permissions.mode_{k}") for k in mode_keys]
229
+ default_mode_idx = mode_keys.index(current_mode) if current_mode in mode_keys else 0
230
+ mode_idx = prompt_choice(t("permissions.mode_prompt"), mode_labels, default=default_mode_idx)
231
+ chosen_mode = mode_keys[mode_idx]
232
+ approval["mode"] = chosen_mode
233
+
234
+ if chosen_mode == "smart":
235
+ existing_smart_model = approval.get("smart_model", "") or approval.get("smartModel", "")
236
+ smart_model = prompt(f" {t('permissions.smart_model')}", default=existing_smart_model)
237
+ if smart_model:
238
+ approval["smart_model"] = smart_model
239
+ else:
240
+ approval.pop("smart_model", None)
241
+ approval.pop("smartModel", None)
242
+
243
+ unattended_keys = ["deny", "allow_safe"]
244
+ unattended_labels = [t(f"permissions.unattended_{k}") for k in unattended_keys]
245
+ current_unatt = approval.get("unattended_policy") or approval.get("unattendedPolicy") or "deny"
246
+ default_unatt_idx = unattended_keys.index(current_unatt) if current_unatt in unattended_keys else 0
247
+ unatt_idx = prompt_choice(t("permissions.unattended"), unattended_labels, default=default_unatt_idx)
248
+ approval["unattended_policy"] = unattended_keys[unatt_idx]
249
+
250
+ cli_auto_default = approval.get("cli_auto_approve")
251
+ if cli_auto_default is None:
252
+ cli_auto_default = approval.get("cliAutoApprove", True)
253
+ approval["cli_auto_approve"] = prompt_yes_no(t("permissions.cli_auto"), default=bool(cli_auto_default))
254
+
255
+ print_success(t("permissions.saved", mode=t(f"permissions.mode_{chosen_mode}")))
256
+
257
+
258
+ # ── Section 4: Sandbox / Execution Backend ───────────────────────────────────
259
+
260
+ def setup_terminal(config: dict) -> None:
261
+ _print_section_header("terminal")
262
+ print_info(t("terminal.intro"))
263
+ print()
264
+
265
+ execution = _ensure_dict(config, "execution")
266
+ backend_keys = ["local", "sandbox", "container", "remote"]
267
+ backend_labels = [t(f"terminal.{k}") for k in backend_keys]
268
+ current_backend = execution.get("default_executor") or execution.get("defaultExecutor") or "sandbox"
269
+ default_idx = backend_keys.index(current_backend) if current_backend in backend_keys else 1
270
+ idx = prompt_choice(t("terminal.select"), backend_labels, default=default_idx)
271
+ chosen = backend_keys[idx]
272
+ execution["default_executor"] = chosen
273
+
274
+ if chosen == "container":
275
+ existing_image = execution.get("container_image") or execution.get("containerImage") or ""
276
+ image = prompt(f" {t('terminal.container_image')}", default=existing_image or "python:3.11-slim")
277
+ execution["container_image"] = image
278
+ elif chosen == "remote":
279
+ execution["remote_host"] = prompt(f" {t('terminal.remote_host')}", default=execution.get("remote_host", ""))
280
+ execution["remote_user"] = prompt(f" {t('terminal.remote_user')}", default=execution.get("remote_user", "root"))
281
+ existing_key = execution.get("remote_key_path") or execution.get("remoteKeyPath") or ""
282
+ execution["remote_key_path"] = prompt(f" {t('terminal.remote_key')}", default=existing_key)
283
+
284
+ network_keys = ["allow", "deny", "restricted"]
285
+ network_labels = [t(f"terminal.network_{k}") for k in network_keys]
286
+ current_net = execution.get("network_policy") or execution.get("networkPolicy") or "deny"
287
+ default_net = network_keys.index(current_net) if current_net in network_keys else 1
288
+ net_idx = prompt_choice(t("terminal.network"), network_labels, default=default_net)
289
+ execution["network_policy"] = network_keys[net_idx]
290
+
291
+ tools = _ensure_dict(config, "tools")
292
+ exec_cfg = _ensure_dict(tools, "exec")
293
+ sec_keys = ["deny", "allowlist", "full"]
294
+ sec_labels = [t(f"terminal.exec_security_{k}") for k in sec_keys]
295
+ current_sec = exec_cfg.get("security", "allowlist")
296
+ default_sec = sec_keys.index(current_sec) if current_sec in sec_keys else 1
297
+ sec_idx = prompt_choice(t("terminal.exec_security"), sec_labels, default=default_sec)
298
+ exec_cfg["security"] = sec_keys[sec_idx]
299
+
300
+ print_success(t("terminal.saved", backend=t(f"terminal.{chosen}"), network=t(f"terminal.network_{network_keys[net_idx]}")))
301
+
302
+
303
+ # ── Section 5: Agent Behavior ─────────────────────────────────────────────────
304
+
305
+ def setup_agent(config: dict) -> None:
306
+ _print_section_header("agent")
307
+ print_info(t("agent.intro"))
308
+ print()
309
+
310
+ agent = _ensure_dict(config, "agent")
311
+ current_iter = int(agent.get("max_iterations") or agent.get("maxIterations") or 40)
312
+ print_info(t("agent.max_iter_hint"))
313
+ raw = prompt(f" {t('agent.max_iter')}", default=str(current_iter))
314
+ try:
315
+ agent["max_iterations"] = max(1, int(raw))
316
+ except ValueError:
317
+ print_warning(t("common.invalid"))
318
+ agent["max_iterations"] = current_iter
319
+
320
+ compression = _ensure_dict(config, "compression")
321
+ compression["enabled"] = prompt_yes_no(t("agent.compression_enabled"), default=bool(compression.get("enabled", True)))
322
+ if compression["enabled"]:
323
+ current_thr = float(compression.get("trigger_ratio") or compression.get("triggerRatio") or 0.7)
324
+ print_info(t("agent.compression_threshold_hint"))
325
+ raw = prompt(f" {t('agent.compression_threshold')}", default=f"{current_thr:.2f}")
326
+ try:
327
+ value = float(raw)
328
+ if 0.5 <= value <= 0.95:
329
+ compression["trigger_ratio"] = value
330
+ else:
331
+ print_warning(t("common.invalid"))
332
+ except ValueError:
333
+ print_warning(t("common.invalid"))
334
+
335
+ gateway = _ensure_dict(config, "gateway")
336
+ sp = _ensure_dict(gateway, "sessionPolicy")
337
+ reset_keys = ["both", "idle", "daily", "none"]
338
+ reset_labels = [t(f"agent.session_reset_{k}") for k in reset_keys]
339
+ current_reset = sp.get("mode", "idle")
340
+ default_reset = reset_keys.index(current_reset) if current_reset in reset_keys else 1
341
+ reset_idx = prompt_choice(t("agent.session_reset"), reset_labels, default=default_reset)
342
+ sp["mode"] = reset_keys[reset_idx]
343
+ if reset_keys[reset_idx] in ("idle", "both"):
344
+ cur_idle = int(sp.get("idleTimeoutMinutes") or sp.get("idle_timeout_minutes") or 1440)
345
+ raw = prompt(f" {t('agent.idle_minutes')}", default=str(cur_idle))
346
+ try:
347
+ sp["idleTimeoutMinutes"] = max(1, int(raw))
348
+ except ValueError:
349
+ sp["idleTimeoutMinutes"] = cur_idle
350
+ if reset_keys[reset_idx] in ("daily", "both"):
351
+ cur_hr = int(sp.get("dailyResetHour") or sp.get("daily_reset_hour") or 4)
352
+ raw = prompt(f" {t('agent.daily_hour')}", default=str(cur_hr))
353
+ try:
354
+ v = int(raw)
355
+ sp["dailyResetHour"] = v if 0 <= v <= 23 else cur_hr
356
+ except ValueError:
357
+ sp["dailyResetHour"] = cur_hr
358
+
359
+ planning = _ensure_dict(config, "planning")
360
+ planning["enabled"] = prompt_yes_no(t("agent.planning_enabled"), default=bool(planning.get("enabled", True)))
361
+ memory = _ensure_dict(config, "memory")
362
+ memory["enabled"] = prompt_yes_no(t("agent.memory_enabled"), default=bool(memory.get("enabled", True)))
363
+
364
+ print_success(t("agent.saved"))
365
+
366
+
367
+ # ── Section 6: Tools ──────────────────────────────────────────────────────────
368
+
369
+ TOOL_OPTIONS = ["web", "image_gen", "tts", "code_exec", "knowledge", "cron", "mcp", "skills", "plugins"]
370
+
371
+
372
+ def setup_tools(config: dict) -> None:
373
+ _print_section_header("tools")
374
+ print_info(t("tools.intro"))
375
+ print()
376
+
377
+ tools = _ensure_dict(config, "tools")
378
+ profile_keys = ["minimal", "messaging", "coding", "full"]
379
+ profile_labels = [t(f"tools.profile_{k}") for k in profile_keys]
380
+ # Keep the fallback in sync with schema.py (ToolsConfig.profile) and
381
+ # default.yaml (tools.profile) — all three default to "full". A stale
382
+ # "coding" fallback here silently drops execute_code/exec from the
383
+ # exposed tool set even though they're registered and approved.
384
+ current_profile = tools.get("profile", "full")
385
+ default_profile = profile_keys.index(current_profile) if current_profile in profile_keys else profile_keys.index("full")
386
+ p_idx = prompt_choice(t("tools.profile"), profile_labels, default=default_profile)
387
+ tools["profile"] = profile_keys[p_idx]
388
+
389
+ pre_selected: list[int] = []
390
+ if (tools.get("web", {}) or {}).get("enabled"):
391
+ pre_selected.append(TOOL_OPTIONS.index("web"))
392
+ image_block = tools.get("image_gen") or tools.get("imageGen") or {}
393
+ if image_block.get("api_key") or image_block.get("apiKey"):
394
+ pre_selected.append(TOOL_OPTIONS.index("image_gen"))
395
+ tts_block = tools.get("tts", {}) or {}
396
+ if tts_block.get("openai_api_key") or tts_block.get("openaiApiKey") or tts_block.get("default_backend"):
397
+ pre_selected.append(TOOL_OPTIONS.index("tts"))
398
+ code_exec_block = tools.get("code_exec") or tools.get("codeExec") or {}
399
+ if code_exec_block.get("enabled", True):
400
+ pre_selected.append(TOOL_OPTIONS.index("code_exec"))
401
+ if (config.get("knowledge", {}) or {}).get("enabled", True):
402
+ pre_selected.append(TOOL_OPTIONS.index("knowledge"))
403
+ if (config.get("scheduler", {}) or {}).get("enabled", True):
404
+ pre_selected.append(TOOL_OPTIONS.index("cron"))
405
+ if tools.get("mcp_servers") or tools.get("mcpServers"):
406
+ pre_selected.append(TOOL_OPTIONS.index("mcp"))
407
+ if (config.get("skills", {}) or {}).get("skills_dir") or (config.get("skills", {}) or {}).get("skillsDir"):
408
+ pre_selected.append(TOOL_OPTIONS.index("skills"))
409
+ if (config.get("plugins", {}) or {}).get("enabled", True):
410
+ pre_selected.append(TOOL_OPTIONS.index("plugins"))
411
+ pre_selected = sorted(set(pre_selected))
412
+
413
+ labels = [t(f"tools.{k}") for k in TOOL_OPTIONS]
414
+ selected = prompt_checklist(t("tools.checklist"), labels, pre_selected=pre_selected)
415
+ chosen = {TOOL_OPTIONS[i] for i in selected}
416
+
417
+ if "web" in chosen:
418
+ web = _ensure_dict(tools, "web")
419
+ web["enabled"] = True
420
+ provider_choices = ["brave", "tavily", "serpapi", "searxng"]
421
+ cur_prov = web.get("search_provider") or web.get("searchProvider") or "brave"
422
+ prov_idx = prompt_choice(t("tools.web_provider"), provider_choices,
423
+ default=provider_choices.index(cur_prov) if cur_prov in provider_choices else 0)
424
+ web["search_provider"] = provider_choices[prov_idx]
425
+ existing_key = web.get("search_api_key") or web.get("searchApiKey") or ""
426
+ if existing_key:
427
+ new_key = prompt(f" {t('tools.web_api_key')} [****{t('common.saved')}]", password=True)
428
+ if new_key:
429
+ web["search_api_key"] = new_key
430
+ else:
431
+ new_key = prompt(f" {t('tools.web_api_key')}", password=True)
432
+ if new_key:
433
+ web["search_api_key"] = new_key
434
+ else:
435
+ if "web" in tools and isinstance(tools["web"], dict):
436
+ tools["web"]["enabled"] = False
437
+
438
+ if "image_gen" in chosen:
439
+ ig = _ensure_dict(tools, "image_gen")
440
+ existing = ig.get("api_key") or ig.get("apiKey") or ""
441
+ if existing:
442
+ new_key = prompt(f" {t('tools.image_api_key')} [****{t('common.saved')}]", password=True)
443
+ if new_key:
444
+ ig["api_key"] = new_key
445
+ else:
446
+ new_key = prompt(f" {t('tools.image_api_key')}", password=True)
447
+ if new_key:
448
+ ig["api_key"] = new_key
449
+ ig["model"] = prompt(f" {t('tools.image_model')}", default=ig.get("model", "dall-e-3"))
450
+
451
+ if "tts" in chosen:
452
+ tts = _ensure_dict(tools, "tts")
453
+ backends = ["edge", "openai", "elevenlabs"]
454
+ cur_backend = tts.get("default_backend") or tts.get("defaultBackend") or "edge"
455
+ b_idx = prompt_choice(t("tools.tts_backend"), backends,
456
+ default=backends.index(cur_backend) if cur_backend in backends else 0)
457
+ tts["default_backend"] = backends[b_idx]
458
+ if backends[b_idx] == "openai":
459
+ existing = tts.get("openai_api_key") or tts.get("openaiApiKey") or ""
460
+ if existing:
461
+ new_key = prompt(f" {t('tools.tts_openai_key')} [****{t('common.saved')}]", password=True)
462
+ if new_key:
463
+ tts["openai_api_key"] = new_key
464
+ else:
465
+ tts["openai_api_key"] = prompt(f" {t('tools.tts_openai_key')}", password=True)
466
+
467
+ code_exec = _ensure_dict(tools, "code_exec")
468
+ code_exec["enabled"] = "code_exec" in chosen
469
+ _ensure_dict(config, "knowledge")["enabled"] = "knowledge" in chosen
470
+ _ensure_dict(config, "scheduler")["enabled"] = "cron" in chosen
471
+ _ensure_dict(config, "plugins")["enabled"] = "plugins" in chosen
472
+
473
+ if "mcp" in chosen and not (tools.get("mcp_servers") or tools.get("mcpServers")):
474
+ print_info(t("tools.mcp_skip_hint"))
475
+
476
+ extras_str = ", ".join(t(f"tools.{k}") for k in TOOL_OPTIONS if k in chosen) or t("common.no")
477
+ print_success(t("tools.saved", profile=t(f"tools.profile_{profile_keys[p_idx]}"), extras=extras_str))
478
+
479
+
480
+ # ── Section 7: Messaging Channels ─────────────────────────────────────────────
481
+
482
+ def _setup_weixin_qr(ch: dict) -> None:
483
+ """Run the iLink QR-code login flow for WeChat Personal."""
484
+ import asyncio
485
+
486
+ print_info(t("channels.weixin_qr"))
487
+ print_info(t("channels.weixin_qr_hint"))
488
+ print()
489
+ from echo_agent.channels.weixin import WeixinChannel
490
+ result = asyncio.run(WeixinChannel.qr_login())
491
+ if result:
492
+ ch["account_id"] = result["account_id"]
493
+ ch["token"] = result["token"]
494
+ if result.get("base_url"):
495
+ ch["base_url"] = result["base_url"]
496
+ print_success(t("channels.weixin_ok"))
497
+ else:
498
+ print_error(t("channels.weixin_fail"))
499
+ print_info(t("channels.weixin_retry"))
500
+
501
+
502
+ def setup_channels(config: dict) -> None:
503
+ _print_section_header("channel")
504
+ print_info(t("channels.intro"))
505
+ print()
506
+
507
+ existing = _ensure_dict(config, "channels")
508
+ pre_selected: list[int] = []
509
+ for i, (ch_key, _label, _fields) in enumerate(CHANNEL_DEFS):
510
+ ch_cfg = existing.get(ch_key, {})
511
+ if isinstance(ch_cfg, dict) and ch_cfg.get("enabled"):
512
+ pre_selected.append(i)
513
+
514
+ channel_names = [c[1] for c in CHANNEL_DEFS]
515
+ selected = prompt_checklist(t("channels.checklist"), channel_names, pre_selected=pre_selected or None)
516
+ if not selected:
517
+ print_info(t("channels.no_extra"))
518
+ return
519
+
520
+ for idx in selected:
521
+ ch_key, ch_label, fields = CHANNEL_DEFS[idx]
522
+ print()
523
+ print(color(f" ── {t('channels.config_for', label=ch_label)} ──", Colors.CYAN))
524
+ ch = _ensure_dict(existing, ch_key)
525
+ ch["enabled"] = True
526
+
527
+ if ch_key == "weixin":
528
+ _setup_weixin_qr(ch)
529
+ continue
530
+
531
+ for field_key, field_label in fields:
532
+ secret = any(s in field_key.lower() for s in ("key", "secret", "token", "password"))
533
+ value = prompt(f" {field_label}", default=ch.get(field_key, ""), password=secret)
534
+ if value:
535
+ ch[field_key] = value
536
+
537
+ if ch_key in ("telegram", "discord", "slack", "qqbot", "email", "weixin", "dingtalk"):
538
+ existing_allow = ch.get("allow_from") or ch.get("allowFrom") or []
539
+ allow_default = ",".join(existing_allow) if isinstance(existing_allow, list) else str(existing_allow or "")
540
+ allow_raw = prompt(f" {t('channels.allow_from')}", default=allow_default)
541
+ if allow_raw:
542
+ ch["allow_from"] = [s.strip() for s in allow_raw.split(",") if s.strip()]
543
+ else:
544
+ ch.pop("allow_from", None)
545
+ ch.pop("allowFrom", None)
546
+ print_warning(t("channels.allow_warn"))
547
+
548
+ home_existing = ch.get("home_channel") or ch.get("homeChannel") or ""
549
+ home = prompt(f" {t('channels.home_channel')}", default=home_existing)
550
+ if home:
551
+ ch["home_channel"] = home
552
+
553
+ print_success(t("channels.saved", n=len(selected)))
554
+
555
+
556
+ # ── Section 8: Gateway ────────────────────────────────────────────────────────
557
+
558
+ def setup_gateway(config: dict) -> None:
559
+ _print_section_header("gateway")
560
+ print_info(t("gateway.intro"))
561
+ print()
562
+
563
+ gw = _ensure_dict(config, "gateway")
564
+ gw["enabled"] = prompt_yes_no(t("gateway.enable"), default=bool(gw.get("enabled", False)))
565
+ if not gw["enabled"]:
566
+ return
567
+
568
+ gw["host"] = prompt(f" {t('gateway.host')}", default=str(gw.get("host", "0.0.0.0")))
569
+ port_str = prompt(f" {t('gateway.port')}", default=str(gw.get("port", 9000)))
570
+ try:
571
+ gw["port"] = int(port_str)
572
+ except ValueError:
573
+ gw["port"] = 9000
574
+ print_warning(t("common.invalid"))
575
+
576
+ auth = _ensure_dict(gw, "auth")
577
+ auth_keys = ["open", "allowlist", "pairing"]
578
+ auth_labels = [t(f"gateway.auth_{k}") for k in auth_keys]
579
+ cur_auth = auth.get("mode", "allowlist")
580
+ default_auth = auth_keys.index(cur_auth) if cur_auth in auth_keys else 1
581
+ a_idx = prompt_choice(t("gateway.auth_mode"), auth_labels, default=default_auth)
582
+ auth["mode"] = auth_keys[a_idx]
583
+
584
+ if auth_keys[a_idx] in ("allowlist", "pairing"):
585
+ existing_tokens = auth.get("api_tokens") or auth.get("apiTokens") or []
586
+ token_default = existing_tokens[0] if existing_tokens else ""
587
+ token = prompt(f" {t('gateway.api_token')}", default=token_default, password=True)
588
+ if not token:
589
+ import secrets
590
+ token = secrets.token_urlsafe(32)
591
+ print_info(f" Generated token: {token}")
592
+ auth["api_tokens"] = [token]
593
+
594
+ print_success(t("gateway.saved", host=gw["host"], port=gw["port"], mode=t(f"gateway.auth_{auth_keys[a_idx]}")))
595
+
596
+
597
+ # ── Section 9: Observability ─────────────────────────────────────────────────
598
+
599
+ def setup_observability(config: dict) -> None:
600
+ _print_section_header("observability")
601
+ print_info(t("observability.intro"))
602
+ print()
603
+
604
+ obs = _ensure_dict(config, "observability")
605
+ log_choices = ["INFO", "DEBUG", "WARNING", "ERROR"]
606
+ cur_level = (obs.get("log_level") or obs.get("logLevel") or "INFO").upper()
607
+ default_log = log_choices.index(cur_level) if cur_level in log_choices else 0
608
+ l_idx = prompt_choice(t("observability.log_level"), log_choices, default=default_log)
609
+ obs["log_level"] = log_choices[l_idx]
610
+
611
+ obs["trace_enabled"] = prompt_yes_no(t("observability.trace"), default=bool(obs.get("trace_enabled", True)))
612
+
613
+ otel_on = prompt_yes_no(t("observability.otel"), default=bool(obs.get("otel_enabled", False)))
614
+ obs["otel_enabled"] = otel_on
615
+ if otel_on:
616
+ obs["otel_endpoint"] = prompt(f" {t('observability.otel_endpoint')}",
617
+ default=obs.get("otel_endpoint") or obs.get("otelEndpoint") or "http://localhost:4317")
618
+ obs["otel_service_name"] = prompt(f" {t('observability.otel_service')}",
619
+ default=obs.get("otel_service_name") or obs.get("otelServiceName") or "echo-agent")
620
+
621
+ print_success(t("observability.saved",
622
+ level=log_choices[l_idx],
623
+ otel=t("common.yes") if otel_on else t("common.no")))
624
+
625
+
626
+ # ── Section 10: Self-evolving skill harness ───────────────────────────────────
627
+
628
+ _EVOLUTION_TRIGGER_KEYS: list[str] = ["manual", "threshold", "scheduled"]
629
+
630
+
631
+ def setup_evolution(config: dict) -> None:
632
+ """Configure the self-evolving skill harness.
633
+
634
+ Disabled by default. When enabled, this section also writes
635
+ ``data/eval/baseline.yaml`` (the gating dataset) if it does not already
636
+ exist, since promotion is impossible without it.
637
+ """
638
+ _print_section_header("evolution")
639
+ print_info(t("evolution.intro"))
640
+ print_warning(t("evolution.warning"))
641
+ print()
642
+
643
+ evo = _ensure_dict(config, "evolution")
644
+
645
+ enabled = prompt_yes_no(
646
+ t("evolution.enabled"),
647
+ default=bool(evo.get("enabled", False)),
648
+ )
649
+ evo["enabled"] = enabled
650
+ if not enabled:
651
+ evo["record_trajectories"] = bool(evo.get("record_trajectories", True))
652
+ print_success(t("evolution.saved_disabled"))
653
+ return
654
+
655
+ # Trigger mode
656
+ trigger_labels = [t(f"evolution.trigger_{k}") for k in _EVOLUTION_TRIGGER_KEYS]
657
+ current_trigger = evo.get("trigger_mode") or evo.get("triggerMode") or "manual"
658
+ default_trigger = (
659
+ _EVOLUTION_TRIGGER_KEYS.index(current_trigger)
660
+ if current_trigger in _EVOLUTION_TRIGGER_KEYS
661
+ else 0
662
+ )
663
+ print_info(t("evolution.trigger_hint"))
664
+ t_idx = prompt_choice(t("evolution.trigger"), trigger_labels, default=default_trigger)
665
+ trigger = _EVOLUTION_TRIGGER_KEYS[t_idx]
666
+ evo["trigger_mode"] = trigger
667
+
668
+ if trigger == "threshold":
669
+ cur = int(evo.get("threshold_trajectories") or evo.get("thresholdTrajectories") or 50)
670
+ raw = prompt(f" {t('evolution.threshold')}", default=str(cur))
671
+ try:
672
+ evo["threshold_trajectories"] = max(1, int(raw))
673
+ except ValueError:
674
+ print_warning(t("common.invalid"))
675
+ evo["threshold_trajectories"] = cur
676
+ elif trigger == "scheduled":
677
+ cur = evo.get("cron_expression") or evo.get("cronExpression") or "0 4 * * *"
678
+ raw = prompt(f" {t('evolution.cron')}", default=cur)
679
+ evo["cron_expression"] = raw or cur
680
+
681
+ # Eval dataset path
682
+ cur_dataset = evo.get("eval_dataset_path") or evo.get("evalDatasetPath") or "data/eval/baseline.yaml"
683
+ raw = prompt(f" {t('evolution.dataset_path')}", default=cur_dataset)
684
+ evo["eval_dataset_path"] = raw or cur_dataset
685
+
686
+ # Strict / regression policy
687
+ evo["require_strict_improvement"] = prompt_yes_no(
688
+ t("evolution.strict"),
689
+ default=bool(evo.get("require_strict_improvement", True)),
690
+ )
691
+ cur_thr = float(evo.get("regression_threshold") or evo.get("regressionThreshold") or 0.05)
692
+ print_info(t("evolution.regression_hint"))
693
+ raw = prompt(f" {t('evolution.regression')}", default=f"{cur_thr:.2f}")
694
+ try:
695
+ v = float(raw)
696
+ if 0.0 <= v <= 0.5:
697
+ evo["regression_threshold"] = v
698
+ else:
699
+ print_warning(t("common.invalid"))
700
+ except ValueError:
701
+ print_warning(t("common.invalid"))
702
+
703
+ # Operational knobs
704
+ evo["candidate_review_required"] = prompt_yes_no(
705
+ t("evolution.review_required"),
706
+ default=bool(evo.get("candidate_review_required", False)),
707
+ )
708
+ cur_cand = int(evo.get("max_candidates_per_run") or evo.get("maxCandidatesPerRun") or 3)
709
+ raw = prompt(f" {t('evolution.max_candidates')}", default=str(cur_cand))
710
+ try:
711
+ evo["max_candidates_per_run"] = max(1, int(raw))
712
+ except ValueError:
713
+ evo["max_candidates_per_run"] = cur_cand
714
+
715
+ cur_retain = int(evo.get("trajectory_retention_days") or evo.get("trajectoryRetentionDays") or 30)
716
+ raw = prompt(f" {t('evolution.retention_days')}", default=str(cur_retain))
717
+ try:
718
+ evo["trajectory_retention_days"] = max(0, int(raw))
719
+ except ValueError:
720
+ evo["trajectory_retention_days"] = cur_retain
721
+
722
+ evo["redact_args"] = prompt_yes_no(
723
+ t("evolution.redact_args"),
724
+ default=bool(evo.get("redact_args", True)),
725
+ )
726
+ evo["record_trajectories"] = prompt_yes_no(
727
+ t("evolution.record"),
728
+ default=bool(evo.get("record_trajectories", True)),
729
+ )
730
+
731
+ # Eval execution knobs (used by PromotionGate)
732
+ cur_par = int(evo.get("eval_parallel") or evo.get("evalParallel") or 2)
733
+ raw = prompt(f" {t('evolution.eval_parallel')}", default=str(cur_par))
734
+ try:
735
+ evo["eval_parallel"] = max(1, int(raw))
736
+ except ValueError:
737
+ evo["eval_parallel"] = cur_par
738
+
739
+ cur_to = int(evo.get("eval_timeout_seconds") or evo.get("evalTimeoutSeconds") or 60)
740
+ raw = prompt(f" {t('evolution.eval_timeout')}", default=str(cur_to))
741
+ try:
742
+ evo["eval_timeout_seconds"] = max(1, int(raw))
743
+ except ValueError:
744
+ evo["eval_timeout_seconds"] = cur_to
745
+
746
+ # Ensure the baseline dataset exists — otherwise PromotionGate will
747
+ # reject every candidate. Resolve workspace from config (it may be
748
+ # relative; in that case we anchor at cwd).
749
+ workspace_raw = config.get("workspace") or "~/.echo-agent"
750
+ ws = Path(str(workspace_raw)).expanduser()
751
+ if not ws.is_absolute():
752
+ ws = (Path.cwd() / ws).resolve()
753
+ dataset_path = ws / evo["eval_dataset_path"]
754
+ if not dataset_path.exists():
755
+ try:
756
+ dataset_path.parent.mkdir(parents=True, exist_ok=True)
757
+ from echo_agent.cli.evolution_cmd import _DEFAULT_DATASET
758
+ dataset_path.write_text(_DEFAULT_DATASET, encoding="utf-8")
759
+ print_info(t("evolution.dataset_seeded", path=str(dataset_path)))
760
+ except Exception as e:
761
+ print_warning(t("evolution.dataset_seed_failed", error=str(e)))
762
+
763
+ print_success(t(
764
+ "evolution.saved_enabled",
765
+ trigger=trigger,
766
+ dataset=evo["eval_dataset_path"],
767
+ ))
768
+
769
+
770
+ # ── Section registry ──────────────────────────────────────────────────────────
771
+
772
+ SETUP_SECTIONS: list[tuple[str, Callable[[dict], None]]] = [
773
+ ("language", setup_language),
774
+ ("model", setup_model),
775
+ ("permissions", setup_permissions),
776
+ ("terminal", setup_terminal),
777
+ ("agent", setup_agent),
778
+ ("tools", setup_tools),
779
+ ("channel", setup_channels),
780
+ ("gateway", setup_gateway),
781
+ ("observability", setup_observability),
782
+ ("evolution", setup_evolution),
783
+ ]
784
+
785
+ # Aliases so users can pass any reasonable section name from the CLI.
786
+ SECTION_ALIASES: dict[str, str] = {
787
+ "lang": "language",
788
+ "language": "language",
789
+ "model": "model",
790
+ "provider": "model",
791
+ "permission": "permissions",
792
+ "permissions": "permissions",
793
+ "approval": "permissions",
794
+ "sandbox": "terminal",
795
+ "terminal": "terminal",
796
+ "execution": "terminal",
797
+ "agent": "agent",
798
+ "behavior": "agent",
799
+ "tool": "tools",
800
+ "tools": "tools",
801
+ "channel": "channel",
802
+ "channels": "channel",
803
+ "gateway": "gateway",
804
+ "network": "gateway",
805
+ "observability": "observability",
806
+ "logging": "observability",
807
+ "evolution": "evolution",
808
+ "evolve": "evolution",
809
+ "self-evolve": "evolution",
810
+ "self_evolve": "evolution",
811
+ "doctor": "doctor",
812
+ "check": "doctor",
813
+ }
814
+
815
+ def _capability_check(config: dict) -> list[tuple[str, bool, str]]:
816
+ """Return a list of (label, ok, hint) tuples summarising what's available."""
817
+ checks: list[tuple[str, bool, str]] = []
818
+ providers = (config.get("models", {}) or {}).get("providers", []) or []
819
+ if providers and any(p.get("name") for p in providers):
820
+ checks.append((t("doctor.model_ok"), True, ""))
821
+ else:
822
+ checks.append((t("doctor.model_missing"), False, ""))
823
+
824
+ enabled_channels = [
825
+ k for k, v in (config.get("channels", {}) or {}).items()
826
+ if isinstance(v, dict) and v.get("enabled") and k != "cli"
827
+ ]
828
+ if enabled_channels:
829
+ checks.append((t("doctor.channel_ok", n=len(enabled_channels)), True, ", ".join(enabled_channels)))
830
+ else:
831
+ checks.append((t("doctor.channel_missing"), False, ""))
832
+
833
+ profile = (config.get("tools", {}) or {}).get("profile", "full")
834
+ checks.append((t("doctor.tools_ok", profile=profile), True, ""))
835
+
836
+ perm_mode = ((config.get("permissions", {}) or {}).get("approval", {}) or {}).get("mode", "smart")
837
+ checks.append((t("doctor.perm_ok", mode=perm_mode), True, ""))
838
+
839
+ sandbox = (config.get("execution", {}) or {}).get("default_executor") or \
840
+ (config.get("execution", {}) or {}).get("defaultExecutor") or "sandbox"
841
+ checks.append((t("doctor.sandbox_ok", backend=sandbox), True, ""))
842
+
843
+ gw = config.get("gateway", {}) or {}
844
+ if gw.get("enabled"):
845
+ checks.append((t("doctor.gateway_on", host=gw.get("host", "0.0.0.0"), port=gw.get("port", 9000)), True, ""))
846
+ else:
847
+ checks.append((t("doctor.gateway_off"), False, ""))
848
+
849
+ if (config.get("memory", {}) or {}).get("enabled", True):
850
+ checks.append((t("doctor.memory_ok"), True, ""))
851
+ else:
852
+ checks.append((t("doctor.memory_off"), False, ""))
853
+
854
+ if (config.get("knowledge", {}) or {}).get("enabled", True):
855
+ checks.append((t("doctor.knowledge_ok"), True, ""))
856
+ else:
857
+ checks.append((t("doctor.knowledge_off"), False, ""))
858
+
859
+ mcps = (config.get("tools", {}) or {}).get("mcp_servers") or (config.get("tools", {}) or {}).get("mcpServers") or {}
860
+ if mcps:
861
+ checks.append((t("doctor.mcp_ok", n=len(mcps)), True, ""))
862
+ else:
863
+ checks.append((t("doctor.mcp_off"), False, ""))
864
+
865
+ obs = config.get("observability", {}) or {}
866
+ checks.append((t("doctor.obs_ok", level=(obs.get("log_level") or obs.get("logLevel") or "INFO")), True, ""))
867
+ if obs.get("otel_enabled"):
868
+ endpoint = obs.get("otel_endpoint") or obs.get("otelEndpoint") or "?"
869
+ checks.append((t("doctor.otel_on", endpoint=endpoint), True, ""))
870
+ else:
871
+ checks.append((t("doctor.otel_off"), False, ""))
872
+
873
+ return checks
874
+
875
+
876
+ def setup_doctor(config: dict) -> None:
877
+ _print_section_header("doctor")
878
+ print_info(t("doctor.intro"))
879
+ print()
880
+ checks = _capability_check(config)
881
+ ok_count = sum(1 for _, ok, _ in checks if ok)
882
+ print_info(t("doctor.summary_count", ok=ok_count, total=len(checks)))
883
+ print()
884
+ for label, ok, extra in checks:
885
+ mark = color("✓", Colors.GREEN) if ok else color("✗", Colors.RED)
886
+ line = f" {mark} {label}"
887
+ if extra:
888
+ line += color(f" ({extra})", Colors.DIM)
889
+ print(line)
890
+ print()
891
+
892
+
893
+ # ── Summary ───────────────────────────────────────────────────────────────────
894
+
895
+ def _print_summary(config: dict, config_path: Path) -> None:
896
+ print()
897
+ print(color(f" ◆ {t('summary.header')}", Colors.CYAN, Colors.BOLD))
898
+ models = config.get("models", {}) or {}
899
+ providers = models.get("providers", []) or []
900
+ if providers:
901
+ print_info(f" {t('summary.provider')}: {providers[0].get('name', '?')}")
902
+ print_info(f" {t('summary.model')}: {models.get('defaultModel') or models.get('default_model') or '-'}")
903
+ enabled = [k for k, v in (config.get("channels", {}) or {}).items()
904
+ if isinstance(v, dict) and v.get("enabled") and k != "cli"]
905
+ if enabled:
906
+ print_info(f" {t('summary.channels')}: {t('summary.channels_cli_plus', names=', '.join(enabled))}")
907
+ else:
908
+ print_info(f" {t('summary.channels')}: {t('summary.channels_cli_only')}")
909
+ print_info(f" {t('summary.config_file')}: {config_path}")
910
+ print()
911
+ print(color(f" {t('summary.next_steps')}", Colors.CYAN))
912
+ print(color(t("summary.next_run"), Colors.GREEN))
913
+ print(color(t("summary.next_setup"), Colors.GREEN))
914
+ print(color(t("summary.next_status"), Colors.GREEN))
915
+ print()
916
+
917
+
918
+ # ── Headless / non-interactive guidance ───────────────────────────────────────
919
+
920
+ def _print_headless_guidance() -> None:
921
+ print()
922
+ print(color(f" ◆ {t('headless.guide_title')}", Colors.CYAN, Colors.BOLD))
923
+ print()
924
+ print_warning(t("headless.warning"))
925
+ print_info(t("headless.guide_intro"))
926
+ print_info(t("headless.guide_yaml"))
927
+ print_info(t("headless.guide_env"))
928
+ print_info(t("headless.guide_docs"))
929
+ print()
930
+ print_info(t("headless.guide_retry"))
931
+
932
+
933
+ # ── Main entry points ─────────────────────────────────────────────────────────
934
+
935
+ def _setup_config_target(config_path: str | Path | None = None, workspace: str | Path | None = None) -> Path | None:
936
+ if config_path:
937
+ return Path(config_path).expanduser()
938
+ if workspace:
939
+ ws = Path(workspace).expanduser()
940
+ return find_local_config_file(ws) or ws / "echo-agent.yaml"
941
+ return resolve_config_file() or default_config_path()
942
+
943
+
944
+ def _load_existing_config(config_path: str | Path | None, workspace: str | Path | None) -> tuple[dict, Path | None]:
945
+ existing_file = resolve_config_file(config_path=config_path, search_dir=workspace)
946
+ config: dict[str, Any] = {}
947
+ if existing_file and existing_file.exists():
948
+ import yaml
949
+ with open(existing_file, encoding="utf-8") as f:
950
+ config = yaml.safe_load(f) or {}
951
+ return config, existing_file
952
+
953
+
954
+ def _resolve_initial_locale(config: dict, lang_override: str | None) -> str:
955
+ """Pick the active locale, preferring CLI flag > saved pref > OS detection."""
956
+ if lang_override:
957
+ chosen = set_locale(lang_override)
958
+ return chosen
959
+ saved = (config.get("ui", {}) or {}).get("locale")
960
+ if saved and saved != "auto":
961
+ return set_locale(saved)
962
+ return set_locale(detect_locale(saved))
963
+
964
+
965
+ def run_setup_wizard(
966
+ section: str | None = None,
967
+ config_path: str | Path | None = None,
968
+ workspace: str | Path | None = None,
969
+ lang: str | None = None,
970
+ flow: str | None = None,
971
+ ) -> None:
972
+ """Run the interactive setup wizard.
973
+
974
+ ``section``: name (or alias) of a single section to configure.
975
+ ``flow``: ``"quickstart"`` or ``"full"``; bypasses the menu.
976
+ ``lang``: ``"zh"`` or ``"en"``; overrides auto-detection.
977
+ """
978
+ if not is_interactive():
979
+ # Pick a locale even in headless mode so the guidance prints in the
980
+ # user's language.
981
+ config, _ = _load_existing_config(config_path, workspace)
982
+ _resolve_initial_locale(config, lang)
983
+ _print_headless_guidance()
984
+ return
985
+
986
+ config_target = _setup_config_target(config_path=config_path, workspace=workspace)
987
+ config, existing_file = _load_existing_config(config_path, workspace)
988
+
989
+ if workspace and (not config_target or not config_target.exists()):
990
+ config["workspace"] = "."
991
+ elif workspace and "workspace" not in config:
992
+ config["workspace"] = "."
993
+
994
+ _resolve_initial_locale(config, lang)
995
+ config.setdefault("ui", {})["locale"] = get_locale()
996
+
997
+ section_map = {key: func for key, func in SETUP_SECTIONS}
998
+ if section:
999
+ canonical = SECTION_ALIASES.get(section.lower(), section.lower())
1000
+ if canonical == "doctor":
1001
+ _print_banner()
1002
+ setup_doctor(config)
1003
+ return
1004
+ func = section_map.get(canonical)
1005
+ if func is None:
1006
+ print_error(f"Unknown section: {section}")
1007
+ print_info("Available: " + ", ".join(k for k, _ in SETUP_SECTIONS) + ", doctor")
1008
+ return
1009
+ _print_banner()
1010
+ func(config)
1011
+ path = save_config(config, config_target)
1012
+ label = t(f"section.{canonical}")
1013
+ print_success(t("summary.section_saved", label=label, path=path))
1014
+ return
1015
+
1016
+ _print_banner()
1017
+
1018
+ is_existing = bool(existing_file and (config.get("models", {}) or {}).get("providers"))
1019
+
1020
+ if flow is None and is_existing:
1021
+ print()
1022
+ print_success(t("menu.existing_detected"))
1023
+ menu = [
1024
+ t("menu.existing_quick"),
1025
+ t("menu.existing_full"),
1026
+ *[t("menu.existing_section", label=t(f"section.{key}")) for key, _ in SETUP_SECTIONS],
1027
+ t("section.doctor"),
1028
+ t("menu.exit"),
1029
+ ]
1030
+ choice = prompt_choice(t("menu.existing_what"), menu)
1031
+ if choice == len(menu) - 1:
1032
+ print_info(t("menu.exit_hint"))
1033
+ return
1034
+ if choice == 0:
1035
+ flow = "quickstart"
1036
+ elif choice == 1:
1037
+ flow = "full"
1038
+ elif choice == len(menu) - 2:
1039
+ setup_doctor(config)
1040
+ return
1041
+ else:
1042
+ section_idx = choice - 2
1043
+ key, func = SETUP_SECTIONS[section_idx]
1044
+ func(config)
1045
+ path = save_config(config, config_target)
1046
+ print_success(t("summary.section_saved", label=t(f"section.{key}"), path=path))
1047
+ return
1048
+
1049
+ if flow is None:
1050
+ idx = prompt_choice(t("menu.first_run"), [
1051
+ t("menu.first_run_quick"),
1052
+ t("menu.first_run_full"),
1053
+ ])
1054
+ flow = "quickstart" if idx == 0 else "full"
1055
+
1056
+ if flow == "quickstart":
1057
+ setup_model(config)
1058
+ print()
1059
+ if prompt_yes_no(t("channels.configure_now"), default=False):
1060
+ setup_channels(config)
1061
+ path = save_config(config, config_target)
1062
+ setup_doctor(config)
1063
+ _print_summary(config, path)
1064
+ print_success(t("summary.complete"))
1065
+ return
1066
+
1067
+ for _key, func in SETUP_SECTIONS:
1068
+ func(config)
1069
+
1070
+ path = save_config(config, config_target)
1071
+ setup_doctor(config)
1072
+ _print_summary(config, path)
1073
+ print_success(t("summary.complete"))
1074
+
1075
+
1076
+ def has_any_provider_configured(config_path: str | Path | None = None, workspace: str | Path | None = None) -> bool:
1077
+ config_file = resolve_config_file(config_path=config_path, search_dir=workspace)
1078
+ if not config_file or not config_file.exists():
1079
+ return False
1080
+ import yaml
1081
+ with open(config_file, encoding="utf-8") as f:
1082
+ data = yaml.safe_load(f) or {}
1083
+ providers = (data.get("models", {}) or {}).get("providers", []) or []
1084
+ return bool(providers)
1085
+
1086
+
1087
+ def prompt_first_run_setup(
1088
+ config_path: str | Path | None = None,
1089
+ workspace: str | Path | None = None,
1090
+ lang: str | None = None,
1091
+ ) -> bool:
1092
+ if not is_interactive():
1093
+ return False
1094
+ if has_any_provider_configured(config_path=config_path, workspace=workspace):
1095
+ return False
1096
+ config_file = _setup_config_target(config_path=config_path, workspace=workspace)
1097
+ if config_file and config_file.exists():
1098
+ return False
1099
+
1100
+ config, _ = _load_existing_config(config_path, workspace)
1101
+ _resolve_initial_locale(config, lang)
1102
+
1103
+ print()
1104
+ print_warning(t("summary.first_run_no_config"))
1105
+ if prompt_yes_no(t("summary.first_run_prompt"), default=True):
1106
+ run_setup_wizard(config_path=config_path, workspace=workspace, lang=lang)
1107
+ return True
1108
+ print_info(t("summary.first_run_skip_msg"))
1109
+ print_info(t("summary.first_run_skip_hint"))
1110
+ return False
1111
+