ai_co_discuss 0.3.46 → 0.3.49
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.
- package/.agents/skills/discuss-host/SKILL.md +1 -0
- package/.agents/skills/discuss-host/references/discussion.md +113 -113
- package/.agents/skills/discuss-host/references/free.md +36 -34
- package/.agents/skills/discuss-host/references/goal.md +35 -0
- package/.agents/skills/discuss-participant/SKILL.md +1 -0
- package/.agents/skills/workflow-creator/SKILL.md +2 -2
- package/README.md +3 -1
- package/backend/admin.py +104 -35
- package/backend/free_room.py +2170 -2136
- package/backend/goal_room.py +358 -0
- package/backend/mode_room.py +105 -0
- package/backend/schema.sql +1593 -1510
- package/backend/server.py +2636 -2555
- package/backend/session_room.py +1180 -1148
- package/docs/THIRD_PARTY_NOTICES.md +1293 -1264
- package/docs/deployment.md +5 -1
- package/docs/en/workflow.md +15 -1
- package/docs/runner.md +28 -7
- package/docs/workflow.md +15 -1
- package/package.json +3 -2
- package/runner/adapters/api.mjs +333 -313
- package/runner/adapters/claude.mjs +12 -16
- package/runner/admin_account.mjs +12 -19
- package/runner/device_auth.mjs +369 -0
- package/runner/discuss.py +1208 -1125
- package/runner/discussion_launch.mjs +107 -12
- package/runner/discussion_service.mjs +25 -1
- package/runner/expert_prompts.mjs +132 -132
- package/runner/free_runner.mjs +982 -910
- package/runner/pinyin_table.mjs +406 -0
- package/runner/project_files.mjs +52 -3
- package/runner/relay_channel.mjs +51 -7
- package/runner/remote_launch.mjs +221 -235
- package/runner/remote_policy.mjs +156 -0
- package/runner/scheduler.mjs +459 -429
- package/runner/stt.mjs +2 -2
- package/runner/tts.mjs +74 -113
- package/runner/tts_providers.mjs +205 -0
- package/runner/webauthn.mjs +155 -0
- package/runner/workflows.mjs +371 -348
- package/web/app.js +355 -51
- package/web/modern.css +1 -1
- package/web/modern.js +5764 -3816
package/runner/discuss.py
CHANGED
|
@@ -1,1125 +1,1208 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""AI CoDiscuss 命令行客户端:显式目标、按需读取、可重放的 HTTP 写入。"""
|
|
3
|
-
|
|
4
|
-
import argparse
|
|
5
|
-
import hashlib
|
|
6
|
-
import json
|
|
7
|
-
import math
|
|
8
|
-
import os
|
|
9
|
-
import re
|
|
10
|
-
import secrets
|
|
11
|
-
import sys
|
|
12
|
-
import tempfile
|
|
13
|
-
from dataclasses import dataclass
|
|
14
|
-
from http.client import HTTPException
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
from urllib.error import HTTPError, URLError
|
|
17
|
-
from urllib.parse import unquote, urlencode, urlsplit
|
|
18
|
-
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
19
|
-
|
|
20
|
-
VERSION = "0.2.0"
|
|
21
|
-
DEFAULT_TIMEOUT = 20
|
|
22
|
-
MAX_BODY_BYTES = 512 * 1024
|
|
23
|
-
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
|
24
|
-
MAX_MESSAGE_ATTACHMENTS = 10
|
|
25
|
-
# Environment variables a hosting AI session exposes its own ID through, with the client name the room records.
|
|
26
|
-
SESSION_VARIABLES = (("CLAUDE_CODE_SESSION_ID", "claude"), ("CODEX_THREAD_ID", "codex"), ("ANTIGRAVITY_CONVERSATION_ID", "antigravity"))
|
|
27
|
-
MAX_TEXT_LENGTH = 32000
|
|
28
|
-
MAX_SUMMARY_LENGTH = 1000
|
|
29
|
-
MAX_BRIEF_LENGTH = 1600
|
|
30
|
-
MAX_MEMBER_HANDLE = 80
|
|
31
|
-
PARTICIPANTS = ("用户", "Codex", "Claude", "Antigravity", "DeepSeek", "Qwen", "Grok")
|
|
32
|
-
# A platform is a type; `Claude-review` is a second Claude instance with its own session. Mirrors backend/identity.py.
|
|
33
|
-
INSTANCE_PATTERN = re.compile(r"(Codex|Claude|Antigravity|DeepSeek|Qwen|Grok)(?:-[0-9A-Za-z_]{1,16})?")
|
|
34
|
-
KINDS = ("opinion", "moderation", "chat")
|
|
35
|
-
OVERVIEW_FIELDS = ("id", "title", "goal", "host", "phase", "focus", "message_count",
|
|
36
|
-
"latest_id", "state_until", "current_turn", "queued_turns", "held_since", "limits", "completed_rounds", "answered_turns", "duplicate", "submission_id",
|
|
37
|
-
"mode", "members", "free")
|
|
38
|
-
GUIDANCE = {
|
|
39
|
-
"initial": "先基于目标、背景和边界独立形成观点;提交前不读他人观点或主持总结。",
|
|
40
|
-
"paused": "停止处理安排并停止本讨论自己的后台任务,核对实际状态。",
|
|
41
|
-
"concluded": "话题已收束;停止本讨论自己的后台任务并核对实际状态。",
|
|
42
|
-
}
|
|
43
|
-
BRIEF_FIELDS = ("id", "title", "goal", "host", "user_request", "background", "boundaries", "experts")
|
|
44
|
-
ROOM_LIST_FIELDS = ("id", "title", "goal", "message_count", "latest_id", "created_at")
|
|
45
|
-
EXIT_CODES = {"0": "成功", "1": "HTTP/API 错误", "2": "参数或输入错误",
|
|
46
|
-
"3": "网络失败或响应无法确认", "130": "被中断,写入结果需核对"}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class CliError(Exception):
|
|
50
|
-
"""Carry a stable exit code and machine-readable error details."""
|
|
51
|
-
|
|
52
|
-
def __init__(self, message, code=2, **details):
|
|
53
|
-
super().__init__(message)
|
|
54
|
-
self.code = code
|
|
55
|
-
self.details = details
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# Shells split a literal text argument when it holds quotes they treat as delimiters; Windows PowerShell also treats
|
|
59
|
-
# Chinese curly quotes as quotes, which surfaces here as leftover arguments.
|
|
60
|
-
UNRECOGNIZED_HINT = "正文或摘要可能被 shell 按引号或空格截断(Windows PowerShell 也把中文弯引号当引号):改用 --body-file / --summary-file 传文本。"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class Parser(argparse.ArgumentParser):
|
|
64
|
-
"""Report parser failures through the same stderr contract as API errors."""
|
|
65
|
-
|
|
66
|
-
def error(self, message):
|
|
67
|
-
"""Raise a usage error instead of printing a separate argparse format."""
|
|
68
|
-
hint = f"查看 {self.prog} --help"
|
|
69
|
-
if message.startswith("unrecognized arguments"):
|
|
70
|
-
hint = UNRECOGNIZED_HINT + hint
|
|
71
|
-
raise CliError(message, hint=hint)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
class NoRedirect(HTTPRedirectHandler):
|
|
75
|
-
"""Do not silently move a request to another service or convert POST to GET."""
|
|
76
|
-
|
|
77
|
-
def redirect_request(self, *args, **kwargs):
|
|
78
|
-
"""Leave redirects as HTTP errors so the target remains explicit."""
|
|
79
|
-
return None
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
@dataclass
|
|
83
|
-
class RequestPlan:
|
|
84
|
-
"""Describe one request without performing network or persistence work."""
|
|
85
|
-
|
|
86
|
-
path: str
|
|
87
|
-
method: str = "GET"
|
|
88
|
-
body: dict | None = None
|
|
89
|
-
view: str | None = None
|
|
90
|
-
params: dict | None = None
|
|
91
|
-
upload_file: str | None = None
|
|
92
|
-
download_file: str | None = None
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def positive_int(value):
|
|
96
|
-
"""Parse a positive page or resource identifier."""
|
|
97
|
-
try:
|
|
98
|
-
number = int(value)
|
|
99
|
-
except ValueError as error:
|
|
100
|
-
raise argparse.ArgumentTypeError("必须为正整数") from error
|
|
101
|
-
if number < 1:
|
|
102
|
-
raise argparse.ArgumentTypeError("必须为正整数")
|
|
103
|
-
return number
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def ai_name(value):
|
|
107
|
-
"""Accept an AI platform or a named instance of one, such as Claude-review."""
|
|
108
|
-
if not INSTANCE_PATTERN.fullmatch(value):
|
|
109
|
-
raise argparse.ArgumentTypeError("须为 Codex/Claude/Antigravity/DeepSeek/Qwen/Grok,或平台名-后缀(后缀 1–16 个英文字母、数字或下划线)")
|
|
110
|
-
return value
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def member_handle(value):
|
|
114
|
-
"""Accept a member as the room calls it: its display name (such as Gemini) or its ID; the site resolves and checks it."""
|
|
115
|
-
value = value.strip()
|
|
116
|
-
if not 0 < len(value) <= MAX_MEMBER_HANDLE:
|
|
117
|
-
raise argparse.ArgumentTypeError(f"须为成员的显示名或 ID,1–{MAX_MEMBER_HANDLE} 字")
|
|
118
|
-
return value
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def participant(value):
|
|
122
|
-
"""Accept the user or any AI instance."""
|
|
123
|
-
return value if value == PARTICIPANTS[0] else ai_name(value)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def room_id(value):
|
|
127
|
-
"""Accept an existing room identifier without allowing path components."""
|
|
128
|
-
if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
|
129
|
-
raise argparse.ArgumentTypeError("房间 ID 只能包含字母、数字、下划线和连字符")
|
|
130
|
-
return value
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def non_negative_int(value):
|
|
134
|
-
"""Parse a message cursor, including zero for the start of history."""
|
|
135
|
-
try:
|
|
136
|
-
number = int(value)
|
|
137
|
-
except ValueError as error:
|
|
138
|
-
raise argparse.ArgumentTypeError("必须为非负整数") from error
|
|
139
|
-
if number < 0:
|
|
140
|
-
raise argparse.ArgumentTypeError("必须为非负整数")
|
|
141
|
-
return number
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def submission_id(value):
|
|
145
|
-
"""Validate the server's reusable submission identifier format."""
|
|
146
|
-
if not re.fullmatch(r"[A-Za-z0-9_-]{8,100}", value):
|
|
147
|
-
raise argparse.ArgumentTypeError("提交编号需为 8–100 位字母、数字、下划线或连字符")
|
|
148
|
-
return value
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def fill_submission_id(args, plan):
|
|
152
|
-
"""Derive an omitted submission ID from the command and its content, so resending the same content is deduplicated
|
|
153
|
-
without the caller generating an ID in the shell; the hosting session ID is left out because it does not change what is written."""
|
|
154
|
-
target = next((part for part in (plan.body, plan.params) if isinstance(part, dict) and "submission_id" in part), None)
|
|
155
|
-
if plan.method != "POST" or target is None or target["submission_id"]:
|
|
156
|
-
return
|
|
157
|
-
body = {key: value for key, value in (plan.body or {}).items() if key not in ("session_id", "session_client")}
|
|
158
|
-
material = {"operation": args.operation, "room": getattr(args, "room", None), "body": body, "params": plan.params}
|
|
159
|
-
if plan.upload_file:
|
|
160
|
-
material["file"] = hashlib.sha256(Path(plan.upload_file).read_bytes()).hexdigest()
|
|
161
|
-
digest = hashlib.sha256(json.dumps(material, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
|
|
162
|
-
target["submission_id"] = "auto-" + digest[:40]
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def attachment_id(value):
|
|
166
|
-
"""Accept only opaque file identifiers, never local or remote paths."""
|
|
167
|
-
if not re.fullmatch(r"[a-f0-9]{32}", value):
|
|
168
|
-
raise argparse.ArgumentTypeError("附件 ID 需为服务端返回的 32 位小写十六进制编号")
|
|
169
|
-
return value
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def add_common(parser):
|
|
173
|
-
"""Allow common flags before or after subcommands without shared state."""
|
|
174
|
-
options = {"default": argparse.SUPPRESS}
|
|
175
|
-
parser.add_argument("--site", help="HTTP 服务地址;未指定时读 DISCUSS_URL,再读凭据中的 site", **options)
|
|
176
|
-
parser.add_argument("--as", dest="actor", type=participant,
|
|
177
|
-
help="业务写入的声明操作者;省略时用凭据绑定的成员;不是登录认证;api 使用原始请求体", **options)
|
|
178
|
-
parser.add_argument("-o", "--output", choices=("json", "text"),
|
|
179
|
-
help="默认 json;成功 stdout,错误 stderr", **options)
|
|
180
|
-
parser.add_argument("--timeout", type=float, help="请求超时秒数,默认 20", **options)
|
|
181
|
-
parser.add_argument("--dry-run", action="store_true", help="只预览写入请求,不联网", **options)
|
|
182
|
-
parser.add_argument("--access-file", help="本房间的受限凭据 JSON 文件(- 读标准输入);未指定时依次读取 DISCUSS_ACCESS_FILE 文件、DISCUSS_ACCESS JSON 文本;不输出其 token", **options)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def add_text(parser, name, required=False):
|
|
186
|
-
"""Offer mutually exclusive literal and UTF-8 file inputs for one field."""
|
|
187
|
-
group = parser.add_mutually_exclusive_group(required=required)
|
|
188
|
-
group.add_argument(f"--{name}", help=f"{name} 正文", default=argparse.SUPPRESS)
|
|
189
|
-
group.add_argument(f"--{name}-file", help="UTF-8 文件;- 从标准输入读取",
|
|
190
|
-
default=argparse.SUPPRESS)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def add_topic(parser, create=False):
|
|
194
|
-
"""Expose topic fields, using a full experts map to avoid hidden merging."""
|
|
195
|
-
for field in ("title", "goal", "user_request", "background", "boundaries", "focus"):
|
|
196
|
-
add_text(parser, field.replace("_", "-"), required=create and field == "title")
|
|
197
|
-
parser.add_argument("--host", type=participant, default=argparse.SUPPRESS,
|
|
198
|
-
help="主持人;创建时默认 --as 操作者")
|
|
199
|
-
parser.add_argument("--experts-file", default=argparse.SUPPRESS,
|
|
200
|
-
help='完整参与者映射 JSON,如 {"Codex":"cli","Antigravity":"cli"};替换整个映射')
|
|
201
|
-
for name in ("max-rounds", "max-minutes"):
|
|
202
|
-
parser.add_argument(f"--{name}", type=non_negative_int, default=argparse.SUPPRESS)
|
|
203
|
-
# Accepted so older scripts keep working; one execution has no time limit any more.
|
|
204
|
-
parser.add_argument("--execution-minutes", type=non_negative_int, default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def add_leaf(subcommands, registry, name, description, needs_room=False):
|
|
208
|
-
"""Register one discoverable command and its common options."""
|
|
209
|
-
parser = subcommands.add_parser(name.split()[-1], help=description, description=description,
|
|
210
|
-
allow_abbrev=False)
|
|
211
|
-
add_common(parser)
|
|
212
|
-
parser.set_defaults(operation=name)
|
|
213
|
-
if needs_room:
|
|
214
|
-
parser.add_argument("room", type=room_id, nargs="?", help="房间 ID;省略时用凭据绑定的房间")
|
|
215
|
-
registry[name] = parser
|
|
216
|
-
return parser
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def room_commands(subcommands, registry):
|
|
220
|
-
"""Register room discovery, progressive reading, creation, and updates."""
|
|
221
|
-
for action, description in (("list", "列出房间概要"), ("view", "只读目标、阶段、焦点及当前安排"),
|
|
222
|
-
("brief", "按需读取背景、边界和专家配置"),
|
|
223
|
-
("conclusion", "收束前申请最新收束状态(阻碍项与 revision);主持人收束前必做"),
|
|
224
|
-
("protocol", "按需读取当前服务的参与协议")):
|
|
225
|
-
parser = add_leaf(subcommands, registry, f"room {action}", description, action != "list")
|
|
226
|
-
if action == "list":
|
|
227
|
-
parser.add_argument("--submission-id", type=submission_id, help="按建房提交编号精确查询")
|
|
228
|
-
elif action in ("view", "brief"):
|
|
229
|
-
parser.add_argument("--request", type=positive_int, help="手动成员:按自己的有效请求限制可读原文")
|
|
230
|
-
events = add_leaf(subcommands, registry, "room events",
|
|
231
|
-
"房主读取房间事件记录:用户与程序的每次写入(含被拒的)及其引起的状态变化,按发生顺序,用于排查问题;默认最新 500 条", True)
|
|
232
|
-
paging = events.add_mutually_exclusive_group()
|
|
233
|
-
paging.add_argument("--after", type=non_negative_int, help="只读此事件编号之后的记录")
|
|
234
|
-
paging.add_argument("--before", type=non_negative_int, help="读此事件编号之前的一页;按返回的 more 继续向前")
|
|
235
|
-
limits = add_leaf(subcommands, registry, "room limits", "查看上限;--check 检查并执行到限处理;--done 通知程序目标已完成", True)
|
|
236
|
-
limits.add_argument("--check", action="store_true")
|
|
237
|
-
limits.add_argument("--done", action="store_true", help="主持人宣告目标已完成:结束讨论,房间回到自由协作;须带 --conclusion")
|
|
238
|
-
limits.add_argument("--conclusion", help="room conclusion 返回的 revision;--done 必填")
|
|
239
|
-
create = add_leaf(subcommands, registry, "room create", "创建话题;不自动启动讨论或监控")
|
|
240
|
-
create.add_argument("--folder-id", help="直接在指定文件夹中创建;省略则未归类")
|
|
241
|
-
add_topic(create, create=True)
|
|
242
|
-
create.add_argument("--submission-id", type=submission_id,
|
|
243
|
-
help="新话题唯一编号;未知结果先查询,重试沿用编号和原始资料。省略时按命令与内容自动生成,同样内容重试得到同一编号")
|
|
244
|
-
update = add_leaf(subcommands, registry, "room update", "维护话题;省略的字段保持不变", True)
|
|
245
|
-
add_topic(update)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
def message_commands(subcommands, registry):
|
|
249
|
-
"""Register bounded search, exact reads, and attributed message writes."""
|
|
250
|
-
listing = add_leaf(subcommands, registry, "message list", "按页检索原文,每页 20 条", True)
|
|
251
|
-
paging = listing.add_mutually_exclusive_group()
|
|
252
|
-
paging.add_argument("--page", type=positive_int, default=1)
|
|
253
|
-
paging.add_argument("--after", type=non_negative_int, help="只读此消息编号之后的内容;按 next_after 续读")
|
|
254
|
-
listing.add_argument("--query", help="关键词或消息编号")
|
|
255
|
-
listing.add_argument("--author", type=participant)
|
|
256
|
-
listing.add_argument("--mention", type=participant)
|
|
257
|
-
listing.add_argument("--kind", choices=KINDS, help="筛选观点、主持说明或协作消息")
|
|
258
|
-
listing.add_argument("--submission-id", type=submission_id, help="按提交编号精确查询结果")
|
|
259
|
-
listing.add_argument("--request", type=positive_int, help="手动成员:按自己的有效请求限制可读原文")
|
|
260
|
-
view = add_leaf(subcommands, registry, "message view", "读取一条精确原文与来源", True)
|
|
261
|
-
view.add_argument("id", type=positive_int, help="消息编号")
|
|
262
|
-
view.add_argument("--request", type=positive_int, help="手动成员:按自己的有效请求限制可读原文")
|
|
263
|
-
group = add_leaf(subcommands, registry, "message group-plan", "房主读取全体请求、成员、顺序与 revision;安排全体前先读,提交时携带该 revision", True)
|
|
264
|
-
group.add_argument("id", type=positive_int, help="全体请求的原消息编号")
|
|
265
|
-
post = add_leaf(subcommands, registry, "message post", "提交观点、主持说明或协作回答", True)
|
|
266
|
-
add_text(post, "body")
|
|
267
|
-
post.add_argument("--attachment", action="append", type=attachment_id, default=[],
|
|
268
|
-
help="已上传附件 ID;可重复指定,最多 10 个")
|
|
269
|
-
add_text(post, "summary")
|
|
270
|
-
post.add_argument("--kind", choices=("chat", "moderation"), default="chat")
|
|
271
|
-
post.add_argument("--execution", type=positive_int, help="自由房间:本次执行编号;省略时用凭据绑定的执行。执行失效后不能改作普通发言")
|
|
272
|
-
post.add_argument("--to", type=member_handle, action="append", default=[],
|
|
273
|
-
help="自由房间:主持人点名派活,普通成员提出回应建议(须房主确认);可重复")
|
|
274
|
-
post.add_argument("--handoff", action="store_true",
|
|
275
|
-
help="自由房间主持人:与 --to 同用,只点名一位成员时,它做完也回到你这里(如再交评审);点名多位时总会回到你")
|
|
276
|
-
post.add_argument("--next-review", type=member_handle, help="主持派实现时声明下一位只读评审;先读 review roster,不可指定本批实现者")
|
|
277
|
-
post.add_argument("--review-scope", help="本批预授权评审范围,1–2000 字")
|
|
278
|
-
post.add_argument("--review-roster", help="先读取 review roster 取得的 revision;状态变化须重读")
|
|
279
|
-
post.add_argument("--request", type=positive_int, action="append", default=[],
|
|
280
|
-
help="自由房间手动接入成员:本次回答完成的请求编号,可重复;不能与 --execution 同用")
|
|
281
|
-
post.add_argument("--reply-to", type=positive_int, help="被引用的原文编号")
|
|
282
|
-
post.add_argument("--submission-id", type=submission_id,
|
|
283
|
-
help="每次新提交的唯一编号;重试必须沿用同编号和正文。省略时按房间、身份、关联与内容自动生成,同一份草稿重试得到同一编号")
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def attachment_commands(subcommands, registry):
|
|
287
|
-
"""Expose explicit upload, bounded discovery, and binary downloads."""
|
|
288
|
-
listing = add_leaf(subcommands, registry, "attachment list", "分页读取已发送附件;按提交编号核对上传", True)
|
|
289
|
-
listing.add_argument("--page", type=positive_int, default=1)
|
|
290
|
-
listing.add_argument("--message", type=positive_int, help="只读指定消息的附件")
|
|
291
|
-
listing.add_argument("--submission-id", type=submission_id, help="核对一次上传,包含尚未发送的文件")
|
|
292
|
-
view = add_leaf(subcommands, registry, "attachment view", "读取文件名、类型、大小与下载地址", True)
|
|
293
|
-
view.add_argument("id", type=attachment_id)
|
|
294
|
-
upload = add_leaf(subcommands, registry, "attachment upload", "上传原文件;之后用 message post --attachment 关联消息", True)
|
|
295
|
-
upload.add_argument("--file", required=True, help="本地原文件路径;最多 20 MiB")
|
|
296
|
-
upload.add_argument("--submission-id", type=submission_id, help="上传唯一编号;省略时按命令与内容自动生成,同样内容重试得到同一编号")
|
|
297
|
-
download = add_leaf(subcommands, registry, "attachment download", "下载原文件到显式路径;不覆盖已有文件", True)
|
|
298
|
-
download.add_argument("id", type=attachment_id)
|
|
299
|
-
download.add_argument("--dest", required=True, help="保存文件的完整或相对路径;父目录须已存在")
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def turn_commands(subcommands, registry):
|
|
303
|
-
"""Read and control discussion plans through their dedicated operations."""
|
|
304
|
-
descriptions = {"list": "分页读取安排历史",
|
|
305
|
-
"view": "读取一个安排",
|
|
306
|
-
"roster": "申请最新专家状态(在场专家、本轮待发言者与 revision);整轮安排前必做",
|
|
307
|
-
"order": "读取发言顺序(已发言、正在发言、待发言与 revision);调整顺序前必做",
|
|
308
|
-
"reorder": "房主调整尚未发言者的顺序(须带 turn order 的 --revision);已发言与正在发言者不动",
|
|
309
|
-
"create": "安排一位专家,或用 --plan-file 一次安排整轮(须带最新 --roster)",
|
|
310
|
-
"continue": "处理用户操作或打断后,按原排队继续下一位",
|
|
311
|
-
"ask": "先读 turn roster,再将其中一条主持追问转为普通提问;不改变讨论队列",
|
|
312
|
-
"cancel": "取消指定未完成安排"}
|
|
313
|
-
for action, description in descriptions.items():
|
|
314
|
-
parser = add_leaf(subcommands, registry, f"turn {action}", description, True)
|
|
315
|
-
if action == "list":
|
|
316
|
-
parser.add_argument("--page", type=positive_int, default=1)
|
|
317
|
-
parser.add_argument("--submission-id", type=submission_id, help="按安排提交编号精确查询结果")
|
|
318
|
-
elif action not in ("create", "continue", "ask", "roster", "order", "reorder"):
|
|
319
|
-
parser.add_argument("id", type=positive_int, help="安排编号")
|
|
320
|
-
if action == "create":
|
|
321
|
-
parser.add_argument("--expert", type=member_handle, help="单个安排的专家;与 --plan-file 二选一")
|
|
322
|
-
add_text(parser, "question")
|
|
323
|
-
parser.add_argument("--plan-file", help='整轮安排 JSON 数组 [{"expert":"Claude","question":"…"}],按顺序接力;- 从标准输入读取')
|
|
324
|
-
parser.add_argument("--roster", required=True, help="turn roster 返回的 revision;任何新安排前必读,专家状态变化后需重新获取")
|
|
325
|
-
parser.add_argument("--submission-id", type=submission_id,
|
|
326
|
-
help="新安排唯一编号;重试沿用同编号和内容。省略时按命令与内容自动生成,同样内容重试得到同一编号")
|
|
327
|
-
if action == "reorder":
|
|
328
|
-
parser.add_argument("--revision", required=True, help="turn order 返回的 revision;顺序变化后需重新获取")
|
|
329
|
-
parser.add_argument("--member", action="append", type=ai_name, required=True,
|
|
330
|
-
help="按新顺序重复指定,须恰好包含 turn order 中 movable 的全部成员")
|
|
331
|
-
if action == "ask":
|
|
332
|
-
parser.add_argument("--request", type=positive_int, required=True, help="turn roster 中 host_requests 的请求编号")
|
|
333
|
-
parser.add_argument("--expert", type=member_handle, required=True, help="在场且已注册的目标成员,含已发言者和主持人")
|
|
334
|
-
parser.add_argument("--submission-id", type=submission_id, help="转交唯一编号;重试沿用原编号与内容。省略时按命令与内容自动生成,同样内容重试得到同一编号")
|
|
335
|
-
if action in ("continue", "ask"):
|
|
336
|
-
parser.add_argument("--roster", required=True, help="turn roster 返回的 revision;状态变化后须重新读取")
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
def state_commands(subcommands, registry):
|
|
340
|
-
"""Register separately requested host summaries with explicit cutoffs."""
|
|
341
|
-
view = add_leaf(subcommands, registry, "state view", "读取主持摘要及材料截止编号", True)
|
|
342
|
-
view.add_argument("--request", type=positive_int, help="手动成员:按自己的有效请求限制可读原文")
|
|
343
|
-
parser = add_leaf(subcommands, registry, "state set", "保存主持摘要;不自动推定已读范围", True)
|
|
344
|
-
add_text(parser, "body", required=True)
|
|
345
|
-
add_text(parser, "brief")
|
|
346
|
-
parser.add_argument("--until", type=int, required=True, help="实际已读的材料截止编号,可为 0")
|
|
347
|
-
decision = add_leaf(subcommands, registry, "state decision", "按编号读取本房间一条智能调度判断、完整输入及选项;先读问题原文,再自行判断", True)
|
|
348
|
-
decision.add_argument("id", type=positive_int, help="任务 scheduler_suggestions 给出的决策记录编号")
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
def run_commands(subcommands, registry):
|
|
352
|
-
"""Expose discussion-session lifecycle controls independently of the resident service."""
|
|
353
|
-
for action in ("view", "start", "pause", "resume", "stop"):
|
|
354
|
-
parser = add_leaf(subcommands, registry, f"run {action}", "查看或显式控制讨论会话", True)
|
|
355
|
-
if action
|
|
356
|
-
parser.add_argument("--revision", required=True, help="run view 返回的 revision;状态变化后须重新读取")
|
|
357
|
-
if action == "start":
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
add_leaf(subcommands, registry, "
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
raise CliError("
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
return
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
def
|
|
494
|
-
"""
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
if
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
payload["
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
return
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
def
|
|
565
|
-
"""
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
return RequestPlan(
|
|
569
|
-
if args.action == "
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
return RequestPlan(
|
|
576
|
-
|
|
577
|
-
if
|
|
578
|
-
args.
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
if args.
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
if args.action == "
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
if
|
|
684
|
-
|
|
685
|
-
return
|
|
686
|
-
if
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
if args.action == "
|
|
695
|
-
return RequestPlan(
|
|
696
|
-
if args.action == "
|
|
697
|
-
return RequestPlan(base,
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
if
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
path
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
if
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
if
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
raise CliError(f"
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
if
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
"""
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
if not
|
|
1070
|
-
raise CliError("--
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
return
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""AI CoDiscuss 命令行客户端:显式目标、按需读取、可重放的 HTTP 写入。"""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from http.client import HTTPException
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.error import HTTPError, URLError
|
|
17
|
+
from urllib.parse import unquote, urlencode, urlsplit
|
|
18
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
19
|
+
|
|
20
|
+
VERSION = "0.2.0"
|
|
21
|
+
DEFAULT_TIMEOUT = 20
|
|
22
|
+
MAX_BODY_BYTES = 512 * 1024
|
|
23
|
+
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
|
24
|
+
MAX_MESSAGE_ATTACHMENTS = 10
|
|
25
|
+
# Environment variables a hosting AI session exposes its own ID through, with the client name the room records.
|
|
26
|
+
SESSION_VARIABLES = (("CLAUDE_CODE_SESSION_ID", "claude"), ("CODEX_THREAD_ID", "codex"), ("ANTIGRAVITY_CONVERSATION_ID", "antigravity"))
|
|
27
|
+
MAX_TEXT_LENGTH = 32000
|
|
28
|
+
MAX_SUMMARY_LENGTH = 1000
|
|
29
|
+
MAX_BRIEF_LENGTH = 1600
|
|
30
|
+
MAX_MEMBER_HANDLE = 80
|
|
31
|
+
PARTICIPANTS = ("用户", "Codex", "Claude", "Antigravity", "DeepSeek", "Qwen", "Grok")
|
|
32
|
+
# A platform is a type; `Claude-review` is a second Claude instance with its own session. Mirrors backend/identity.py.
|
|
33
|
+
INSTANCE_PATTERN = re.compile(r"(Codex|Claude|Antigravity|DeepSeek|Qwen|Grok)(?:-[0-9A-Za-z_]{1,16})?")
|
|
34
|
+
KINDS = ("opinion", "moderation", "chat")
|
|
35
|
+
OVERVIEW_FIELDS = ("id", "title", "goal", "host", "phase", "focus", "message_count",
|
|
36
|
+
"latest_id", "state_until", "current_turn", "queued_turns", "held_since", "limits", "completed_rounds", "answered_turns", "duplicate", "submission_id",
|
|
37
|
+
"mode", "members", "free")
|
|
38
|
+
GUIDANCE = {
|
|
39
|
+
"initial": "先基于目标、背景和边界独立形成观点;提交前不读他人观点或主持总结。",
|
|
40
|
+
"paused": "停止处理安排并停止本讨论自己的后台任务,核对实际状态。",
|
|
41
|
+
"concluded": "话题已收束;停止本讨论自己的后台任务并核对实际状态。",
|
|
42
|
+
}
|
|
43
|
+
BRIEF_FIELDS = ("id", "title", "goal", "host", "user_request", "background", "boundaries", "experts")
|
|
44
|
+
ROOM_LIST_FIELDS = ("id", "title", "goal", "message_count", "latest_id", "created_at")
|
|
45
|
+
EXIT_CODES = {"0": "成功", "1": "HTTP/API 错误", "2": "参数或输入错误",
|
|
46
|
+
"3": "网络失败或响应无法确认", "130": "被中断,写入结果需核对"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CliError(Exception):
|
|
50
|
+
"""Carry a stable exit code and machine-readable error details."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, message, code=2, **details):
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.code = code
|
|
55
|
+
self.details = details
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Shells split a literal text argument when it holds quotes they treat as delimiters; Windows PowerShell also treats
|
|
59
|
+
# Chinese curly quotes as quotes, which surfaces here as leftover arguments.
|
|
60
|
+
UNRECOGNIZED_HINT = "正文或摘要可能被 shell 按引号或空格截断(Windows PowerShell 也把中文弯引号当引号):改用 --body-file / --summary-file 传文本。"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Parser(argparse.ArgumentParser):
|
|
64
|
+
"""Report parser failures through the same stderr contract as API errors."""
|
|
65
|
+
|
|
66
|
+
def error(self, message):
|
|
67
|
+
"""Raise a usage error instead of printing a separate argparse format."""
|
|
68
|
+
hint = f"查看 {self.prog} --help"
|
|
69
|
+
if message.startswith("unrecognized arguments"):
|
|
70
|
+
hint = UNRECOGNIZED_HINT + hint
|
|
71
|
+
raise CliError(message, hint=hint)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class NoRedirect(HTTPRedirectHandler):
|
|
75
|
+
"""Do not silently move a request to another service or convert POST to GET."""
|
|
76
|
+
|
|
77
|
+
def redirect_request(self, *args, **kwargs):
|
|
78
|
+
"""Leave redirects as HTTP errors so the target remains explicit."""
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class RequestPlan:
|
|
84
|
+
"""Describe one request without performing network or persistence work."""
|
|
85
|
+
|
|
86
|
+
path: str
|
|
87
|
+
method: str = "GET"
|
|
88
|
+
body: dict | None = None
|
|
89
|
+
view: str | None = None
|
|
90
|
+
params: dict | None = None
|
|
91
|
+
upload_file: str | None = None
|
|
92
|
+
download_file: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def positive_int(value):
|
|
96
|
+
"""Parse a positive page or resource identifier."""
|
|
97
|
+
try:
|
|
98
|
+
number = int(value)
|
|
99
|
+
except ValueError as error:
|
|
100
|
+
raise argparse.ArgumentTypeError("必须为正整数") from error
|
|
101
|
+
if number < 1:
|
|
102
|
+
raise argparse.ArgumentTypeError("必须为正整数")
|
|
103
|
+
return number
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ai_name(value):
|
|
107
|
+
"""Accept an AI platform or a named instance of one, such as Claude-review."""
|
|
108
|
+
if not INSTANCE_PATTERN.fullmatch(value):
|
|
109
|
+
raise argparse.ArgumentTypeError("须为 Codex/Claude/Antigravity/DeepSeek/Qwen/Grok,或平台名-后缀(后缀 1–16 个英文字母、数字或下划线)")
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def member_handle(value):
|
|
114
|
+
"""Accept a member as the room calls it: its display name (such as Gemini) or its ID; the site resolves and checks it."""
|
|
115
|
+
value = value.strip()
|
|
116
|
+
if not 0 < len(value) <= MAX_MEMBER_HANDLE:
|
|
117
|
+
raise argparse.ArgumentTypeError(f"须为成员的显示名或 ID,1–{MAX_MEMBER_HANDLE} 字")
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def participant(value):
|
|
122
|
+
"""Accept the user or any AI instance."""
|
|
123
|
+
return value if value == PARTICIPANTS[0] else ai_name(value)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def room_id(value):
|
|
127
|
+
"""Accept an existing room identifier without allowing path components."""
|
|
128
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
|
129
|
+
raise argparse.ArgumentTypeError("房间 ID 只能包含字母、数字、下划线和连字符")
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def non_negative_int(value):
|
|
134
|
+
"""Parse a message cursor, including zero for the start of history."""
|
|
135
|
+
try:
|
|
136
|
+
number = int(value)
|
|
137
|
+
except ValueError as error:
|
|
138
|
+
raise argparse.ArgumentTypeError("必须为非负整数") from error
|
|
139
|
+
if number < 0:
|
|
140
|
+
raise argparse.ArgumentTypeError("必须为非负整数")
|
|
141
|
+
return number
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def submission_id(value):
|
|
145
|
+
"""Validate the server's reusable submission identifier format."""
|
|
146
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{8,100}", value):
|
|
147
|
+
raise argparse.ArgumentTypeError("提交编号需为 8–100 位字母、数字、下划线或连字符")
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def fill_submission_id(args, plan):
|
|
152
|
+
"""Derive an omitted submission ID from the command and its content, so resending the same content is deduplicated
|
|
153
|
+
without the caller generating an ID in the shell; the hosting session ID is left out because it does not change what is written."""
|
|
154
|
+
target = next((part for part in (plan.body, plan.params) if isinstance(part, dict) and "submission_id" in part), None)
|
|
155
|
+
if plan.method != "POST" or target is None or target["submission_id"]:
|
|
156
|
+
return
|
|
157
|
+
body = {key: value for key, value in (plan.body or {}).items() if key not in ("session_id", "session_client")}
|
|
158
|
+
material = {"operation": args.operation, "room": getattr(args, "room", None), "body": body, "params": plan.params}
|
|
159
|
+
if plan.upload_file:
|
|
160
|
+
material["file"] = hashlib.sha256(Path(plan.upload_file).read_bytes()).hexdigest()
|
|
161
|
+
digest = hashlib.sha256(json.dumps(material, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
|
|
162
|
+
target["submission_id"] = "auto-" + digest[:40]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def attachment_id(value):
|
|
166
|
+
"""Accept only opaque file identifiers, never local or remote paths."""
|
|
167
|
+
if not re.fullmatch(r"[a-f0-9]{32}", value):
|
|
168
|
+
raise argparse.ArgumentTypeError("附件 ID 需为服务端返回的 32 位小写十六进制编号")
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def add_common(parser):
|
|
173
|
+
"""Allow common flags before or after subcommands without shared state."""
|
|
174
|
+
options = {"default": argparse.SUPPRESS}
|
|
175
|
+
parser.add_argument("--site", help="HTTP 服务地址;未指定时读 DISCUSS_URL,再读凭据中的 site", **options)
|
|
176
|
+
parser.add_argument("--as", dest="actor", type=participant,
|
|
177
|
+
help="业务写入的声明操作者;省略时用凭据绑定的成员;不是登录认证;api 使用原始请求体", **options)
|
|
178
|
+
parser.add_argument("-o", "--output", choices=("json", "text"),
|
|
179
|
+
help="默认 json;成功 stdout,错误 stderr", **options)
|
|
180
|
+
parser.add_argument("--timeout", type=float, help="请求超时秒数,默认 20", **options)
|
|
181
|
+
parser.add_argument("--dry-run", action="store_true", help="只预览写入请求,不联网", **options)
|
|
182
|
+
parser.add_argument("--access-file", help="本房间的受限凭据 JSON 文件(- 读标准输入);未指定时依次读取 DISCUSS_ACCESS_FILE 文件、DISCUSS_ACCESS JSON 文本;不输出其 token", **options)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def add_text(parser, name, required=False):
|
|
186
|
+
"""Offer mutually exclusive literal and UTF-8 file inputs for one field."""
|
|
187
|
+
group = parser.add_mutually_exclusive_group(required=required)
|
|
188
|
+
group.add_argument(f"--{name}", help=f"{name} 正文", default=argparse.SUPPRESS)
|
|
189
|
+
group.add_argument(f"--{name}-file", help="UTF-8 文件;- 从标准输入读取",
|
|
190
|
+
default=argparse.SUPPRESS)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def add_topic(parser, create=False):
|
|
194
|
+
"""Expose topic fields, using a full experts map to avoid hidden merging."""
|
|
195
|
+
for field in ("title", "goal", "user_request", "background", "boundaries", "focus"):
|
|
196
|
+
add_text(parser, field.replace("_", "-"), required=create and field == "title")
|
|
197
|
+
parser.add_argument("--host", type=participant, default=argparse.SUPPRESS,
|
|
198
|
+
help="主持人;创建时默认 --as 操作者")
|
|
199
|
+
parser.add_argument("--experts-file", default=argparse.SUPPRESS,
|
|
200
|
+
help='完整参与者映射 JSON,如 {"Codex":"cli","Antigravity":"cli"};替换整个映射')
|
|
201
|
+
for name in ("max-rounds", "max-minutes"):
|
|
202
|
+
parser.add_argument(f"--{name}", type=non_negative_int, default=argparse.SUPPRESS)
|
|
203
|
+
# Accepted so older scripts keep working; one execution has no time limit any more.
|
|
204
|
+
parser.add_argument("--execution-minutes", type=non_negative_int, default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def add_leaf(subcommands, registry, name, description, needs_room=False):
|
|
208
|
+
"""Register one discoverable command and its common options."""
|
|
209
|
+
parser = subcommands.add_parser(name.split()[-1], help=description, description=description,
|
|
210
|
+
allow_abbrev=False)
|
|
211
|
+
add_common(parser)
|
|
212
|
+
parser.set_defaults(operation=name)
|
|
213
|
+
if needs_room:
|
|
214
|
+
parser.add_argument("room", type=room_id, nargs="?", help="房间 ID;省略时用凭据绑定的房间")
|
|
215
|
+
registry[name] = parser
|
|
216
|
+
return parser
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def room_commands(subcommands, registry):
|
|
220
|
+
"""Register room discovery, progressive reading, creation, and updates."""
|
|
221
|
+
for action, description in (("list", "列出房间概要"), ("view", "只读目标、阶段、焦点及当前安排"),
|
|
222
|
+
("brief", "按需读取背景、边界和专家配置"),
|
|
223
|
+
("conclusion", "收束前申请最新收束状态(阻碍项与 revision);主持人收束前必做"),
|
|
224
|
+
("protocol", "按需读取当前服务的参与协议")):
|
|
225
|
+
parser = add_leaf(subcommands, registry, f"room {action}", description, action != "list")
|
|
226
|
+
if action == "list":
|
|
227
|
+
parser.add_argument("--submission-id", type=submission_id, help="按建房提交编号精确查询")
|
|
228
|
+
elif action in ("view", "brief"):
|
|
229
|
+
parser.add_argument("--request", type=positive_int, help="手动成员:按自己的有效请求限制可读原文")
|
|
230
|
+
events = add_leaf(subcommands, registry, "room events",
|
|
231
|
+
"房主读取房间事件记录:用户与程序的每次写入(含被拒的)及其引起的状态变化,按发生顺序,用于排查问题;默认最新 500 条", True)
|
|
232
|
+
paging = events.add_mutually_exclusive_group()
|
|
233
|
+
paging.add_argument("--after", type=non_negative_int, help="只读此事件编号之后的记录")
|
|
234
|
+
paging.add_argument("--before", type=non_negative_int, help="读此事件编号之前的一页;按返回的 more 继续向前")
|
|
235
|
+
limits = add_leaf(subcommands, registry, "room limits", "查看上限;--check 检查并执行到限处理;--done 通知程序目标已完成", True)
|
|
236
|
+
limits.add_argument("--check", action="store_true")
|
|
237
|
+
limits.add_argument("--done", action="store_true", help="主持人宣告目标已完成:结束讨论,房间回到自由协作;须带 --conclusion")
|
|
238
|
+
limits.add_argument("--conclusion", help="room conclusion 返回的 revision;--done 必填")
|
|
239
|
+
create = add_leaf(subcommands, registry, "room create", "创建话题;不自动启动讨论或监控")
|
|
240
|
+
create.add_argument("--folder-id", help="直接在指定文件夹中创建;省略则未归类")
|
|
241
|
+
add_topic(create, create=True)
|
|
242
|
+
create.add_argument("--submission-id", type=submission_id,
|
|
243
|
+
help="新话题唯一编号;未知结果先查询,重试沿用编号和原始资料。省略时按命令与内容自动生成,同样内容重试得到同一编号")
|
|
244
|
+
update = add_leaf(subcommands, registry, "room update", "维护话题;省略的字段保持不变", True)
|
|
245
|
+
add_topic(update)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def message_commands(subcommands, registry):
|
|
249
|
+
"""Register bounded search, exact reads, and attributed message writes."""
|
|
250
|
+
listing = add_leaf(subcommands, registry, "message list", "按页检索原文,每页 20 条", True)
|
|
251
|
+
paging = listing.add_mutually_exclusive_group()
|
|
252
|
+
paging.add_argument("--page", type=positive_int, default=1)
|
|
253
|
+
paging.add_argument("--after", type=non_negative_int, help="只读此消息编号之后的内容;按 next_after 续读")
|
|
254
|
+
listing.add_argument("--query", help="关键词或消息编号")
|
|
255
|
+
listing.add_argument("--author", type=participant)
|
|
256
|
+
listing.add_argument("--mention", type=participant)
|
|
257
|
+
listing.add_argument("--kind", choices=KINDS, help="筛选观点、主持说明或协作消息")
|
|
258
|
+
listing.add_argument("--submission-id", type=submission_id, help="按提交编号精确查询结果")
|
|
259
|
+
listing.add_argument("--request", type=positive_int, help="手动成员:按自己的有效请求限制可读原文")
|
|
260
|
+
view = add_leaf(subcommands, registry, "message view", "读取一条精确原文与来源", True)
|
|
261
|
+
view.add_argument("id", type=positive_int, help="消息编号")
|
|
262
|
+
view.add_argument("--request", type=positive_int, help="手动成员:按自己的有效请求限制可读原文")
|
|
263
|
+
group = add_leaf(subcommands, registry, "message group-plan", "房主读取全体请求、成员、顺序与 revision;安排全体前先读,提交时携带该 revision", True)
|
|
264
|
+
group.add_argument("id", type=positive_int, help="全体请求的原消息编号")
|
|
265
|
+
post = add_leaf(subcommands, registry, "message post", "提交观点、主持说明或协作回答", True)
|
|
266
|
+
add_text(post, "body")
|
|
267
|
+
post.add_argument("--attachment", action="append", type=attachment_id, default=[],
|
|
268
|
+
help="已上传附件 ID;可重复指定,最多 10 个")
|
|
269
|
+
add_text(post, "summary")
|
|
270
|
+
post.add_argument("--kind", choices=("chat", "moderation"), default="chat")
|
|
271
|
+
post.add_argument("--execution", type=positive_int, help="自由房间:本次执行编号;省略时用凭据绑定的执行。执行失效后不能改作普通发言")
|
|
272
|
+
post.add_argument("--to", type=member_handle, action="append", default=[],
|
|
273
|
+
help="自由房间:主持人点名派活,普通成员提出回应建议(须房主确认);可重复")
|
|
274
|
+
post.add_argument("--handoff", action="store_true",
|
|
275
|
+
help="自由房间主持人:与 --to 同用,只点名一位成员时,它做完也回到你这里(如再交评审);点名多位时总会回到你")
|
|
276
|
+
post.add_argument("--next-review", type=member_handle, help="主持派实现时声明下一位只读评审;先读 review roster,不可指定本批实现者")
|
|
277
|
+
post.add_argument("--review-scope", help="本批预授权评审范围,1–2000 字")
|
|
278
|
+
post.add_argument("--review-roster", help="先读取 review roster 取得的 revision;状态变化须重读")
|
|
279
|
+
post.add_argument("--request", type=positive_int, action="append", default=[],
|
|
280
|
+
help="自由房间手动接入成员:本次回答完成的请求编号,可重复;不能与 --execution 同用")
|
|
281
|
+
post.add_argument("--reply-to", type=positive_int, help="被引用的原文编号")
|
|
282
|
+
post.add_argument("--submission-id", type=submission_id,
|
|
283
|
+
help="每次新提交的唯一编号;重试必须沿用同编号和正文。省略时按房间、身份、关联与内容自动生成,同一份草稿重试得到同一编号")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def attachment_commands(subcommands, registry):
|
|
287
|
+
"""Expose explicit upload, bounded discovery, and binary downloads."""
|
|
288
|
+
listing = add_leaf(subcommands, registry, "attachment list", "分页读取已发送附件;按提交编号核对上传", True)
|
|
289
|
+
listing.add_argument("--page", type=positive_int, default=1)
|
|
290
|
+
listing.add_argument("--message", type=positive_int, help="只读指定消息的附件")
|
|
291
|
+
listing.add_argument("--submission-id", type=submission_id, help="核对一次上传,包含尚未发送的文件")
|
|
292
|
+
view = add_leaf(subcommands, registry, "attachment view", "读取文件名、类型、大小与下载地址", True)
|
|
293
|
+
view.add_argument("id", type=attachment_id)
|
|
294
|
+
upload = add_leaf(subcommands, registry, "attachment upload", "上传原文件;之后用 message post --attachment 关联消息", True)
|
|
295
|
+
upload.add_argument("--file", required=True, help="本地原文件路径;最多 20 MiB")
|
|
296
|
+
upload.add_argument("--submission-id", type=submission_id, help="上传唯一编号;省略时按命令与内容自动生成,同样内容重试得到同一编号")
|
|
297
|
+
download = add_leaf(subcommands, registry, "attachment download", "下载原文件到显式路径;不覆盖已有文件", True)
|
|
298
|
+
download.add_argument("id", type=attachment_id)
|
|
299
|
+
download.add_argument("--dest", required=True, help="保存文件的完整或相对路径;父目录须已存在")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def turn_commands(subcommands, registry):
|
|
303
|
+
"""Read and control discussion plans through their dedicated operations."""
|
|
304
|
+
descriptions = {"list": "分页读取安排历史",
|
|
305
|
+
"view": "读取一个安排",
|
|
306
|
+
"roster": "申请最新专家状态(在场专家、本轮待发言者与 revision);整轮安排前必做",
|
|
307
|
+
"order": "读取发言顺序(已发言、正在发言、待发言与 revision);调整顺序前必做",
|
|
308
|
+
"reorder": "房主调整尚未发言者的顺序(须带 turn order 的 --revision);已发言与正在发言者不动",
|
|
309
|
+
"create": "安排一位专家,或用 --plan-file 一次安排整轮(须带最新 --roster)",
|
|
310
|
+
"continue": "处理用户操作或打断后,按原排队继续下一位",
|
|
311
|
+
"ask": "先读 turn roster,再将其中一条主持追问转为普通提问;不改变讨论队列",
|
|
312
|
+
"cancel": "取消指定未完成安排"}
|
|
313
|
+
for action, description in descriptions.items():
|
|
314
|
+
parser = add_leaf(subcommands, registry, f"turn {action}", description, True)
|
|
315
|
+
if action == "list":
|
|
316
|
+
parser.add_argument("--page", type=positive_int, default=1)
|
|
317
|
+
parser.add_argument("--submission-id", type=submission_id, help="按安排提交编号精确查询结果")
|
|
318
|
+
elif action not in ("create", "continue", "ask", "roster", "order", "reorder"):
|
|
319
|
+
parser.add_argument("id", type=positive_int, help="安排编号")
|
|
320
|
+
if action == "create":
|
|
321
|
+
parser.add_argument("--expert", type=member_handle, help="单个安排的专家;与 --plan-file 二选一")
|
|
322
|
+
add_text(parser, "question")
|
|
323
|
+
parser.add_argument("--plan-file", help='整轮安排 JSON 数组 [{"expert":"Claude","question":"…"}],按顺序接力;- 从标准输入读取')
|
|
324
|
+
parser.add_argument("--roster", required=True, help="turn roster 返回的 revision;任何新安排前必读,专家状态变化后需重新获取")
|
|
325
|
+
parser.add_argument("--submission-id", type=submission_id,
|
|
326
|
+
help="新安排唯一编号;重试沿用同编号和内容。省略时按命令与内容自动生成,同样内容重试得到同一编号")
|
|
327
|
+
if action == "reorder":
|
|
328
|
+
parser.add_argument("--revision", required=True, help="turn order 返回的 revision;顺序变化后需重新获取")
|
|
329
|
+
parser.add_argument("--member", action="append", type=ai_name, required=True,
|
|
330
|
+
help="按新顺序重复指定,须恰好包含 turn order 中 movable 的全部成员")
|
|
331
|
+
if action == "ask":
|
|
332
|
+
parser.add_argument("--request", type=positive_int, required=True, help="turn roster 中 host_requests 的请求编号")
|
|
333
|
+
parser.add_argument("--expert", type=member_handle, required=True, help="在场且已注册的目标成员,含已发言者和主持人")
|
|
334
|
+
parser.add_argument("--submission-id", type=submission_id, help="转交唯一编号;重试沿用原编号与内容。省略时按命令与内容自动生成,同样内容重试得到同一编号")
|
|
335
|
+
if action in ("continue", "ask"):
|
|
336
|
+
parser.add_argument("--roster", required=True, help="turn roster 返回的 revision;状态变化后须重新读取")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def state_commands(subcommands, registry):
|
|
340
|
+
"""Register separately requested host summaries with explicit cutoffs."""
|
|
341
|
+
view = add_leaf(subcommands, registry, "state view", "读取主持摘要及材料截止编号", True)
|
|
342
|
+
view.add_argument("--request", type=positive_int, help="手动成员:按自己的有效请求限制可读原文")
|
|
343
|
+
parser = add_leaf(subcommands, registry, "state set", "保存主持摘要;不自动推定已读范围", True)
|
|
344
|
+
add_text(parser, "body", required=True)
|
|
345
|
+
add_text(parser, "brief")
|
|
346
|
+
parser.add_argument("--until", type=int, required=True, help="实际已读的材料截止编号,可为 0")
|
|
347
|
+
decision = add_leaf(subcommands, registry, "state decision", "按编号读取本房间一条智能调度判断、完整输入及选项;先读问题原文,再自行判断", True)
|
|
348
|
+
decision.add_argument("id", type=positive_int, help="任务 scheduler_suggestions 给出的决策记录编号")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def run_commands(subcommands, registry):
|
|
352
|
+
"""Expose discussion-session lifecycle controls independently of the resident service."""
|
|
353
|
+
for action in ("view", "start", "pause", "resume", "stop"):
|
|
354
|
+
parser = add_leaf(subcommands, registry, f"run {action}", "查看或显式控制讨论会话", True)
|
|
355
|
+
if action not in ("view", "start"):
|
|
356
|
+
parser.add_argument("--revision", required=True, help="run view 返回的 revision;状态变化后须重新读取")
|
|
357
|
+
if action == "start":
|
|
358
|
+
parser.add_argument("--revision", help="run view 返回的 revision(房主开启时用)")
|
|
359
|
+
parser.add_argument("--request", type=positive_int,
|
|
360
|
+
help="主持人用:用户 /discuss 命令生成的 discuss_start 请求编号;只补全 --goal,上限用房间默认值")
|
|
361
|
+
add_text(parser, "goal")
|
|
362
|
+
for name in ("max-rounds", "max-minutes"):
|
|
363
|
+
parser.add_argument(f"--{name}", type=non_negative_int, default=argparse.SUPPRESS)
|
|
364
|
+
# Accepted so older scripts keep working; one execution has no time limit any more.
|
|
365
|
+
parser.add_argument("--execution-minutes", type=non_negative_int, default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
366
|
+
parser.add_argument("--submission-id", type=submission_id,
|
|
367
|
+
help="可选启动提交编号;重试须沿用编号和原请求")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def goal_commands(subcommands, registry):
|
|
371
|
+
"""Goal mode: read it, let the host fill in or end it, and let the owner control it."""
|
|
372
|
+
add_leaf(subcommands, registry, "goal view", "读取 Goal 的目标、状态、续跑次数、用时、是否空闲与 revision;完成、卡住与续跑须先读", True)
|
|
373
|
+
start = add_leaf(subcommands, registry, "goal start",
|
|
374
|
+
"主持人凭用户 /goal 命令生成的 goal_start 请求补全目标并开启(--request);房主可带 --revision 直接开启", True)
|
|
375
|
+
start.add_argument("--request", type=positive_int, help="任务 requests 中 purpose 为 goal_start 的请求编号(主持人用)")
|
|
376
|
+
start.add_argument("--revision", help="goal view 返回的 revision(房主直接开启时用)")
|
|
377
|
+
add_text(start, "objective")
|
|
378
|
+
for action, help_text in (("done", "主持人对照实际状态核验后宣告 Goal 完成;先 goal view 取 revision"),
|
|
379
|
+
("block", "同一阻碍连续 3 次续跑仍未解决时,主持人宣告 Goal 卡住并写明阻碍;先 goal view 取 revision")):
|
|
380
|
+
parser = add_leaf(subcommands, registry, f"goal {action}", help_text, True)
|
|
381
|
+
parser.add_argument("--revision", required=True, help="goal view 返回的 revision;状态变化后须重新读取")
|
|
382
|
+
add_text(parser, "note")
|
|
383
|
+
for action in ("pause", "resume", "clear"):
|
|
384
|
+
add_leaf(subcommands, registry, f"goal {action}", "房主暂停、恢复或结束 Goal", True)
|
|
385
|
+
update = add_leaf(subcommands, registry, "goal update", "房主修改 Goal 的目标或预算(0 表示不限)", True)
|
|
386
|
+
add_text(update, "objective")
|
|
387
|
+
for parser in (start, update):
|
|
388
|
+
for name in ("max-continues", "max-minutes"):
|
|
389
|
+
parser.add_argument(f"--{name}", type=non_negative_int, default=argparse.SUPPRESS, help="房主可选预算,0 表示不限")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def mode_commands(subcommands, registry):
|
|
393
|
+
"""Whichever mode (goal or discussion) is open: read it, and let the host declare it complete."""
|
|
394
|
+
add_leaf(subcommands, registry, "mode view", "读取房间当前的模式(goal / discussion / 无)、状态和宣告完成所需的 revision", True)
|
|
395
|
+
done = add_leaf(subcommands, registry, "mode done",
|
|
396
|
+
"主持人对照实际结果核验后宣告当前模式完成:Goal 结束为完成,讨论收束;先 mode view 取 revision", True)
|
|
397
|
+
done.add_argument("--revision", required=True, help="mode view 返回的 revision;状态变化后须重新读取")
|
|
398
|
+
add_text(done, "note")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def execution_commands(subcommands, registry):
|
|
402
|
+
"""Read an execution or report its failure through its durable identifier."""
|
|
403
|
+
for action in ("view", "fail"):
|
|
404
|
+
parser = add_leaf(subcommands, registry, f"execution {action}", "读取执行或报告本次执行失败", True)
|
|
405
|
+
parser.add_argument("id", type=positive_int, nargs="?", help="执行编号;省略时用凭据绑定的执行")
|
|
406
|
+
if action == "fail":
|
|
407
|
+
parser.add_argument("--reason", required=True)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def member_commands(subcommands, registry):
|
|
411
|
+
"""Let a manual member announce itself; the CLI attaches the hosting session on its own."""
|
|
412
|
+
add_leaf(subcommands, registry, "member register", "手动接入成员用自己的凭据向房间注册;会话 ID 由本程序自动附带,无需填写", True)
|
|
413
|
+
capability = add_leaf(subcommands, registry, "member review-capability", "房主读取一个成员的能力事实和 revision;本机服务提交前先读", True)
|
|
414
|
+
capability.add_argument("id", type=positive_int)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def review_commands(subcommands, registry):
|
|
418
|
+
"""Inspect the local service's read-only reviewer facts."""
|
|
419
|
+
add_leaf(subcommands, registry, "review roster", "房主或主持人读取可核验的只读评审候选;此读取不派发任务", True)
|
|
420
|
+
ready = add_leaf(subcommands, registry, "review ready", "先读取本批完整交付、评审资格、额度与 revision", True)
|
|
421
|
+
ready.add_argument("--batch", type=positive_int, required=True)
|
|
422
|
+
advance = add_leaf(subcommands, registry, "review advance", "先读 review ready,再带 revision 只推进既定评审并处理对应交接", True)
|
|
423
|
+
advance.add_argument("--batch", type=positive_int, required=True)
|
|
424
|
+
advance.add_argument("--revision", required=True)
|
|
425
|
+
advance.add_argument("--submission-id", type=submission_id, help="推进唯一编号;省略时按命令与内容自动生成,同样内容重试得到同一编号")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def build_parser(prog="discuss"):
|
|
429
|
+
"""Build help and schema from the same command definitions."""
|
|
430
|
+
parser = Parser(prog=prog, description="AI CoDiscuss CLI:通过 HTTP 操作现有讨论网站。",
|
|
431
|
+
allow_abbrev=False, epilog="站点、房间、操作者与执行编号省略时取自凭据文件;提交编号省略时按内容生成;不保存当前房间,不自动重试写入。")
|
|
432
|
+
add_common(parser)
|
|
433
|
+
parser.add_argument("--version", action="version", version=f"{prog} {VERSION}")
|
|
434
|
+
subcommands = parser.add_subparsers(dest="resource", required=True)
|
|
435
|
+
registry = {}
|
|
436
|
+
for name, register in (("room", room_commands), ("message", message_commands),
|
|
437
|
+
("attachment", attachment_commands),
|
|
438
|
+
("turn", turn_commands), ("state", state_commands),
|
|
439
|
+
("run", run_commands), ("goal", goal_commands), ("mode", mode_commands), ("execution", execution_commands), ("member", member_commands), ("review", review_commands)):
|
|
440
|
+
group = subcommands.add_parser(name, help=f"{name} 操作", allow_abbrev=False)
|
|
441
|
+
add_common(group)
|
|
442
|
+
register(group.add_subparsers(dest="action", required=True), registry)
|
|
443
|
+
add_leaf(subcommands, registry, "health", "检查目标站点的 HTTP 健康接口")
|
|
444
|
+
api = add_leaf(subcommands, registry, "api", "直接调用本站 /api 或 /r;请求体原样传送")
|
|
445
|
+
api.add_argument("path", help="本站路径,可含查询参数;禁止完整 URL")
|
|
446
|
+
api.add_argument("--method", choices=("GET", "POST"), default="GET")
|
|
447
|
+
api.add_argument("--input", help="POST JSON 对象文件;- 从标准输入读取")
|
|
448
|
+
schema = add_leaf(subcommands, registry, "schema", "离线列出命令或指定命令的参数约定")
|
|
449
|
+
schema.add_argument("command", nargs="*", help="例如 message post;为空时列出全部命令")
|
|
450
|
+
return parser, registry
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def read_input(path):
|
|
454
|
+
"""Read UTF-8 content, accepting a BOM and at most one stdin consumer."""
|
|
455
|
+
if path == "-":
|
|
456
|
+
if sys.stdin.isatty():
|
|
457
|
+
raise CliError("--*-file - / --input - 需要通过管道提供标准输入")
|
|
458
|
+
return sys.stdin.read().lstrip("\ufeff")
|
|
459
|
+
try:
|
|
460
|
+
return Path(path).read_text(encoding="utf-8-sig")
|
|
461
|
+
except (OSError, UnicodeError) as error:
|
|
462
|
+
raise CliError(f"无法读取 UTF-8 文件 {path}: {error}") from error
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def read_object(path):
|
|
466
|
+
"""Require a JSON object rather than silently accepting arrays or scalars."""
|
|
467
|
+
try:
|
|
468
|
+
value = json.loads(read_input(path))
|
|
469
|
+
except ValueError as error:
|
|
470
|
+
raise CliError(f"无效 JSON: {error}") from error
|
|
471
|
+
if not isinstance(value, dict):
|
|
472
|
+
raise CliError("JSON 文件必须包含对象")
|
|
473
|
+
return value
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def load_access(args):
|
|
477
|
+
"""Read an explicit file, a process-bound file, or inline credentials, in that order."""
|
|
478
|
+
path = getattr(args, "access_file", None) or os.getenv("DISCUSS_ACCESS_FILE", "").strip()
|
|
479
|
+
if path:
|
|
480
|
+
return read_object(path)
|
|
481
|
+
text = os.getenv("DISCUSS_ACCESS", "").strip()
|
|
482
|
+
if not text:
|
|
483
|
+
return {}
|
|
484
|
+
try:
|
|
485
|
+
value = json.loads(text)
|
|
486
|
+
except ValueError as error:
|
|
487
|
+
raise CliError(f"DISCUSS_ACCESS 不是有效 JSON: {error}") from error
|
|
488
|
+
if not isinstance(value, dict):
|
|
489
|
+
raise CliError("DISCUSS_ACCESS 必须是 JSON 对象")
|
|
490
|
+
return value
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def text_field(args, field):
|
|
494
|
+
"""Read one literal or file-backed field without altering its content."""
|
|
495
|
+
values = vars(args)
|
|
496
|
+
path = values.get(f"{field}_file")
|
|
497
|
+
return read_input(path) if path is not None else values.get(field)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def require_actor(args):
|
|
501
|
+
"""Use the declared operator, or the member a task credential is bound to."""
|
|
502
|
+
actor = getattr(args, "actor", None) or load_access(args).get("member")
|
|
503
|
+
if not actor:
|
|
504
|
+
raise CliError("写入须用 --as 指定操作者,或使用绑定成员的凭据;该字段声明操作者,不提供身份认证")
|
|
505
|
+
return actor
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def bound_execution(args):
|
|
509
|
+
"""Return the execution a task credential is bound to, if any."""
|
|
510
|
+
value = load_access(args).get("execution_id")
|
|
511
|
+
if value is None:
|
|
512
|
+
return None
|
|
513
|
+
try:
|
|
514
|
+
return positive_int(str(value))
|
|
515
|
+
except argparse.ArgumentTypeError as error:
|
|
516
|
+
raise CliError("凭据文件中的 execution_id 无效") from error
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def bind_room(args):
|
|
520
|
+
"""Fill an omitted room from the credential; a credential for another room is still rejected before sending."""
|
|
521
|
+
if hasattr(args, "room") and args.room is None:
|
|
522
|
+
args.room = load_access(args).get("room") or None
|
|
523
|
+
if not args.room:
|
|
524
|
+
raise CliError("请指定房间 ID,或使用绑定了房间的凭据(--access-file / DISCUSS_ACCESS_FILE)")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def topic_payload(args):
|
|
528
|
+
"""Collect only supplied topic fields; expert configuration is a full map."""
|
|
529
|
+
payload = {}
|
|
530
|
+
limits = {key: getattr(args, key) for key in ("max_rounds", "max_minutes", "execution_minutes") if hasattr(args, key)}
|
|
531
|
+
if limits:
|
|
532
|
+
payload["limits"] = limits
|
|
533
|
+
for field in ("title", "goal", "user_request", "background", "boundaries", "focus"):
|
|
534
|
+
value = text_field(args, field)
|
|
535
|
+
if value is not None:
|
|
536
|
+
payload[field] = value
|
|
537
|
+
if hasattr(args, "host"):
|
|
538
|
+
payload["host"] = args.host
|
|
539
|
+
if hasattr(args, "experts_file"):
|
|
540
|
+
experts = read_object(args.experts_file)
|
|
541
|
+
if any(not INSTANCE_PATTERN.fullmatch(name) or mode != "cli" for name, mode in experts.items()):
|
|
542
|
+
raise CliError("专家映射的名称必须是有效 AI,模式必须是 cli")
|
|
543
|
+
payload["experts"] = experts
|
|
544
|
+
return payload
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def read_context_params(args):
|
|
548
|
+
"""Select an explicit manual request or the execution bound to this access file."""
|
|
549
|
+
request_id = getattr(args, "request", None)
|
|
550
|
+
execution_value = load_access(args).get("execution_id")
|
|
551
|
+
if request_id is not None and execution_value is not None:
|
|
552
|
+
raise CliError("读取时 --request 不能与凭据文件中的 execution_id 同用")
|
|
553
|
+
if request_id is not None:
|
|
554
|
+
return {"request_id": request_id}
|
|
555
|
+
if execution_value is None:
|
|
556
|
+
return {}
|
|
557
|
+
try:
|
|
558
|
+
execution_id = positive_int(str(execution_value))
|
|
559
|
+
except argparse.ArgumentTypeError as error:
|
|
560
|
+
raise CliError("凭据文件中的 execution_id 无效") from error
|
|
561
|
+
return {"execution_id": execution_id}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def plan_room(args):
|
|
565
|
+
"""Translate room operations to one existing HTTP endpoint."""
|
|
566
|
+
if args.action == "list":
|
|
567
|
+
params = {"submission_id": args.submission_id} if args.submission_id else None
|
|
568
|
+
return RequestPlan("/api/rooms", view="room_list", params=params)
|
|
569
|
+
if args.action == "create":
|
|
570
|
+
payload = topic_payload(args)
|
|
571
|
+
payload.setdefault("host", require_actor(args))
|
|
572
|
+
payload["submission_id"] = args.submission_id
|
|
573
|
+
if args.folder_id is not None:
|
|
574
|
+
payload["folder_id"] = args.folder_id
|
|
575
|
+
return RequestPlan("/api/rooms", "POST", payload, "overview")
|
|
576
|
+
base = f"/api/rooms/{args.room}"
|
|
577
|
+
if args.action == "limits":
|
|
578
|
+
if args.done:
|
|
579
|
+
return RequestPlan(base + "/limits", "POST", {"actor": require_actor(args), "action": "done",
|
|
580
|
+
**({"conclusion": args.conclusion} if args.conclusion else {})}, "overview")
|
|
581
|
+
return RequestPlan(base + "/limits", "POST", {"actor": require_actor(args), "action": "check"}, "overview") if args.check else RequestPlan(base + "/limits")
|
|
582
|
+
if args.action == "update":
|
|
583
|
+
payload = topic_payload(args)
|
|
584
|
+
if not payload:
|
|
585
|
+
raise CliError("room update 至少需要一个话题字段")
|
|
586
|
+
payload["actor"] = require_actor(args)
|
|
587
|
+
return RequestPlan(base + "/topic", "POST", payload, "overview")
|
|
588
|
+
if args.action == "conclusion":
|
|
589
|
+
return RequestPlan(base + "/conclusion")
|
|
590
|
+
if args.action == "protocol":
|
|
591
|
+
return RequestPlan(f"/r/{args.room}/protocol")
|
|
592
|
+
if args.action == "events":
|
|
593
|
+
params = {"after": args.after, "before": args.before}
|
|
594
|
+
return RequestPlan(base + "/events", params={key: value for key, value in params.items() if value})
|
|
595
|
+
return RequestPlan(base, view="brief" if args.action == "brief" else "overview", params=read_context_params(args))
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def plan_message(args):
|
|
599
|
+
"""Preserve operator, original author, message role, and replay identifier."""
|
|
600
|
+
base = f"/api/rooms/{args.room}/messages"
|
|
601
|
+
if args.action == "group-plan":
|
|
602
|
+
return RequestPlan(f"/api/rooms/{args.room}/recommendations/{args.id}")
|
|
603
|
+
if args.action == "view":
|
|
604
|
+
return RequestPlan(f"{base}/{args.id}", params=read_context_params(args))
|
|
605
|
+
if args.action == "list":
|
|
606
|
+
params = {"page": args.page, "q": args.query, "author": args.author, "mention": args.mention,
|
|
607
|
+
"after": args.after, "kind": args.kind, "submission_id": args.submission_id,
|
|
608
|
+
**read_context_params(args)}
|
|
609
|
+
return RequestPlan(base, params={key: value for key, value in params.items() if value is not None})
|
|
610
|
+
actor = require_actor(args)
|
|
611
|
+
if not args.execution and not args.request:
|
|
612
|
+
args.execution = bound_execution(args)
|
|
613
|
+
if (args.to or args.handoff) and args.kind != "chat":
|
|
614
|
+
raise CliError("--to / --handoff 仅用于 chat 消息")
|
|
615
|
+
if args.kind == "moderation" and not (args.execution or args.request):
|
|
616
|
+
raise CliError("主持说明须关联本次 --execution 或有效 --request")
|
|
617
|
+
if args.execution and args.request:
|
|
618
|
+
raise CliError("--execution 用于接入程序交付的执行,--request 用于手动接入成员,二者不能同用")
|
|
619
|
+
payload = {"author": actor, "submitted_by": actor, "source": "cli",
|
|
620
|
+
"kind": args.kind, "text": text_field(args, "body") or "", "submission_id": args.submission_id}
|
|
621
|
+
if len(args.attachment) > MAX_MESSAGE_ATTACHMENTS or len(set(args.attachment)) != len(args.attachment):
|
|
622
|
+
raise CliError("每条消息最多 10 个附件,不能重复")
|
|
623
|
+
if args.attachment:
|
|
624
|
+
payload["attachment_ids"] = args.attachment
|
|
625
|
+
if (not payload["text"].strip() and not args.attachment) or len(payload["text"].strip()) > MAX_TEXT_LENGTH:
|
|
626
|
+
raise CliError(f"发言需包含正文或附件,正文最多 {MAX_TEXT_LENGTH} 个 Unicode 字符;不会自动截断或分段")
|
|
627
|
+
summary = text_field(args, "summary")
|
|
628
|
+
if summary is not None:
|
|
629
|
+
if len(summary.strip()) > MAX_SUMMARY_LENGTH:
|
|
630
|
+
raise CliError(f"回答摘要最多 {MAX_SUMMARY_LENGTH} 个 Unicode 字符;不会自动截断")
|
|
631
|
+
payload["summary"] = summary
|
|
632
|
+
if args.execution:
|
|
633
|
+
payload["execution_id"] = args.execution
|
|
634
|
+
if args.request:
|
|
635
|
+
payload["request_ids"] = list(dict.fromkeys(args.request))
|
|
636
|
+
if args.to:
|
|
637
|
+
payload["to"] = list(dict.fromkeys(args.to))
|
|
638
|
+
if args.handoff:
|
|
639
|
+
payload["handoff"] = True
|
|
640
|
+
for field in ("next_review", "review_scope", "review_roster"):
|
|
641
|
+
if getattr(args, field, None) is not None:
|
|
642
|
+
payload[field] = getattr(args, field)
|
|
643
|
+
if args.reply_to:
|
|
644
|
+
payload["reply_to"] = args.reply_to
|
|
645
|
+
# A hosting Claude Code or Codex session exposes its own ID; the room records it so the local service can take the member over.
|
|
646
|
+
for variable, client in SESSION_VARIABLES:
|
|
647
|
+
session_id = os.getenv(variable, "").strip()
|
|
648
|
+
if session_id:
|
|
649
|
+
payload["session_id"] = session_id
|
|
650
|
+
payload["session_client"] = client
|
|
651
|
+
break
|
|
652
|
+
return RequestPlan(base, "POST", payload)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def plan_attachment(args):
|
|
656
|
+
"""Keep file bytes outside JSON messages and all storage targets explicit."""
|
|
657
|
+
base = f"/api/rooms/{args.room}/attachments"
|
|
658
|
+
if args.action == "list":
|
|
659
|
+
params = {"page": args.page, "message_id": args.message, "submission_id": args.submission_id}
|
|
660
|
+
return RequestPlan(base, params={key: value for key, value in params.items() if value is not None})
|
|
661
|
+
if args.action == "upload":
|
|
662
|
+
path = Path(args.file)
|
|
663
|
+
try:
|
|
664
|
+
if not path.is_file() or path.stat().st_size > MAX_ATTACHMENT_BYTES:
|
|
665
|
+
raise CliError("上传路径须为文件,大小最多 20 MiB")
|
|
666
|
+
except OSError as error:
|
|
667
|
+
raise CliError(f"无法读取上传文件: {error}") from error
|
|
668
|
+
metadata = {"actor": require_actor(args), "file_name": path.name, "submission_id": args.submission_id}
|
|
669
|
+
return RequestPlan(base, "POST", params=metadata, upload_file=str(path))
|
|
670
|
+
if args.action == "download":
|
|
671
|
+
destination = Path(args.dest)
|
|
672
|
+
if destination.exists() or not destination.parent.is_dir():
|
|
673
|
+
raise CliError("--dest 须为不存在的文件路径,且父目录已存在;不会覆盖文件")
|
|
674
|
+
return RequestPlan(f"{base}/{args.id}/content", params={"download": "1"}, download_file=str(destination))
|
|
675
|
+
return RequestPlan(f"{base}/{args.id}")
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def plan_turn(args):
|
|
679
|
+
"""Separate creating an assignment from external notification delivery."""
|
|
680
|
+
base = f"/api/rooms/{args.room}"
|
|
681
|
+
if args.action == "list":
|
|
682
|
+
params = {"page": args.page}
|
|
683
|
+
if args.submission_id:
|
|
684
|
+
params["submission_id"] = args.submission_id
|
|
685
|
+
return RequestPlan(base + "/turns", params=params)
|
|
686
|
+
if args.action == "roster":
|
|
687
|
+
return RequestPlan(base + "/turns/roster")
|
|
688
|
+
if args.action == "order":
|
|
689
|
+
return RequestPlan(base + "/speaking-order")
|
|
690
|
+
if args.action == "reorder":
|
|
691
|
+
return RequestPlan(base + "/speaking-order", "POST", {"revision": args.revision, "order": args.member})
|
|
692
|
+
if args.action == "create":
|
|
693
|
+
return RequestPlan(base + "/turns", "POST", turn_payload(args))
|
|
694
|
+
if args.action == "continue":
|
|
695
|
+
return RequestPlan(base + "/turns/continue", "POST", {"actor": require_actor(args), "revision": args.roster})
|
|
696
|
+
if args.action == "ask":
|
|
697
|
+
return RequestPlan(base + "/turns/ask", "POST", {"actor": require_actor(args), "revision": args.roster,
|
|
698
|
+
"request_id": args.request, "expert": args.expert, "submission_id": args.submission_id})
|
|
699
|
+
path = f"{base}/turns/{args.id}"
|
|
700
|
+
if args.action == "cancel":
|
|
701
|
+
status = "cancelled"
|
|
702
|
+
return RequestPlan(path, "POST", {"actor": require_actor(args), "status": status})
|
|
703
|
+
return RequestPlan(path)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def turn_payload(args):
|
|
707
|
+
"""Build one arrangement or an ordered round plan from a UTF-8 JSON array."""
|
|
708
|
+
payload = {"actor": require_actor(args), "submission_id": args.submission_id}
|
|
709
|
+
question = text_field(args, "question")
|
|
710
|
+
if args.plan_file is not None:
|
|
711
|
+
if args.expert or question is not None:
|
|
712
|
+
raise CliError("--plan-file 不能与 --expert / --question 同时使用")
|
|
713
|
+
try:
|
|
714
|
+
turns = json.loads(read_input(args.plan_file))
|
|
715
|
+
except ValueError as error:
|
|
716
|
+
raise CliError(f"无效 JSON: {error}") from error
|
|
717
|
+
if not isinstance(turns, list) or not all(isinstance(item, dict) for item in turns):
|
|
718
|
+
raise CliError("--plan-file 必须是由 expert、question 对象组成的数组")
|
|
719
|
+
return {**payload, "turns": turns, "roster": args.roster}
|
|
720
|
+
if not args.expert or question is None:
|
|
721
|
+
raise CliError("需要 --expert 与 --question/--question-file,或改用 --plan-file")
|
|
722
|
+
return {**payload, "expert": args.expert, "question": question, "roster": args.roster}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def plan_state(args):
|
|
726
|
+
"""Keep a host summary's cutoff explicit instead of assuming latest means read."""
|
|
727
|
+
base = f"/api/rooms/{args.room}"
|
|
728
|
+
if args.action == "decision":
|
|
729
|
+
return RequestPlan(f"{base}/scheduler-decisions/{args.id}")
|
|
730
|
+
if args.action == "view":
|
|
731
|
+
return RequestPlan(base, view="state", params=read_context_params(args))
|
|
732
|
+
if args.until < 0:
|
|
733
|
+
raise CliError("--until 不能小于 0")
|
|
734
|
+
payload = {"actor": require_actor(args), "text": text_field(args, "body"), "until": args.until}
|
|
735
|
+
brief = text_field(args, "brief")
|
|
736
|
+
if brief is not None:
|
|
737
|
+
if len(brief.strip()) > MAX_BRIEF_LENGTH:
|
|
738
|
+
raise CliError(f"主持简报最多 {MAX_BRIEF_LENGTH} 个 Unicode 字符;不会自动截断")
|
|
739
|
+
payload["brief"] = brief
|
|
740
|
+
return RequestPlan(base + "/state", "POST", payload, "state")
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def plan_api(args):
|
|
744
|
+
"""Allow literal HTTP requests only under the selected site's API/read roots."""
|
|
745
|
+
path = urlsplit(args.path)
|
|
746
|
+
decoded = unquote(path.path)
|
|
747
|
+
if (path.scheme or path.netloc or path.fragment or "\\" in decoded
|
|
748
|
+
or not decoded.startswith(("/api/", "/r/")) or ".." in decoded.split("/")):
|
|
749
|
+
raise CliError("api 路径必须是本站 /api/... 或 /r/...,不能切换主机或包含 ..")
|
|
750
|
+
if args.method == "GET" and args.input:
|
|
751
|
+
raise CliError("GET 不接受 --input;查询条件直接写在路径中")
|
|
752
|
+
if args.method == "POST" and not args.input:
|
|
753
|
+
raise CliError("POST 需要 --input JSON文件(或 -)")
|
|
754
|
+
return RequestPlan(args.path, args.method, read_object(args.input) if args.input else None)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def plan_run(args):
|
|
758
|
+
"""Build one revision-bound discussion-session lifecycle request."""
|
|
759
|
+
path = f"/api/rooms/{args.room}/discussion"
|
|
760
|
+
if args.action == "view":
|
|
761
|
+
return RequestPlan(path)
|
|
762
|
+
body = {"action": "end" if args.action == "stop" else args.action}
|
|
763
|
+
if args.action == "start" and bool(args.request) == bool(args.revision):
|
|
764
|
+
raise CliError("run start 须二选一:房主用 --revision,主持人用 --request(discuss_start 请求编号)")
|
|
765
|
+
if args.revision:
|
|
766
|
+
body["revision"] = args.revision
|
|
767
|
+
if args.action == "start" and args.request:
|
|
768
|
+
body["request_id"] = args.request
|
|
769
|
+
if args.action == "start":
|
|
770
|
+
goal = text_field(args, "goal")
|
|
771
|
+
if goal is not None:
|
|
772
|
+
body["goal"] = goal
|
|
773
|
+
limits = {key: getattr(args, key) for key in ("max_rounds", "max_minutes", "execution_minutes")
|
|
774
|
+
if hasattr(args, key)}
|
|
775
|
+
if limits:
|
|
776
|
+
body["limits"] = limits
|
|
777
|
+
if args.submission_id:
|
|
778
|
+
body["submission_id"] = args.submission_id
|
|
779
|
+
return RequestPlan(path, "POST", body)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def plan_goal(args):
|
|
783
|
+
"""Build one goal request; budgets and the objective are sent only when given."""
|
|
784
|
+
path = f"/api/rooms/{args.room}/goal"
|
|
785
|
+
if args.action == "view":
|
|
786
|
+
return RequestPlan(path)
|
|
787
|
+
body = {"action": args.action}
|
|
788
|
+
objective = text_field(args, "objective") if args.action in ("start", "update") else None
|
|
789
|
+
if objective is not None:
|
|
790
|
+
body["objective"] = objective
|
|
791
|
+
note = text_field(args, "note") if args.action in ("done", "block") else None
|
|
792
|
+
if note is not None:
|
|
793
|
+
body["note"] = note
|
|
794
|
+
for key in ("max_continues", "max_minutes"):
|
|
795
|
+
if hasattr(args, key):
|
|
796
|
+
body[key] = getattr(args, key)
|
|
797
|
+
if args.action == "start":
|
|
798
|
+
if bool(args.request) == bool(args.revision):
|
|
799
|
+
raise CliError("goal start 须二选一:主持人用 --request(goal_start 请求编号),房主用 --revision")
|
|
800
|
+
if args.request:
|
|
801
|
+
body["request_id"] = args.request
|
|
802
|
+
if getattr(args, "revision", None):
|
|
803
|
+
body["revision"] = args.revision
|
|
804
|
+
return RequestPlan(path, "POST", body)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def plan_mode(args):
|
|
808
|
+
"""Read the open mode, or complete it with the revision read from mode view."""
|
|
809
|
+
path = f"/api/rooms/{args.room}/mode"
|
|
810
|
+
if args.action == "view":
|
|
811
|
+
return RequestPlan(path)
|
|
812
|
+
body = {"action": "done", "revision": args.revision}
|
|
813
|
+
note = text_field(args, "note")
|
|
814
|
+
if note is not None:
|
|
815
|
+
body["note"] = note
|
|
816
|
+
return RequestPlan(path, "POST", body)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def plan_execution(args):
|
|
820
|
+
"""Read or fail one durable member execution."""
|
|
821
|
+
execution_id = args.id or bound_execution(args)
|
|
822
|
+
if not execution_id:
|
|
823
|
+
raise CliError("请指定执行编号,或使用绑定了执行的凭据")
|
|
824
|
+
path = f"/api/rooms/{args.room}/executions/{execution_id}"
|
|
825
|
+
if args.action == "view":
|
|
826
|
+
return RequestPlan(path)
|
|
827
|
+
return RequestPlan(path, "POST", {"action": "fail", "reason": args.reason})
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def plan_member(args):
|
|
831
|
+
"""Register with the credential file; the token itself decides which member this is."""
|
|
832
|
+
if args.action == "review-capability":
|
|
833
|
+
return RequestPlan(f"/api/rooms/{args.room}/members/{args.id}/review-capability")
|
|
834
|
+
if not load_access(args):
|
|
835
|
+
raise CliError("注册须使用成员自己的凭据文件(--access-file 或 DISCUSS_ACCESS_FILE)")
|
|
836
|
+
return RequestPlan(f"/api/rooms/{args.room}/register", "POST", {})
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def plan_review(args):
|
|
840
|
+
"""Read eligible reviewers without creating a request or changing permissions."""
|
|
841
|
+
if args.action == "roster":
|
|
842
|
+
return RequestPlan(f"/api/rooms/{args.room}/reviews/roster")
|
|
843
|
+
if args.action == "ready":
|
|
844
|
+
return RequestPlan(f"/api/rooms/{args.room}/reviews/{args.batch}/ready")
|
|
845
|
+
return RequestPlan(f"/api/rooms/{args.room}/reviews/advance", "POST", {
|
|
846
|
+
"actor": require_actor(args), "batch_id": args.batch, "revision": args.revision, "submission_id": args.submission_id})
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def build_plan(args):
|
|
850
|
+
"""Validate local input before constructing any network request."""
|
|
851
|
+
stdin_count = sum(value == "-" for key, value in vars(args).items()
|
|
852
|
+
if key.endswith("_file") or key == "input")
|
|
853
|
+
if stdin_count > 1:
|
|
854
|
+
raise CliError("一次命令只能有一个字段从标准输入读取")
|
|
855
|
+
handlers = {"room": plan_room, "message": plan_message, "turn": plan_turn,
|
|
856
|
+
"attachment": plan_attachment,
|
|
857
|
+
"state": plan_state, "api": plan_api,
|
|
858
|
+
"run": plan_run, "goal": plan_goal, "mode": plan_mode, "execution": plan_execution, "member": plan_member, "review": plan_review}
|
|
859
|
+
bind_room(args)
|
|
860
|
+
plan = RequestPlan("/api/health") if args.resource == "health" else handlers[args.resource](args)
|
|
861
|
+
fill_submission_id(args, plan)
|
|
862
|
+
if getattr(args, "dry_run", False) and plan.method != "POST" and plan.download_file is None:
|
|
863
|
+
raise CliError("--dry-run 仅用于写入命令;读取命令本身不会修改数据")
|
|
864
|
+
if plan.body is not None and len(json.dumps(plan.body, ensure_ascii=False).encode("utf-8")) > MAX_BODY_BYTES:
|
|
865
|
+
raise CliError(f"请求体超过服务端限制 {MAX_BODY_BYTES} 字节")
|
|
866
|
+
return plan
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def resolve_site(args):
|
|
870
|
+
"""Resolve the flag, then the environment, then the site the access file is bound to; never a built-in default."""
|
|
871
|
+
site = getattr(args, "site", None) or os.getenv("DISCUSS_URL", "")
|
|
872
|
+
if not site:
|
|
873
|
+
# The credential's own site is the only place it is ever sent back to.
|
|
874
|
+
site = str(load_access(args).get("site", ""))
|
|
875
|
+
parsed = urlsplit(site)
|
|
876
|
+
if (parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username
|
|
877
|
+
or parsed.password or parsed.query or parsed.fragment or parsed.path not in ("", "/")):
|
|
878
|
+
raise CliError("请用 --site、DISCUSS_URL 或凭据(--access-file / DISCUSS_ACCESS_FILE / DISCUSS_ACCESS)指定 http(s)://主机[:端口],不带路径、凭据或查询")
|
|
879
|
+
try:
|
|
880
|
+
parsed.port
|
|
881
|
+
except ValueError as error:
|
|
882
|
+
raise CliError(str(error)) from error
|
|
883
|
+
return site.rstrip("/")
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def decode_response(response, plan):
|
|
887
|
+
"""Decode the expected payload while identifying uncertain write responses."""
|
|
888
|
+
if plan.download_file is not None:
|
|
889
|
+
return save_attachment_response(response, plan.download_file)
|
|
890
|
+
try:
|
|
891
|
+
text = response.read().decode("utf-8-sig")
|
|
892
|
+
if plan.path.startswith("/r/") and plan.method == "GET":
|
|
893
|
+
if "text/plain" not in response.headers.get("Content-Type", ""):
|
|
894
|
+
raise ValueError("预期纯文本响应")
|
|
895
|
+
return text
|
|
896
|
+
value = json.loads(text)
|
|
897
|
+
if not isinstance(value, dict):
|
|
898
|
+
raise ValueError("预期 JSON 对象")
|
|
899
|
+
return value
|
|
900
|
+
except (ValueError, UnicodeError) as error:
|
|
901
|
+
raise CliError("响应不是预期的 UTF-8 / JSON 内容", 3,
|
|
902
|
+
outcome="unknown" if plan.method == "POST" else "unavailable") from error
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def save_attachment_response(response, destination):
|
|
906
|
+
"""Validate a complete bounded response before creating an exclusive local file."""
|
|
907
|
+
data = response.read(MAX_ATTACHMENT_BYTES + 1)
|
|
908
|
+
size_header = response.headers.get("Content-Length")
|
|
909
|
+
if len(data) > MAX_ATTACHMENT_BYTES or (size_header and len(data) != int(size_header)):
|
|
910
|
+
raise CliError("附件下载不完整或超过 20 MiB;未保存文件", 3, outcome="unavailable")
|
|
911
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
912
|
+
if response.headers.get("X-Content-SHA256", digest) != digest:
|
|
913
|
+
raise CliError("附件校验失败;未保存文件", 3, outcome="unavailable")
|
|
914
|
+
path = Path(destination)
|
|
915
|
+
created = False
|
|
916
|
+
try:
|
|
917
|
+
with path.open("xb") as target:
|
|
918
|
+
created = True
|
|
919
|
+
target.write(data)
|
|
920
|
+
except OSError as error:
|
|
921
|
+
if created:
|
|
922
|
+
path.unlink(missing_ok=True)
|
|
923
|
+
raise CliError(f"无法保存附件;不会覆盖已有文件: {error}") from error
|
|
924
|
+
return {"path": str(path.resolve()), "size": len(data), "sha256": digest}
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def request_data(plan):
|
|
928
|
+
"""Load a bounded file for binary upload, or encode ordinary JSON requests."""
|
|
929
|
+
if plan.upload_file is not None:
|
|
930
|
+
try:
|
|
931
|
+
with Path(plan.upload_file).open("rb") as source:
|
|
932
|
+
data = source.read(MAX_ATTACHMENT_BYTES + 1)
|
|
933
|
+
except OSError as error:
|
|
934
|
+
raise CliError(f"无法读取上传文件: {error}") from error
|
|
935
|
+
if len(data) > MAX_ATTACHMENT_BYTES:
|
|
936
|
+
raise CliError("单个附件最多 20 MiB")
|
|
937
|
+
return data
|
|
938
|
+
return json.dumps(plan.body, ensure_ascii=False).encode("utf-8") if plan.body is not None else None
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def http_error(error, plan):
|
|
942
|
+
"""Retain useful API errors and distinguish rejected from uncertain writes."""
|
|
943
|
+
try:
|
|
944
|
+
body = error.read().decode("utf-8")
|
|
945
|
+
value = json.loads(body)
|
|
946
|
+
message = value.get("error", body) if isinstance(value, dict) else body
|
|
947
|
+
except (ValueError, UnicodeError, OSError, HTTPException):
|
|
948
|
+
message = f"HTTP {error.code}: {error.reason}"
|
|
949
|
+
uncertain = plan.method == "POST" and error.code >= 500
|
|
950
|
+
return CliError(str(message), 1, http_status=error.code,
|
|
951
|
+
outcome="unknown" if uncertain else "rejected")
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def send_request(plan, url, timeout, headers=None):
|
|
955
|
+
"""Send exactly once, preserving TLS verification and bounded waiting."""
|
|
956
|
+
data = request_data(plan)
|
|
957
|
+
content_type = "application/octet-stream" if plan.upload_file is not None else "application/json"
|
|
958
|
+
request = Request(url, data=data, method=plan.method,
|
|
959
|
+
headers={"Content-Type": content_type, "Accept": "*/*" if plan.download_file else "application/json, text/plain",
|
|
960
|
+
"User-Agent": f"AI-CoDiscuss-CLI/{VERSION}", **(headers or {})})
|
|
961
|
+
try:
|
|
962
|
+
with build_opener(NoRedirect()).open(request, timeout=timeout) as response:
|
|
963
|
+
return decode_response(response, plan)
|
|
964
|
+
except HTTPError as error:
|
|
965
|
+
with error:
|
|
966
|
+
raise http_error(error, plan) from error
|
|
967
|
+
except (URLError, OSError, HTTPException) as error:
|
|
968
|
+
raise CliError(f"网络请求失败: {error}", 3,
|
|
969
|
+
outcome="unknown" if plan.method == "POST" else "unavailable") from error
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def select_fields(value, names):
|
|
973
|
+
"""Project explicitly requested fields without expanding additional context."""
|
|
974
|
+
return {name: value[name] for name in names if name in value}
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def project_result(value, plan, site):
|
|
978
|
+
"""Keep summaries, briefing material, host state, and original text separate."""
|
|
979
|
+
if plan.view == "room_list":
|
|
980
|
+
return {"rooms": [select_fields(room, ROOM_LIST_FIELDS) for room in value["rooms"]]}
|
|
981
|
+
if plan.view in ("overview", "brief", "state"):
|
|
982
|
+
fields = {"overview": OVERVIEW_FIELDS, "brief": BRIEF_FIELDS,
|
|
983
|
+
"state": ("id", "state_text", "state_until", "host_brief", "answered_turns")}[plan.view]
|
|
984
|
+
result = select_fields(value, fields)
|
|
985
|
+
if plan.view == "overview":
|
|
986
|
+
result["guidance"] = GUIDANCE.get(value["phase"], "按本次主持或专家职责,结合最新阶段、安排和必要原文决定下一步。")
|
|
987
|
+
result["url"] = f"{site}/r/{value['id']}"
|
|
988
|
+
return result
|
|
989
|
+
return value
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def command_schema(args, registry):
|
|
993
|
+
"""Describe available commands and fields from the live argparse registry."""
|
|
994
|
+
name = " ".join(args.command)
|
|
995
|
+
if not name:
|
|
996
|
+
return {"version": VERSION, "commands": {key: item.description for key, item in registry.items()},
|
|
997
|
+
"exit_codes": EXIT_CODES, "site": "--site > DISCUSS_URL > 凭据(--access-file / DISCUSS_ACCESS_FILE / DISCUSS_ACCESS)中的 site;无内置默认站点",
|
|
998
|
+
"output": "默认 JSON;成功 stdout,错误 stderr;无交互提示、无自动重试"}
|
|
999
|
+
if name not in registry:
|
|
1000
|
+
raise CliError(f"未知命令 {name};运行 discuss schema 查看可用命令")
|
|
1001
|
+
parser = registry[name]
|
|
1002
|
+
arguments = []
|
|
1003
|
+
for action in parser._actions:
|
|
1004
|
+
if action.dest == "help":
|
|
1005
|
+
continue
|
|
1006
|
+
item = {"name": action.dest, "flags": action.option_strings,
|
|
1007
|
+
"required": action.required, "help": action.help}
|
|
1008
|
+
if action.dest == "actor" and name in ("room create", "room update", "message post",
|
|
1009
|
+
"turn create", "turn cancel", "state set", "attachment upload", "review advance"):
|
|
1010
|
+
item["required"] = True
|
|
1011
|
+
if action.choices is not None:
|
|
1012
|
+
item["choices"] = list(action.choices)
|
|
1013
|
+
if action.default not in (argparse.SUPPRESS, None):
|
|
1014
|
+
item["default"] = action.default
|
|
1015
|
+
arguments.append(item)
|
|
1016
|
+
groups = [{"one_of": [item.dest for item in group._group_actions], "required": group.required}
|
|
1017
|
+
for group in parser._mutually_exclusive_groups]
|
|
1018
|
+
return {"command": name, "description": parser.description, "arguments": arguments,
|
|
1019
|
+
"exclusive_groups": groups, "scope": "CLI 参数约定;服务端协议见 room protocol"}
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def text_output(value):
|
|
1023
|
+
"""Render readable text while retaining complete message bodies and IDs."""
|
|
1024
|
+
if isinstance(value, str):
|
|
1025
|
+
return value
|
|
1026
|
+
if isinstance(value, dict):
|
|
1027
|
+
lines = []
|
|
1028
|
+
for key, item in value.items():
|
|
1029
|
+
if isinstance(item, (dict, list)):
|
|
1030
|
+
lines.append(f"{key}:\n{text_output(item)}")
|
|
1031
|
+
else:
|
|
1032
|
+
lines.append(f"{key}: {item if item is not None else '—'}")
|
|
1033
|
+
return "\n".join(lines)
|
|
1034
|
+
if isinstance(value, list):
|
|
1035
|
+
return "\n\n".join(text_output(item) for item in value) or "(空)"
|
|
1036
|
+
return str(value)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def emit(value, output="json", error=False):
|
|
1040
|
+
"""Write the stable JSON envelope or human text to the correct stream."""
|
|
1041
|
+
stream = sys.stderr if error else sys.stdout
|
|
1042
|
+
if output == "json":
|
|
1043
|
+
rendered = json.dumps(value, ensure_ascii=False, indent=2)
|
|
1044
|
+
else:
|
|
1045
|
+
rendered = text_output(value if error else value["data"])
|
|
1046
|
+
stream.write(rendered + "\n")
|
|
1047
|
+
stream.flush()
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def save_access(path, value):
|
|
1051
|
+
"""Persist private capabilities before sending requests, without printing their content."""
|
|
1052
|
+
target = Path(path)
|
|
1053
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=target.parent, delete=False) as stream:
|
|
1054
|
+
temporary = Path(stream.name)
|
|
1055
|
+
os.chmod(temporary, 0o600)
|
|
1056
|
+
json.dump(value, stream, ensure_ascii=False, indent=2)
|
|
1057
|
+
stream.flush()
|
|
1058
|
+
os.fsync(stream.fileno())
|
|
1059
|
+
try:
|
|
1060
|
+
os.replace(temporary, target)
|
|
1061
|
+
finally:
|
|
1062
|
+
temporary.unlink(missing_ok=True)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def prepare_creation_access(args, site, plan):
|
|
1066
|
+
"""Create or reuse one explicit private credential bundle for a stable creation id."""
|
|
1067
|
+
if getattr(args, "operation", "") != "room create":
|
|
1068
|
+
return
|
|
1069
|
+
if not getattr(args, "access_file", None):
|
|
1070
|
+
raise CliError("建房须用 --access-file 指定私有凭据文件路径;首次自动生成,重试沿用同一文件")
|
|
1071
|
+
path = Path(args.access_file)
|
|
1072
|
+
payload = plan.body
|
|
1073
|
+
if path.exists() and not args.submission_id:
|
|
1074
|
+
# A retry that omits the ID continues the creation this credential file already recorded.
|
|
1075
|
+
payload["submission_id"] = read_object(path).get("submission_id") or payload["submission_id"]
|
|
1076
|
+
access = read_object(path) if path.exists() else {"site": site, "room": "", "token": secrets.token_urlsafe(48),
|
|
1077
|
+
"submission_id": payload.get("submission_id"), "members": {}}
|
|
1078
|
+
if access.get("site") != site or access.get("submission_id") != payload.get("submission_id"):
|
|
1079
|
+
raise CliError("建房凭据文件与本次站点或 submission_id 不符")
|
|
1080
|
+
experts = payload.setdefault("experts", {name: "cli" for name in PARTICIPANTS[1:]})
|
|
1081
|
+
members = []
|
|
1082
|
+
for name in experts:
|
|
1083
|
+
member = access["members"].setdefault(name, {"token": secrets.token_urlsafe(48)})
|
|
1084
|
+
members.append({"name": name, "token": member["token"], "access": "manual"})
|
|
1085
|
+
payload["members"] = members
|
|
1086
|
+
save_access(path, access)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def finish_creation_access(args, site, plan, value):
|
|
1090
|
+
"""Save the confirmed room and separate member files; return paths without secrets."""
|
|
1091
|
+
if getattr(args, "operation", "") != "room create":
|
|
1092
|
+
return {}
|
|
1093
|
+
path = Path(args.access_file).resolve()
|
|
1094
|
+
access = read_object(path)
|
|
1095
|
+
access["room"] = value["id"]
|
|
1096
|
+
save_access(path, access)
|
|
1097
|
+
paths = {}
|
|
1098
|
+
for member in plan.body["members"]:
|
|
1099
|
+
member_path = path.with_name(f"{path.stem}.{member['name']}.json")
|
|
1100
|
+
save_access(member_path, {"site": site, "room": value["id"], "member": member["name"], "token": member["token"]})
|
|
1101
|
+
paths[member["name"]] = str(member_path)
|
|
1102
|
+
return {"owner_access_file": str(path), "member_access_files": paths}
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def access_headers(args, site, path):
|
|
1106
|
+
"""Load a room-scoped secret only for its exact site and room."""
|
|
1107
|
+
access = load_access(args)
|
|
1108
|
+
if not access:
|
|
1109
|
+
return {}
|
|
1110
|
+
expected = f"/api/rooms/{access.get('room', '')}"
|
|
1111
|
+
target = urlsplit(path).path
|
|
1112
|
+
creation = target == "/api/rooms" and bool(access.get("submission_id"))
|
|
1113
|
+
reading = f"/r/{access.get('room', '')}"
|
|
1114
|
+
if access.get("site", "").rstrip("/") != site or not (creation or target == expected or target.startswith(expected + "/") or target == reading or target.startswith(reading + "/")):
|
|
1115
|
+
raise CliError("凭据文件不属于本次站点和房间")
|
|
1116
|
+
token = access.get("token")
|
|
1117
|
+
if not isinstance(token, str) or not 32 <= len(token) <= 256:
|
|
1118
|
+
raise CliError("凭据文件中的 token 无效")
|
|
1119
|
+
headers = {"X-Discussion-Token": token}
|
|
1120
|
+
# A hosting Claude Code or Codex session reports itself on any call, so a manual member can be taken over before it first speaks.
|
|
1121
|
+
for variable, client in SESSION_VARIABLES:
|
|
1122
|
+
session_id = os.getenv(variable, "").strip()
|
|
1123
|
+
if session_id:
|
|
1124
|
+
headers.update({"X-Discussion-Session": session_id, "X-Discussion-Session-Client": client})
|
|
1125
|
+
break
|
|
1126
|
+
return headers
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def preview_body(value):
|
|
1130
|
+
"""Keep private capability material out of offline request previews."""
|
|
1131
|
+
if isinstance(value, dict):
|
|
1132
|
+
return {key: "[redacted]" if key in ("token", "execution_token", "control_hash", "token_hash") else preview_body(item)
|
|
1133
|
+
for key, item in value.items()}
|
|
1134
|
+
if isinstance(value, list):
|
|
1135
|
+
return [preview_body(item) for item in value]
|
|
1136
|
+
return value
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def run(args, registry):
|
|
1140
|
+
"""Execute a parsed command, with schema and previews remaining offline."""
|
|
1141
|
+
if args.resource == "schema":
|
|
1142
|
+
return {"ok": True, "data": command_schema(args, registry)}
|
|
1143
|
+
plan = build_plan(args)
|
|
1144
|
+
site = resolve_site(args)
|
|
1145
|
+
timeout = getattr(args, "timeout", DEFAULT_TIMEOUT)
|
|
1146
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
1147
|
+
raise CliError("--timeout 必须为有限正数")
|
|
1148
|
+
url = site + plan.path
|
|
1149
|
+
if plan.params:
|
|
1150
|
+
url += "?" + urlencode(plan.params)
|
|
1151
|
+
meta = {"site": site, "method": plan.method, "path": plan.path}
|
|
1152
|
+
if getattr(args, "dry_run", False):
|
|
1153
|
+
return {"ok": True, "data": {"method": plan.method, "url": url, "body": preview_body(plan.body),
|
|
1154
|
+
**({"file": plan.upload_file} if plan.upload_file else {}),
|
|
1155
|
+
**({"destination": plan.download_file} if plan.download_file else {})},
|
|
1156
|
+
"meta": {**meta, "dry_run": True, "validation": "仅本地参数;未验证房间、身份或服务端状态"}}
|
|
1157
|
+
try:
|
|
1158
|
+
prepare_creation_access(args, site, plan)
|
|
1159
|
+
headers = access_headers(args, site, plan.path)
|
|
1160
|
+
value = send_request(plan, url, timeout, headers) if headers else send_request(plan, url, timeout)
|
|
1161
|
+
result = project_result(value, plan, site)
|
|
1162
|
+
if getattr(args, "operation", "") == "room create":
|
|
1163
|
+
result.update(finish_creation_access(args, site, plan, value))
|
|
1164
|
+
except CliError as error:
|
|
1165
|
+
error.details.update(meta)
|
|
1166
|
+
if plan.method == "POST":
|
|
1167
|
+
error.details["submission_id"] = (plan.body or plan.params or {}).get("submission_id")
|
|
1168
|
+
error.details["hint"] = "未自动重试。创建结果未知时用对应 list --submission-id 查询;确认需要重试才沿用原编号和原始内容。"
|
|
1169
|
+
raise
|
|
1170
|
+
except (KeyError, TypeError) as error:
|
|
1171
|
+
raise CliError("响应结构与 CLI 不兼容,请用 api 命令检查服务版本", 3,
|
|
1172
|
+
**meta, outcome="unknown" if plan.method == "POST" else "unavailable") from error
|
|
1173
|
+
body = plan.body or {}
|
|
1174
|
+
if getattr(args, "operation", "") == "message post" and len(body.get("to") or []) == 1 \
|
|
1175
|
+
and not body.get("handoff") and not body.get("next_review"):
|
|
1176
|
+
# A single dispatch without handoff ends the chain at the member's answer; hosts often expect it back.
|
|
1177
|
+
meta["hint"] = (f"若你是主持人:只点名 {body['to'][0]} 一位且未加 --handoff,它的回答直接给用户,你不会再被唤醒。"
|
|
1178
|
+
"以后需要据结果安排下一步(如评审、验证)时派活加 --handoff。")
|
|
1179
|
+
return {"ok": True, "data": result, "meta": meta}
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def main(argv=None):
|
|
1183
|
+
"""Run the CLI without prompts; return documented process exit codes."""
|
|
1184
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
1185
|
+
if hasattr(stream, "reconfigure"):
|
|
1186
|
+
stream.reconfigure(encoding="utf-8")
|
|
1187
|
+
args = None
|
|
1188
|
+
try:
|
|
1189
|
+
parser, registry = build_parser(prog=os.getenv("DISCUSS_CLI_NAME", "discuss"))
|
|
1190
|
+
args = parser.parse_args(argv)
|
|
1191
|
+
emit(run(args, registry), getattr(args, "output", "json"))
|
|
1192
|
+
return 0
|
|
1193
|
+
except CliError as error:
|
|
1194
|
+
output = getattr(args, "output", "json")
|
|
1195
|
+
emit({"ok": False, "error": {"message": str(error), "exit_code": error.code, **error.details}},
|
|
1196
|
+
output, error=True)
|
|
1197
|
+
return error.code
|
|
1198
|
+
except (ValueError, UnicodeError, OSError) as error:
|
|
1199
|
+
emit({"ok": False, "error": {"message": str(error), "exit_code": 2}}, error=True)
|
|
1200
|
+
return 2
|
|
1201
|
+
except KeyboardInterrupt:
|
|
1202
|
+
emit({"ok": False, "error": {"message": "已中断;若正在写入,请先读取记录核对结果。",
|
|
1203
|
+
"exit_code": 130, "outcome": "unknown"}}, error=True)
|
|
1204
|
+
return 130
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
if __name__ == "__main__":
|
|
1208
|
+
sys.exit(main())
|