netizen-cli 0.10.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 (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,124 @@
1
+ """Bounded, credential-filtered explanations for user-visible failures.
2
+
3
+ Only explicit exception causes and public native error fields are projected.
4
+ RPC data, native additional details, traceback and arbitrary object reprs are
5
+ not user-facing error messages.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from enum import Enum
12
+
13
+ from openai_codex.errors import JsonRpcError, TransportClosedError
14
+ from openai_codex.types import TurnError
15
+ from pydantic import ValidationError
16
+
17
+ from .turn_activity import sanitize_activity_operation_text
18
+
19
+
20
+ _CODE = re.compile(r"[A-Za-z][A-Za-z0-9_.:/-]{0,95}\Z")
21
+ _NATIVE_VARIANTS = (
22
+ "http_connection_failed",
23
+ "response_stream_connection_failed",
24
+ "response_stream_disconnected",
25
+ "response_too_many_failed_attempts",
26
+ "active_turn_not_steerable",
27
+ )
28
+
29
+
30
+ def _safe_text(value: str, *, limit: int) -> str:
31
+ # A serialized payload or traceback is not a public explanation. Avoid
32
+ # accidentally exposing one through Exception.__str__ or TurnError.message.
33
+ if value != "[敏感内容已隐藏]" and value.lstrip().startswith(("{", "[", "Traceback (")):
34
+ return "错误详情不是可显示的文字说明"
35
+ return sanitize_activity_operation_text(value, limit=limit) or "未提供错误说明"
36
+
37
+
38
+ def _describe_one(error: BaseException, *, limit: int) -> str:
39
+ if isinstance(error, ValidationError):
40
+ # Pydantic's text and even error locations/types may contain input data
41
+ # or custom-validator details; only the validation count is safe here.
42
+ return (
43
+ f"ValidationError:数据格式不符合预期({error.error_count()} 处校验错误)"
44
+ )
45
+ if isinstance(error, JsonRpcError):
46
+ detail = _safe_text(error.message, limit=limit)
47
+ return f"{type(error).__name__}(code={error.code}):{detail}"
48
+ if isinstance(error, TimeoutError):
49
+ fallback = "请求超时,未收到确认结果"
50
+ elif isinstance(error, (ConnectionError, TransportClosedError)):
51
+ fallback = "与后端的连接中断,未收到确认结果"
52
+ else:
53
+ fallback = "未提供错误说明"
54
+ if isinstance(error, OSError) and isinstance(error.strerror, str):
55
+ message = error.strerror
56
+ elif not error.args or (len(error.args) == 1 and isinstance(error.args[0], str)):
57
+ message = str(error).strip()
58
+ else:
59
+ message = ""
60
+ detail = _safe_text(message, limit=limit) if message else fallback
61
+ # Domain exceptions already carry actionable wording. RuntimeError is also
62
+ # used to carry the native Turn's public explanation.
63
+ if message and (
64
+ type(error) is RuntimeError or type(error).__module__.startswith("netizen_cli.")
65
+ ):
66
+ return detail
67
+ return f"{type(error).__name__}:{detail}"
68
+
69
+
70
+ def describe_error(error: BaseException, *, limit: int = 500) -> str:
71
+ """Keep operation context and its explicit underlying cause within a bound."""
72
+
73
+ if limit < 1:
74
+ return ""
75
+ chain: list[BaseException] = []
76
+ seen: set[int] = set()
77
+ current: BaseException | None = error
78
+ while current is not None and id(current) not in seen and len(chain) < 8:
79
+ seen.add(id(current))
80
+ chain.append(current)
81
+ current = current.__cause__
82
+ outer = _describe_one(chain[0], limit=limit)
83
+ if len(chain) == 1:
84
+ return _bounded(outer, limit)
85
+ underlying = _describe_one(chain[-1], limit=limit)
86
+ if underlying == outer:
87
+ return _bounded(underlying, limit)
88
+ # Reserve space for the underlying reason, even when a wrapper is verbose.
89
+ context = _bounded(outer, min(180, max(1, limit // 3)))
90
+ return _bounded(f"{context};原因:{underlying}", limit)
91
+
92
+
93
+ def _bounded(value: str, limit: int) -> str:
94
+ return value if len(value) <= limit else value[: limit - 1].rstrip() + "…"
95
+
96
+
97
+ def _native_code(info: object) -> str | None:
98
+ root = getattr(info, "root", info)
99
+ value = root.value if isinstance(root, Enum) else getattr(root, "type", root)
100
+ if isinstance(value, str) and _CODE.fullmatch(value):
101
+ return value
102
+ for name in _NATIVE_VARIANTS:
103
+ variant = getattr(root, name, None)
104
+ if variant is not None:
105
+ code = name.split("_")[0] + "".join(
106
+ part.title() for part in name.split("_")[1:]
107
+ )
108
+ status = getattr(variant, "http_status_code", None)
109
+ if type(status) is int and 100 <= status <= 599:
110
+ return f"{code}, HTTP {status}"
111
+ return code
112
+ return None
113
+
114
+
115
+ def native_turn_failure(error: object) -> RuntimeError:
116
+ """Project only the SDK TurnError's public explanation and typed error code."""
117
+
118
+ if not isinstance(error, TurnError):
119
+ return RuntimeError("Codex 本轮执行失败,未提供错误说明")
120
+ message = _safe_text(error.message, limit=400)
121
+ code = _native_code(error.codex_error_info)
122
+ if code is not None:
123
+ return RuntimeError(f"Codex 错误码 {code}:{message}")
124
+ return RuntimeError(message)
@@ -0,0 +1,531 @@
1
+ """Map Feishu text into model-visible prompts or client-only controls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from collections.abc import Collection
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+
10
+ from .domain import (
11
+ ChannelInteraction,
12
+ ControlIntent,
13
+ ControlName,
14
+ FeishuScope,
15
+ NativeCapability,
16
+ PromptInput,
17
+ )
18
+ from .skill_references import InvalidSkillReference, parse_skill_references
19
+
20
+
21
+ class InvalidInteraction(ValueError):
22
+ pass
23
+
24
+
25
+ class CommandOwner(str, Enum):
26
+ CHANNEL = "channel"
27
+ NATIVE_THREAD = "native-thread"
28
+ HYBRID = "hybrid"
29
+ HOST = "host"
30
+
31
+
32
+ class CommandGroup(str, Enum):
33
+ START = "开始与设置"
34
+ TASK = "任务操作"
35
+ SESSION = "会话管理"
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class CommandSpec:
40
+ name: str
41
+ intent: ControlName | None
42
+ owner: CommandOwner
43
+ usage: str
44
+ summary: str
45
+ aliases: tuple[str, ...] = ()
46
+ requires: NativeCapability | None = None
47
+ unavailable_reason: str | None = None
48
+ group: CommandGroup = CommandGroup.TASK
49
+
50
+
51
+ COMMAND_SPECS = (
52
+ CommandSpec(
53
+ "new",
54
+ ControlName.NEW,
55
+ CommandOwner.HYBRID,
56
+ "/new",
57
+ "选择项目并创建会话;创建后发送任务即可开始",
58
+ group=CommandGroup.START,
59
+ ),
60
+ CommandSpec(
61
+ "config",
62
+ ControlName.CONFIG,
63
+ CommandOwner.NATIVE_THREAD,
64
+ "/config",
65
+ "调整当前会话的模型、思考强度、速度、任务反馈和群聊消息范围,后续新任务生效",
66
+ group=CommandGroup.START,
67
+ ),
68
+ CommandSpec(
69
+ "settings",
70
+ ControlName.SETTINGS,
71
+ CommandOwner.CHANNEL,
72
+ "/settings",
73
+ "添加、启用和管理项目(任务使用的工作目录)",
74
+ group=CommandGroup.START,
75
+ ),
76
+ CommandSpec(
77
+ "defaults",
78
+ ControlName.DEFAULTS,
79
+ CommandOwner.CHANNEL,
80
+ "/defaults",
81
+ "查看和设置当前聊天的默认会话配置;无当前会话时自动创建",
82
+ group=CommandGroup.START,
83
+ ),
84
+ CommandSpec(
85
+ "admin",
86
+ ControlName.ADMIN,
87
+ CommandOwner.CHANNEL,
88
+ "/admin",
89
+ "查看当前实例的管理地址和根目录;无需创建会话",
90
+ group=CommandGroup.START,
91
+ ),
92
+ CommandSpec(
93
+ "help",
94
+ ControlName.HELP,
95
+ CommandOwner.CHANNEL,
96
+ "/help",
97
+ "显示本帮助",
98
+ group=CommandGroup.START,
99
+ ),
100
+ CommandSpec(
101
+ "status",
102
+ ControlName.STATUS,
103
+ CommandOwner.HYBRID,
104
+ "/status",
105
+ "查看当前项目、Git 分支、会话、任务状态和上下文用量",
106
+ ),
107
+ CommandSpec(
108
+ "stop",
109
+ ControlName.STOP,
110
+ CommandOwner.HYBRID,
111
+ "/stop",
112
+ "中断当前任务,并请求清理已登记的后台终端;不保证前台工具进程退出",
113
+ ),
114
+ CommandSpec(
115
+ "side",
116
+ ControlName.SIDE,
117
+ CommandOwner.HYBRID,
118
+ "/side [首轮问题]",
119
+ "从当前会话另开临时分支话题;话题内用 `/side close` 结束",
120
+ requires=NativeCapability.SIDE,
121
+ unavailable_reason=(
122
+ "当前 SDK/App Server 的 Side Thread 兼容契约未通过"
123
+ ),
124
+ ),
125
+ CommandSpec(
126
+ "goal",
127
+ ControlName.GOAL,
128
+ CommandOwner.NATIVE_THREAD,
129
+ "/goal [目标|pause|resume|clear]",
130
+ "不带参数查看当前目标;输入目标启动持续执行,或用子命令暂停、恢复、清除",
131
+ requires=NativeCapability.GOAL,
132
+ unavailable_reason=(
133
+ "当前 SDK/App Server 的 Goal 兼容契约未通过"
134
+ ),
135
+ ),
136
+ CommandSpec(
137
+ "cron",
138
+ ControlName.CRON,
139
+ CommandOwner.CHANNEL,
140
+ "/cron",
141
+ "管理定时任务:新建、修改、启停、删除和最近执行",
142
+ ),
143
+ CommandSpec(
144
+ "sessions",
145
+ ControlName.SESSIONS,
146
+ CommandOwner.HYBRID,
147
+ "/sessions [archived]",
148
+ "列出当前聊天或话题的普通会话;加 `archived` 查看已归档会话",
149
+ aliases=("threads",),
150
+ group=CommandGroup.SESSION,
151
+ ),
152
+ CommandSpec(
153
+ "resume",
154
+ ControlName.RESUME,
155
+ CommandOwner.HYBRID,
156
+ "/resume <会话短 ID>",
157
+ "切换到已有会话;短 ID 可在 `/sessions` 查看",
158
+ group=CommandGroup.SESSION,
159
+ ),
160
+ CommandSpec(
161
+ "rename",
162
+ ControlName.RENAME,
163
+ CommandOwner.NATIVE_THREAD,
164
+ "/rename [名称]",
165
+ "重命名当前会话",
166
+ group=CommandGroup.SESSION,
167
+ ),
168
+ CommandSpec(
169
+ "compact",
170
+ ControlName.COMPACT,
171
+ CommandOwner.NATIVE_THREAD,
172
+ "/compact",
173
+ "压缩当前空闲会话的上下文",
174
+ group=CommandGroup.SESSION,
175
+ ),
176
+ CommandSpec(
177
+ "release",
178
+ ControlName.RELEASE,
179
+ CommandOwner.NATIVE_THREAD,
180
+ "/release",
181
+ "释放当前空闲会话的连接;会话和历史保留,之后仍可继续",
182
+ requires=NativeCapability.RELEASE,
183
+ unavailable_reason="当前 SDK/App Server 的 Thread 订阅释放契约未通过",
184
+ group=CommandGroup.SESSION,
185
+ ),
186
+ CommandSpec(
187
+ "archive",
188
+ ControlName.ARCHIVE,
189
+ CommandOwner.HYBRID,
190
+ "/archive",
191
+ "归档当前会话;归档后可以恢复",
192
+ group=CommandGroup.SESSION,
193
+ ),
194
+ CommandSpec(
195
+ "unarchive",
196
+ ControlName.UNARCHIVE,
197
+ CommandOwner.HYBRID,
198
+ "/unarchive <会话短 ID>",
199
+ "恢复已归档会话并切换到它",
200
+ group=CommandGroup.SESSION,
201
+ ),
202
+ CommandSpec(
203
+ "delete",
204
+ ControlName.DELETE,
205
+ CommandOwner.HYBRID,
206
+ "/delete",
207
+ "永久删除当前会话及其原生历史",
208
+ group=CommandGroup.SESSION,
209
+ ),
210
+ CommandSpec(
211
+ "plan",
212
+ None,
213
+ CommandOwner.NATIVE_THREAD,
214
+ "/plan [prompt]",
215
+ "切换原生 Codex Plan 模式",
216
+ unavailable_reason=(
217
+ "当前锁定的 openai-codex 高层 SDK 缺少 collaboration mode / plan 控制"
218
+ ),
219
+ ),
220
+ CommandSpec(
221
+ "apps",
222
+ None,
223
+ CommandOwner.NATIVE_THREAD,
224
+ "/apps",
225
+ "发现并选择原生 Codex App",
226
+ unavailable_reason=(
227
+ "当前锁定的 openai-codex 高层 SDK 缺少 Apps discovery 的公开能力"
228
+ ),
229
+ ),
230
+ CommandSpec(
231
+ "copy",
232
+ None,
233
+ CommandOwner.HOST,
234
+ "/copy",
235
+ "复制宿主界面的最新输出",
236
+ unavailable_reason="这是 Codex CLI/App 宿主界面命令,飞书中不适用",
237
+ ),
238
+ CommandSpec(
239
+ "vim",
240
+ None,
241
+ CommandOwner.HOST,
242
+ "/vim",
243
+ "切换 CLI 输入模式",
244
+ unavailable_reason="这是 Codex CLI 宿主界面命令,飞书中不适用",
245
+ ),
246
+ CommandSpec(
247
+ "theme",
248
+ None,
249
+ CommandOwner.HOST,
250
+ "/theme",
251
+ "设置宿主界面主题",
252
+ unavailable_reason="这是 Codex CLI/App 宿主界面命令,飞书中不适用",
253
+ ),
254
+ CommandSpec(
255
+ "exit",
256
+ None,
257
+ CommandOwner.HOST,
258
+ "/exit",
259
+ "退出宿主应用",
260
+ aliases=("quit",),
261
+ unavailable_reason="这是 Codex CLI/App 宿主生命周期命令,飞书中不适用",
262
+ ),
263
+ )
264
+
265
+
266
+ _COMMANDS: dict[str, CommandSpec] = {
267
+ token: spec
268
+ for spec in COMMAND_SPECS
269
+ for token in (spec.name, *spec.aliases)
270
+ }
271
+
272
+ _CONFIG_ALIASES = frozenset({"model", "effort", "fast"})
273
+
274
+
275
+ def _command_error(
276
+ reason: str,
277
+ *,
278
+ spec: CommandSpec | None = None,
279
+ ) -> InvalidInteraction:
280
+ parts = [reason]
281
+ if spec is not None:
282
+ parts.append(f"用法:{spec.usage}。")
283
+ parts.append("发送 /help 查看快速开始和可用命令。")
284
+ return InvalidInteraction(" ".join(parts))
285
+
286
+
287
+ def parse_message(
288
+ *,
289
+ scope: FeishuScope,
290
+ message_id: str,
291
+ sender_id: str,
292
+ text: str,
293
+ available_capabilities: Collection[NativeCapability] = (),
294
+ ) -> ChannelInteraction:
295
+ capabilities = frozenset(available_capabilities)
296
+ body = text.strip()
297
+ if not body:
298
+ raise _command_error("消息内容为空。")
299
+ if body.startswith("//"):
300
+ return PromptInput(scope, message_id, sender_id, body[1:])
301
+ if not body.startswith("/"):
302
+ try:
303
+ skill_names = parse_skill_references(body)
304
+ except InvalidSkillReference as error:
305
+ raise InvalidInteraction(str(error)) from error
306
+ if skill_names and NativeCapability.SKILLS not in capabilities:
307
+ raise InvalidInteraction(
308
+ "当前原生 Skills discovery 不可用,$skill 引用未执行。"
309
+ )
310
+ return PromptInput(scope, message_id, sender_id, body, skill_names)
311
+ if body == "/":
312
+ return ControlIntent(scope, message_id, sender_id, ControlName.MENU)
313
+
314
+ raw_parts = body[1:].lstrip().split(maxsplit=1)
315
+ raw_spec = _COMMANDS.get(raw_parts[0].lower()) if raw_parts else None
316
+ if (
317
+ raw_spec is not None
318
+ and raw_spec.intent is ControlName.NEW
319
+ and len(raw_parts) == 2
320
+ ):
321
+ # `/new` is deliberately card-only. Reject the raw tail before shlex
322
+ # so quoted and even unterminated-quote variants all receive the same
323
+ # migration result and can never reach a mutating ControlIntent.
324
+ raise InvalidInteraction(
325
+ "快捷创建已下线,请发送 /new 并在卡片中选择。"
326
+ )
327
+ if raw_spec is not None and raw_spec.intent in {
328
+ ControlName.GOAL,
329
+ ControlName.SIDE,
330
+ }:
331
+ # Goal objectives and Side first prompts are free-form text, so quotes
332
+ # in the tail are data;
333
+ # only the command head participates in command parsing.
334
+ tokens = [raw_parts[0]]
335
+ else:
336
+ try:
337
+ tokens = shlex.split(body[1:])
338
+ except ValueError as error:
339
+ raise _command_error(
340
+ "命令格式错误:请补全成对引号,并检查末尾的反斜杠。",
341
+ spec=raw_spec,
342
+ ) from error
343
+ if not tokens:
344
+ return ControlIntent(scope, message_id, sender_id, ControlName.MENU)
345
+ spec = _COMMANDS.get(tokens[0].lower())
346
+ if spec is None:
347
+ unavailable = tokens[0].lower()
348
+ if unavailable in {"project", "projects"}:
349
+ raise _command_error(
350
+ "项目通过 /settings 添加或启用;准备好项目后,发送 /new 创建会话。"
351
+ "本条消息未执行。"
352
+ )
353
+ if unavailable in _CONFIG_ALIASES:
354
+ raise _command_error(
355
+ "Model / Effort / Speed 不提供独立命令,请统一使用 /config。"
356
+ )
357
+ raise _command_error(f"未知命令:/{tokens[0]},本条消息未执行。")
358
+ if spec.intent is None:
359
+ assert spec.unavailable_reason is not None
360
+ raise InvalidInteraction(
361
+ f"/{spec.name} 尚未开放:{spec.unavailable_reason},本条消息未执行。"
362
+ )
363
+ if spec.requires is not None and spec.requires not in capabilities:
364
+ assert spec.unavailable_reason is not None
365
+ raise InvalidInteraction(
366
+ f"/{spec.name} 尚未开放:{spec.unavailable_reason},本条消息未执行。"
367
+ )
368
+ name = spec.intent
369
+ assert name is not None
370
+ arguments = tuple(tokens[1:])
371
+ if name in {ControlName.GOAL, ControlName.SIDE}:
372
+ # Goal objective and Side first prompt are free-form user text, not a
373
+ # shell argv. Preserve the
374
+ # tail (including internal whitespace and quoting) instead of rebuilding
375
+ # it from shlex tokens. Control words remain ordinary one-word tails.
376
+ arguments = (raw_parts[1],) if len(raw_parts) == 2 else ()
377
+ elif name is ControlName.RENAME and len(tokens) > 1:
378
+ arguments = (" ".join(tokens[1:]),)
379
+ try:
380
+ _validate_arguments(name, arguments)
381
+ except InvalidInteraction as error:
382
+ if name is ControlName.NEW:
383
+ raise
384
+ raise _command_error(str(error), spec=spec) from error
385
+ return ControlIntent(scope, message_id, sender_id, name, arguments)
386
+
387
+
388
+ def _validate_arguments(name: ControlName, arguments: tuple[str, ...]) -> None:
389
+ expected = {
390
+ ControlName.MENU: 0,
391
+ ControlName.NEW: 0,
392
+ ControlName.SIDE: None,
393
+ ControlName.CONFIG: 0,
394
+ ControlName.DEFAULTS: 0,
395
+ ControlName.COMPACT: 0,
396
+ ControlName.SETTINGS: 0,
397
+ ControlName.CRON: 0,
398
+ ControlName.SESSIONS: None,
399
+ ControlName.RESUME: 1,
400
+ ControlName.RENAME: None,
401
+ ControlName.ARCHIVE: 0,
402
+ ControlName.DELETE: 0,
403
+ ControlName.UNARCHIVE: 1,
404
+ ControlName.STOP: 0,
405
+ ControlName.RELEASE: 0,
406
+ ControlName.STATUS: 0,
407
+ ControlName.ADMIN: 0,
408
+ ControlName.GOAL: None,
409
+ ControlName.HELP: 0,
410
+ }[name]
411
+ if name is ControlName.SIDE and len(arguments) in {0, 1}:
412
+ if arguments:
413
+ value = arguments[0].strip()
414
+ if not value:
415
+ raise InvalidInteraction("Side 首轮问题不能为空。")
416
+ if len(value) > 4_000:
417
+ raise InvalidInteraction("Side 首轮问题不能超过 4000 个字符。")
418
+ return
419
+ if name is ControlName.SESSIONS and (
420
+ not arguments
421
+ or (
422
+ len(arguments) == 1
423
+ and arguments[0].lower() == "archived"
424
+ )
425
+ ):
426
+ return
427
+ if name is ControlName.RENAME and len(arguments) in {0, 1}:
428
+ if arguments:
429
+ value = arguments[0].strip()
430
+ if not value:
431
+ raise InvalidInteraction("会话名称不能为空。")
432
+ if len(value) > 120:
433
+ raise InvalidInteraction("会话名称不能超过 120 个字符。")
434
+ return
435
+ if name is ControlName.GOAL and len(arguments) in {0, 1}:
436
+ if arguments:
437
+ value = arguments[0].strip()
438
+ if not value:
439
+ raise InvalidInteraction("目标内容不能为空。")
440
+ if len(value) > 4_000:
441
+ raise InvalidInteraction("Goal objective 不能超过 4000 个字符。")
442
+ try:
443
+ skill_names = parse_skill_references(value)
444
+ except InvalidSkillReference as error:
445
+ raise InvalidInteraction(str(error)) from error
446
+ if skill_names:
447
+ raise InvalidInteraction(
448
+ "当前尚未验证 Goal objective 中的 $skill 语义;"
449
+ "请先用普通消息调用 Skill。"
450
+ )
451
+ return
452
+ if expected is not None and len(arguments) == expected:
453
+ return
454
+ if name is ControlName.NEW:
455
+ raise InvalidInteraction(
456
+ "快捷创建已下线,请发送 /new 并在卡片中选择。"
457
+ )
458
+ if expected == 0:
459
+ raise InvalidInteraction(f"/{name.value} 不接受参数。")
460
+ raise InvalidInteraction("命令参数不正确。")
461
+
462
+
463
+ def command_help(
464
+ available_capabilities: Collection[NativeCapability] = (),
465
+ ) -> str:
466
+ capabilities = frozenset(available_capabilities)
467
+ available = tuple(
468
+ spec
469
+ for spec in COMMAND_SPECS
470
+ if spec.intent is not None
471
+ and (spec.requires is None or spec.requires in capabilities)
472
+ )
473
+ lines = [
474
+ "### 快速开始",
475
+ "",
476
+ "1. **创建会话**:发送 `/new`,在卡片中选择项目并创建会话。",
477
+ "2. **发送任务**:创建成功后,直接发送任务,例如:介绍一下这个项目。",
478
+ "",
479
+ "**没有可选项目?** 先用 `/settings` 添加或启用项目(任务使用的工作目录)。",
480
+ "",
481
+ "**已有会话?** 发送 `/sessions` 查看并切换,继续之前的工作。",
482
+ ]
483
+ for group in CommandGroup:
484
+ entries = tuple(spec for spec in available if spec.group is group)
485
+ if entries:
486
+ lines.extend(("", "---", "", f"### {group.value}", ""))
487
+ lines.extend(f"- `{spec.usage}` — {spec.summary}" for spec in entries)
488
+ lines.extend(
489
+ (
490
+ "",
491
+ "---",
492
+ "",
493
+ "### 使用提示",
494
+ "",
495
+ "- **群聊**:群主线和群话题中的每条消息都需要 @机器人;单聊及单聊话题无需 @。",
496
+ "- **补充任务**:普通任务执行中继续发消息,会补充到当前任务,不会排队成下一轮。",
497
+ "- **发送斜杠**:用 `//` 开头可把首个 `/` 作为普通消息发送。",
498
+ "- **发送图片**:普通图片和富文本图片可直接发送,也可随逐条引用一起交给 Codex。",
499
+ )
500
+ )
501
+ return "\n".join(lines)
502
+
503
+
504
+ def side_command_help(*, requires_mention: bool) -> str:
505
+ lines = [
506
+ "### Side 话题帮助",
507
+ "",
508
+ "当前是多轮 Side 话题。**直接发送消息**即可开始新任务;"
509
+ "任务执行中发送的消息会补充到当前任务。",
510
+ "",
511
+ "---",
512
+ "",
513
+ "### 可用操作",
514
+ "",
515
+ "- `/status` — 查看 Side 状态",
516
+ "- `/stop` — 只中断当前 Side 任务,Side 仍可继续",
517
+ "- `/side close` — 结束当前 Side 话题,结束后不能继续",
518
+ "- `/admin` — 查看当前实例的管理地址和根目录",
519
+ "- `/help` 或 `/` — 显示本帮助",
520
+ "",
521
+ "---",
522
+ "",
523
+ "### 使用提示",
524
+ "",
525
+ ]
526
+ if requires_mention:
527
+ lines.append("- **群聊**:本群 Side 话题中的每条消息都需要 @机器人。")
528
+ else:
529
+ lines.append("- **单聊**:本单聊 Side 话题无需 @机器人。")
530
+ lines.append("- **发送斜杠**:用 `//` 开头可把首个 `/` 作为普通消息发送。")
531
+ return "\n".join(lines)