ai_co_discuss 0.2.37 → 0.2.39

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.
@@ -38,7 +38,7 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
38
38
 
39
39
  自由房间的普通成员可在回答中用 `--to 成员名` 或正文 `@成员` 提出回应建议,不能点名自己;这不会派发任务。网页显示“建议请 X 回应”,只有房主点击“转为请求”后才建立关联原回答的新用户任务链。任务的 `request_messages` 可能指向已读游标之前的原回答,须用 `message view ROOM ID` 补读;用户确认消息不代替原建议。不要把建议显示或复制入口当作已经通知;代码与引用里的正文 @ 不生成建议。
40
40
 
41
- **手动接入:** 用户已选手动时沿用人工触发或原生定时;需要持续参与才读 [手动持续参与](references/manual.md)。没有任务文件本身不代表手动授权:若房间正在自动运行,等待本次交付或核对缺项,不从旧文件接管。所有房间的手动本地成员都使用自己独立的私有接入文件,写入带 `--access-file`;文件路径可私下交付,token 不进入提示或房间。房主控制文件不能代替成员文件署名,在线成员没有自行写入资格。
41
+ **手动接入:** 用户已选手动时沿用人工触发或原生定时;需要持续参与才读 [手动持续参与](references/manual.md)。没有任务文件本身不代表手动授权:若房间正在自动运行,等待本次交付或核对缺项,不从旧文件接管。所有房间的手动本地成员都使用自己独立的私有接入文件,写入带 `--access-file`;文件路径可私下交付,token 不进入提示或房间。房主控制文件不能代替成员文件署名,在线成员没有自行写入资格。手动本地成员首次读取房间后立即执行一次 `member register ROOM --as 本人 --access-file FILE` 注册;未注册前网站显示“待注册”,不算到场,也不会被本机服务接管。会话 ID 由 CLI 从宿主会话自动附带,不自行填写;凭证重新签发后须重新注册。
42
42
 
43
43
  ## 先读什么
44
44
 
@@ -229,6 +229,7 @@ def free_view(connection, room_id):
229
229
  members = []
230
230
  for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
231
231
  member = {key: row[key] for key in MEMBER_FIELDS}
232
+ member["registered_at"] = registered_at(connection, row)
232
233
  latest = connection.execute("SELECT * FROM member_executions WHERE member_id=? ORDER BY id DESC LIMIT 1",
233
234
  (row["id"],)).fetchone()
234
235
  member["execution"] = public_execution(latest)
@@ -258,6 +259,30 @@ def require_member(connection, room_id, token, name=None):
258
259
  return row
259
260
 
260
261
 
262
+ def registered_at(connection, member):
263
+ """Return when the member registered with its current credential, or '' while it has not."""
264
+ row = connection.execute("SELECT registered_at FROM member_registrations WHERE member_id=? AND token_hash=?",
265
+ (member["id"], member["token_hash"])).fetchone()
266
+ return row[0] if row else ""
267
+
268
+
269
+ def register_member(connection, room_id, token, now):
270
+ """A manual member announces itself with its own credential; repeating it keeps the first time.
271
+
272
+ The session ID is not part of the request: the CLI attaches it to every call and the room records it separately.
273
+ """
274
+ begin_write(connection)
275
+ member = require_member(connection, room_id, token)
276
+ if member["access"] != "manual":
277
+ raise ValueError("只有手动接入的成员需要注册")
278
+ stamp = registered_at(connection, member) or now
279
+ connection.execute("""INSERT INTO member_registrations (member_id,room_id,token_hash,registered_at) VALUES (?,?,?,?)
280
+ ON CONFLICT(member_id) DO UPDATE SET token_hash=excluded.token_hash,registered_at=excluded.registered_at""",
281
+ (member["id"], room_id, member["token_hash"], stamp))
282
+ reported = connection.execute("SELECT client FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
283
+ return {"member": member["name"], "registered_at": stamp, "session_client": reported[0] if reported else ""}
284
+
285
+
261
286
  def authorize_free_write(connection, room_id, token, operation, payload):
262
287
  """Decide who is writing: the owner, or one member posting its own answer.
263
288
 
@@ -784,8 +809,45 @@ def save_discussion_experts(connection, room_id, experts):
784
809
  connection.execute("UPDATE topic_settings SET experts=? WHERE room_id=?", (json.dumps(experts, ensure_ascii=False), room_id))
785
810
 
786
811
 
812
+ def current_round_speakers(connection, room_id, experts):
813
+ """Experts who have given an opinion in the latest unfinished round (empty when it just completed)."""
814
+ spoken = set()
815
+ rows = connection.execute("""SELECT messages.author FROM messages
816
+ JOIN message_details AS details ON details.message_id=messages.id AND details.kind='opinion'
817
+ WHERE messages.room_id=? ORDER BY messages.id""", (room_id,))
818
+ for row in rows:
819
+ if row["author"] not in experts:
820
+ continue
821
+ spoken.add(row["author"])
822
+ if len(spoken) == len(experts):
823
+ spoken = set()
824
+ return spoken
825
+
826
+
827
+ def place_invited_expert(connection, room_id, name):
828
+ """Move the just-queued newcomer to where the current round completes: after the last queued expert who
829
+ has not spoken this round, before the first one who would speak again. Rows keep their ids; only who and what shift."""
830
+ rows = connection.execute("SELECT id,expert,question FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id", (room_id,)).fetchall()
831
+ if len(rows) < 2 or rows[-1]["expert"] != name:
832
+ return
833
+ _, experts, _ = discussion_roster(connection, room_id)
834
+ spoken = current_round_speakers(connection, room_id, experts)
835
+ open_turn = connection.execute("SELECT expert FROM discussion_turns WHERE room_id=? AND status IN ('pending','notified')", (room_id,)).fetchone()
836
+ if open_turn:
837
+ spoken.add(open_turn["expert"])
838
+ slot = len(rows) - 1
839
+ for index, row in enumerate(rows[:-1]):
840
+ if row["expert"] in spoken:
841
+ slot = index
842
+ break
843
+ spoken.add(row["expert"])
844
+ ordered = rows[:slot] + [rows[-1]] + rows[slot:-1]
845
+ for target, source in zip(rows, ordered):
846
+ connection.execute("UPDATE discussion_turns SET expert=?,question=? WHERE id=? AND status='queued'", (source["expert"], source["question"], target["id"]))
847
+
848
+
787
849
  def queue_invited_expert(connection, room_id, name, now):
788
- """Append a new expert to an existing live plan without changing its current speaker or hold."""
850
+ """Queue a new expert in an existing live plan at its round-completing slot, without changing the current speaker or hold."""
789
851
  connection.execute("""INSERT INTO discussion_turns (room_id,expert,question,status,submission_id,created_at)
790
852
  SELECT settings.room_id,member.name,
791
853
  CASE WHEN settings.phase='initial' THEN '欢迎加入:请先读取议题,独立给出你的开场观点。'
@@ -797,6 +859,7 @@ def queue_invited_expert(connection, room_id, name, now):
797
859
  AND NOT EXISTS (SELECT 1 FROM discussion_limits WHERE room_id=settings.room_id AND closing_at>0)
798
860
  AND EXISTS (SELECT 1 FROM discussion_turns WHERE room_id=settings.room_id AND status IN ('pending','notified','queued'))""",
799
861
  (now, room_id, name))
862
+ place_invited_expert(connection, room_id, name)
800
863
 
801
864
 
802
865
  def add_discussion_member(connection, room_id, token, payload, now):
@@ -873,6 +936,8 @@ def adopt_manual_member(connection, room_id, member, payload):
873
936
  """Hand a manual expert to the local service once its CLI reported the session it runs in; the owner's service resumes that session."""
874
937
  reported = connection.execute("SELECT session_id FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
875
938
  run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
939
+ if not registered_at(connection, member):
940
+ raise ValueError("成员尚未注册,不能转为托管")
876
941
  if not reported or reported["session_id"] != payload.get("session_id") or payload.get("adapter") not in ADOPTABLE_ADAPTERS:
877
942
  raise ValueError("成员尚未上报与之相符的会话,不能转为托管")
878
943
  if member["access"] != "manual" or member["status"] != "active" or not run or run["mode"] != "automatic":
@@ -546,6 +546,13 @@ CREATE TABLE IF NOT EXISTS member_sessions (
546
546
  session_id TEXT NOT NULL,
547
547
  client TEXT NOT NULL
548
548
  );
549
+ -- A manual member's own registration; it is bound to the credential it used, so a re-issued credential must register again.
550
+ CREATE TABLE IF NOT EXISTS member_registrations (
551
+ member_id INTEGER PRIMARY KEY REFERENCES room_members(id) ON DELETE CASCADE,
552
+ room_id TEXT NOT NULL,
553
+ token_hash TEXT NOT NULL,
554
+ registered_at TEXT NOT NULL
555
+ );
549
556
  CREATE TABLE IF NOT EXISTS member_executions (
550
557
  id INTEGER PRIMARY KEY AUTOINCREMENT,
551
558
  room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
@@ -600,6 +607,11 @@ CREATE TABLE IF NOT EXISTS scheduler_decisions (
600
607
  created_at TEXT NOT NULL
601
608
  );
602
609
  CREATE INDEX IF NOT EXISTS scheduler_decisions_room ON scheduler_decisions(room_id,id);
610
+ -- What the decision model was asked and chose, shown folded under the decision; not part of backups.
611
+ CREATE TABLE IF NOT EXISTS scheduler_decision_details (
612
+ decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
613
+ detail TEXT NOT NULL
614
+ );
603
615
  -- Smart scheduling's suggestion that another member answer an unnamed message. The host keeps the message;
604
616
  -- the user may adopt before expires_at (waiting until the host's request ends for mode 'after'), otherwise it lapses.
605
617
  CREATE TABLE IF NOT EXISTS member_recommendations (
package/backend/server.py CHANGED
@@ -23,7 +23,7 @@ from .free_room import (add_member, after_free_message, authorize_free_write, ch
23
23
  inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
24
24
  discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
25
25
  from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions, sync_participants
26
- from .free_room import adopt_recommendation, recommend_member
26
+ from .free_room import adopt_recommendation, recommend_member, register_member
27
27
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
28
28
  from .room_import import import_room, imported_attachment
29
29
  from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
@@ -74,6 +74,7 @@ MAX_QUESTION_LENGTH = 1000
74
74
  MAX_PLAN_TURNS = 10
75
75
  RECENT_DECISIONS = 100
76
76
  MAX_DECISION_LABEL = 200
77
+ MAX_DECISION_DETAIL = 4000
77
78
  SCHEDULER_PROVIDERS = ("jev", "decisions")
78
79
  ROOM_BACKUP_QUERIES = {
79
80
  "rooms": "SELECT * FROM rooms WHERE id=?",
@@ -211,13 +212,20 @@ def require_room(connection, room_id):
211
212
  room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
212
213
  room["mode"] = room_mode(connection, room_id)
213
214
  room["members"] = [dict(row) for row in connection.execute(
214
- """SELECT m.id,m.name,m.adapter,m.role,m.status,m.access,m.session_mode,COALESCE(s.session_id,'') AS reported_session,COALESCE(s.client,'') AS reported_client
215
- FROM room_members AS m LEFT JOIN member_sessions AS s ON s.member_id=m.id WHERE m.room_id=? AND m.status<>'removed' ORDER BY m.id""", (room_id,))]
215
+ """SELECT m.id,m.name,m.adapter,m.role,m.status,m.access,m.session_mode,COALESCE(s.session_id,'') AS reported_session,COALESCE(s.client,'') AS reported_client,
216
+ COALESCE(r.registered_at,'') AS registered_at
217
+ FROM room_members AS m LEFT JOIN member_sessions AS s ON s.member_id=m.id
218
+ LEFT JOIN member_registrations AS r ON r.member_id=m.id AND r.token_hash=m.token_hash WHERE m.room_id=? AND m.status<>'removed' ORDER BY m.id""", (room_id,))]
216
219
  option = connection.execute("SELECT named_followup FROM discussion_options WHERE room_id=?", (room_id,)).fetchone()
217
220
  # Rooms created before the option keep the host's named follow-ups.
218
221
  room["named_followup"] = bool(option[0]) if option else True
219
222
  room["scheduler_decisions"] = [dict(row) for row in connection.execute(
220
223
  "SELECT * FROM scheduler_decisions WHERE room_id=? ORDER BY id DESC LIMIT ?", (room_id, RECENT_DECISIONS))][::-1]
224
+ details = {row[0]: json.loads(row[1]) for row in connection.execute(
225
+ "SELECT decision_id,detail FROM scheduler_decision_details WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))}
226
+ for decision in room["scheduler_decisions"]:
227
+ decision["detail"] = details.get(decision["id"])
228
+ room["host_arrangements"] = host_arrangements(connection, room_id, room["scheduler_decisions"])
221
229
  parent = connection.execute("""SELECT rooms.id,rooms.title FROM room_links JOIN rooms ON rooms.id=room_links.parent_id
222
230
  WHERE room_links.room_id=?""", (room_id,)).fetchone()
223
231
  room["parent_room"] = dict(parent) if parent else None
@@ -264,6 +272,23 @@ def require_floor(room, speaker):
264
272
  raise FileExistsError("请先领取本人发言权;他人持有或尚未被点名时不能发言或安排")
265
273
 
266
274
 
275
+ def host_arrangements(connection, room_id, decisions):
276
+ """Each plan the host created (turns saved in one batch), anchored after the latest answer before it.
277
+
278
+ Invitation turns are not plans, and a plan the smart scheduler applied is already shown by its own decision.
279
+ """
280
+ plans, latest_answer = [], 0
281
+ for row in connection.execute("SELECT id,expert,question,answer_id,created_at,submission_id FROM discussion_turns WHERE room_id=? ORDER BY id", (room_id,)):
282
+ if not row["submission_id"].startswith("invite:"):
283
+ if plans and plans[-1]["created_at"] == row["created_at"]:
284
+ plans[-1]["turns"].append({"expert": row["expert"], "question": row["question"]})
285
+ else:
286
+ plans.append({"id": row["id"], "after_message_id": latest_answer, "created_at": row["created_at"], "turns": [{"expert": row["expert"], "question": row["question"]}]})
287
+ latest_answer = max(latest_answer, row["answer_id"] or 0)
288
+ applied = {item["after_message_id"] for item in decisions if item["applied"]}
289
+ return [plan for plan in plans if plan["after_message_id"] and plan["after_message_id"] not in applied][-RECENT_DECISIONS:]
290
+
291
+
267
292
  def discussion_rounds(connection, room_id, experts):
268
293
  """Group complete history metadata until every configured expert has spoken.
269
294
 
@@ -549,8 +574,10 @@ def manage_room(connection, room_id, action, payload):
549
574
 
550
575
  def delete_room_records(connection, room_id):
551
576
  """Remove a room's dependent records in the caller's transaction."""
552
- for table in ("member_recommendations", "member_requests", "member_executions", "member_sessions", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
577
+ for table in ("member_recommendations", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
553
578
  "discussion_options", "scheduler_decisions"):
579
+ if table == "scheduler_decisions":
580
+ connection.execute("DELETE FROM scheduler_decision_details WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
554
581
  connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
555
582
  connection.execute("DELETE FROM message_timings WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
556
583
  connection.execute("DELETE FROM message_summaries WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
@@ -1013,6 +1040,12 @@ def record_scheduler_decision(connection, room_id, token, payload):
1013
1040
  raise ValueError("须提供供应商 provider、是否执行 applied 与非负的 after_message_id")
1014
1041
  cursor = connection.execute("""INSERT INTO scheduler_decisions (room_id,after_message_id,kind,label,confidence,applied,provider,created_at)
1015
1042
  VALUES (?,?,?,?,?,?,?,?)""", (room_id, after, kind, label.strip(), confidence, int(payload["applied"]), payload["provider"], utc_now()))
1043
+ detail = payload.get("detail")
1044
+ if detail is not None:
1045
+ text = json.dumps(detail, ensure_ascii=False)
1046
+ if not isinstance(detail, list) or len(detail) > 4 or len(text) > MAX_DECISION_DETAIL:
1047
+ raise ValueError(f"detail 须为最多 4 项、不超过 {MAX_DECISION_DETAIL} 字的列表")
1048
+ connection.execute("INSERT INTO scheduler_decision_details (decision_id,detail) VALUES (?,?)", (cursor.lastrowid, text))
1016
1049
  return dict(connection.execute("SELECT * FROM scheduler_decisions WHERE id=?", (cursor.lastrowid,)).fetchone())
1017
1050
 
1018
1051
 
@@ -1614,6 +1647,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1614
1647
  record_reported_session(connection, room_id, token, {"session_id": self.headers.get("X-Discussion-Session"),
1615
1648
  "session_client": self.headers.get("X-Discussion-Session-Client")})
1616
1649
  connection.commit()
1650
+ if parts[3:] == ["register"] and method == "POST":
1651
+ result = register_member(connection, room_id, token, utc_now())
1652
+ connection.commit()
1653
+ return self.respond(result)
1617
1654
  free = room_mode(connection, room_id) == "free"
1618
1655
  if free and parts[3:4] in (["inbox"], ["executions"], ["members"], ["suggestions"], ["recommendations"]):
1619
1656
  return self.handle_free_api(connection, parts, method)
@@ -220,7 +220,7 @@ Python 通过进程内条件变量唤醒订阅者,不占用数据库连接,
220
220
  ## 讨论选项与智能调度记录
221
221
 
222
222
  - `discussion_options`:建房时的讨论选项,目前只有 `named_followup`(0/1,建房 `named_followup` 布尔值,默认 false,计入建房幂等比对)。没有记录的旧房间视为 true;房间视图返回 `named_followup`。
223
- - `scheduler_decisions`:讨论中每次智能调度判断的可见记录(`kind` 为 `interjection` / `round`,`label`、`confidence`、`applied`、`provider`、`after_message_id`),由接入程序用运行控制凭据 `POST scheduler-decisions` 写入,不是消息,不影响轮次、发言权与暂扣;房间视图 `scheduler_decisions` 返回最近 100 条。
223
+ - `scheduler_decisions`:讨论中每次智能调度判断的可见记录(`kind` 为 `interjection` / `round`,`label`、`confidence`、`applied`、`provider`、`after_message_id`),由接入程序用运行控制凭据 `POST scheduler-decisions` 写入,不是消息,不影响轮次、发言权与暂扣;房间视图 `scheduler_decisions` 返回最近 100 条;每条可带 `detail`(独立表 `scheduler_decision_details`,由 `POST scheduler-decisions` 的 `detail` 写入:每个问题的题目说明、选项,以及决策模型选了哪项和各选项概率,最多 4 项、4000 字,不含房间消息摘录,不随备份导出),网页默认折叠,点开才显示“问 / 答”。房间视图另附 `host_arrangements`:由 `discussion_turns` 按同一批创建时间分组得到的主持人安排(不含邀请追加的开场安排,也不含已由智能调度写入决策的那一批),`after_message_id` 取该批之前最新的回答,网页在该回答后显示与智能调度卡片同样式的“主持人安排:A → B → C”一行,点开才逐条显示专家与主持人写的任务说明(`turns`);没有可跟随的回答(讨论开场)时不显示。不新增表。
224
224
  - 两表都是独立表,D1 重新应用 schema 即可;备份、导入(`after_message_id` 随消息重编号)与删除一并处理。智能调度代主持人执行的写入不新增权限,沿用该主持事件的执行凭据。
225
225
 
226
226
  ## 自动运行协议
@@ -249,6 +249,7 @@ running 状态即使只有 pending 事件,也必须先暂停才能重新启动
249
249
 
250
250
  - **凭据**:建房时由客户端提供房间控制凭据和每位成员的成员凭证,服务端只存哈希。用户身份的房间写入(发言、附件、存档、恢复、删除、打断)都须控制凭据;房间排序和归入文件夹只改共享目录,与站点级文件夹操作一样不需要凭据;无凭据、错误凭据、成员凭证或他房凭据一律只读。成员凭证只能以该成员身份发言或上传附件。声明字段(`author`、`source`、localhost)不代替凭据。
251
251
  - **成员**:`room_members` 以 `id` 为稳定主键,`access` 为 `runner`(接入程序投递)、`manual`(成员自行读写)或 `online`(只读、原文代发),`session_mode` 标记是否借用用户正在使用的会话。房主用控制凭据 `POST members` 运行中加入成员,`POST members/ID {action}` 让成员离开(`away`,作废其进行中执行与未投递请求)、请回(`active`,必须提供不同于原凭证的新凭证,旧凭证副本不再有效)或移除(`remove`,保留历史发言)。同一成员凭证不能复用于其他成员或房主;相同凭证与配置的邀请、请回重试返回已有结果。会话续接由本机接入程序负责,服务端不保存会话绑定;唯一例外是手动成员上报的会话:带凭据的请求可带请求头 `X-Discussion-Session` 与 `X-Discussion-Session-Client`,`message post` 也可在正文带 `session_id` 与 `session_client`(CLI 读取 `CLAUDE_CODE_SESSION_ID` 或 `CODEX_THREAD_ID`,取值 `claude` / `codex`),服务端在 `member_sessions` 保存该手动成员最近上报的会话,成员视图以 `reported_session` 暴露。房主用 `POST members/ID {action:"adopt", adapter:"claude", session_id}`(须与上报一致、仅全自动讨论中在场的手动成员)把成员改为 `runner`、`borrowed` 并清除上报,本机接入程序据此接管。两套后端行为一致。
252
+ - **手动成员注册**:`member_registrations`(`member_id`、`room_id`、`token_hash`、`registered_at`)记录手动成员自己发起的注册,`POST /api/rooms/ID/register` 只接受该成员自己的凭证(房主凭据与他人凭证返回 403),重复调用保留首次时间。记录绑定注册时的凭证哈希:请回换发新凭证后旧记录不再匹配,视图中的 `registered_at` 回到空串,须重新注册。会话 ID 不属于注册内容,仍由 CLI 头部采集进 `member_sessions`;`adopt` 要求已注册。讨论房间的成员视图与自由房间 `free.members` 都带 `registered_at`;新表为独立表,D1 重新应用 schema 即可。
252
253
  - **请求**:用户消息按结构化接收者 `to`(成员名)为每位成员生成一条请求;省略且仅有一位在场成员时默认点名该成员,多位成员时交给主持人(`route`),只有在线成员而没有主持人时只作留言,不广播。网页只从正文中代码与引用之外的 `@成员` 生成 `to`,没有 @ 时省略 `to`。请求状态为 `pending`(待投递)→ `delivering`(处理中)→ `completed` / `cancelled` / `failed`。
253
254
  - **执行**:`member_executions` 每位成员最多一个 `dispatched/started`(部分唯一索引),不同成员可并行。接入程序用控制凭据 `GET inbox`、`POST executions {member_id, client_id}`(同一 `client_id` 返回同一执行)把成员的全部待投递请求打包;`POST executions/ID {action}` 支持 `renew`(租约 120 秒,不超过单次执行时限,默认 20 分钟)、`fail`(请求标记失败)和 `release`(仅确认未送达时退回队列)。租约过期的执行在下次读取或领取时标记失败,不自动重发。
254
255
  - **主持人与任务链**:用户是需求方,不担任主持人。只要有在场的非在线成员,房间就保持恰好一位主持人:成员变动时,离开或移出的主持人失去角色,由第一位在场的 runner 成员(没有则手动成员)接任;建房和每条房主消息前也做同样的补位,因此旧房间在下一条消息时自动获得主持人。房主用 `POST members/ID {action:"role", role:"host"}` 更换主持人(在线成员不能担任),把现任主持人改为 `member` 会被拒绝。多位成员时未点名的房主消息生成给主持人的 `route` 请求。主持人的回答可带 `to` 点名其他成员(不能点名自己),这些请求组成一个批次(`free_batches`);批次内全部进入终态后,只要有一个完成就给主持人生成一次 `handoff` 请求,全部失败则停下该任务链。每条房主消息开启一条任务链(`chain_id` 为该消息编号),链上 AI 发起的请求(派活与回交)最多 8 次,超出即停下(`free_chains`,原因 `budget` / `all_failed`),房主发新消息开启新链。Worker 用同一批次内的集合 SQL 完成回交,回交请求按(派活消息,主持人)唯一,重复执行不会重复唤醒。
package/docs/cli.md CHANGED
@@ -157,6 +157,8 @@ codiscuss message post ROOM --as Claude --kind chat --execution 执行编号 --b
157
157
 
158
158
  手动接入的成员没有执行编号:`room view` 的 `free.requests` 列出它的待处理请求,回答时用 `--request 请求编号`(可重复)关闭对应请求;不带 `--request` 的回答是普通发言,不关闭任何请求。`--request` 与 `--execution` 不能同用。
159
159
 
160
+ 手动接入成员首次读取房间后执行一次 `member register ROOM --as 本人 --access-file FILE`:网站记录该成员已用自己的凭证注册,重复执行保留首次时间,房主凭据或他人凭证被拒绝(403),自动接入成员不需要注册(400)。命令不带会话 ID,CLI 在每次请求头中自动附带宿主会话(`CLAUDE_CODE_SESSION_ID` / `CODEX_THREAD_ID`)。未注册的成员在网页显示“待注册”,也不会被本机服务接管。
161
+
160
162
  主持人在回答中用 `--to 成员名`(可重复)点名派活,例如 `--to Codex --to Gemini`;不能点名自己。被点名的成员都结束后,主持人会再收到一次 `handoff` 请求。普通成员也可用 `--to` 或正文 `@成员` 提出回应建议,但不会派发;房主在网页点击“转为请求”后才建立关联原消息的新用户任务链。代码与引用里的正文 @ 不生成建议。
161
163
 
162
164
  ## 写入核对与错误处理
package/docs/runner.md CHANGED
@@ -316,7 +316,7 @@ Codex 要求 CLI 0.163.0 或更新版本,使用 permission profiles 和本地
316
316
 
317
317
  `codiscuss launch room-member` 在运行中的自由房间邀请(`add`)或请回(`rejoin`)自动成员:先用房间控制凭据核对房主身份,在登记配置中写入成员的软件配置与新凭证,再通知网站;网页通过 `member_token` 提供其已保存的新凭证,结果不明时沿用同一凭证重试。网页邀请默认项目只读,显式选择可写后通过该成员的 `participants[name].project_access` 传递,不改变其他成员;重试保留最初选择。该命令也可为手动成员生成配置旁的 `manual-成员名.access.json`,回执只含文件路径;网页手动邀请和请回则直接在网站完成。常驻服务读取更新后的配置时逐个成员核对:新成员直接采用,已绑定会话的成员若项目、cwd 或接入方式变化,只阻止该成员并报告原因,不影响其他成员。
318
318
 
319
- 运行中的讨论同样可用 `room-member` 邀请或请回专家,但只接受由本机唤醒的成员(不接受 `access: manual`)。`participants[name].session_id` 指定已有会话时,成员标记为 `borrowed`,首次投递即续接该会话;网页仅对 Codex CLI / App 与 Claude 提供此项,agy 照旧拒绝。邀请前在本机核对会话确实存在(Codex 查 `CODEX_HOME/sessions`,Claude 查 `CLAUDE_CONFIG_DIR/projects`),填错时当场报错,不会让讨论在轮到它时暂停;Claude 会话只能在原目录续接,未指定项目时沿用原目录,项目目录不同则拒绝。Codex 会话若正被 App 持有,CLI 续接报 active writer,进度看板在暂停原因下提供“切换到 Codex App”,保存采用后可在弹窗内重启。常驻服务每轮检查成员视图:手动 Claude 专家带 `reported_session` 时,`adopt_member_profile` 在本机配置登记其借用会话(会话须存在于 `CLAUDE_CONFIG_DIR/projects` 且 Claude 软件已配置),随后调用 `adopt` 把成员转为托管;任一步失败则保持手动并记录一次日志,同一会话 ID 不重试。讨论配置只新增参与者时常驻服务直接采用,讨论不暂停;已有参与者的项目、cwd 或身份变化仍须暂停后重新接入。
319
+ 运行中的讨论同样可用 `room-member` 邀请或请回专家,但只接受由本机唤醒的成员(不接受 `access: manual`)。`participants[name].session_id` 指定已有会话时,成员标记为 `borrowed`,首次投递即续接该会话;网页仅对 Codex CLI / App 与 Claude 提供此项,agy 照旧拒绝。邀请前在本机核对会话确实存在(Codex 查 `CODEX_HOME/sessions`,Claude 查 `CLAUDE_CONFIG_DIR/projects`),填错时当场报错,不会让讨论在轮到它时暂停;Claude 会话只能在原目录续接,未指定项目时沿用原目录,项目目录不同则拒绝。Codex 会话若正被 App 持有,CLI 续接报 active writer,进度看板在暂停原因下提供“切换到 Codex App”,保存采用后可在弹窗内重启。常驻服务每轮检查成员视图:已注册(`registered_at` 非空)且带 `reported_session` 的手动 Claude 专家,`adopt_member_profile` 在本机配置登记其借用会话(会话须存在于 `CLAUDE_CONFIG_DIR/projects` 且 Claude 软件已配置),随后调用 `adopt` 把成员转为托管;任一步失败则保持手动并记录一次日志,同一会话 ID 不重试。讨论配置只新增参与者时常驻服务直接采用,讨论不暂停;已有参与者的项目、cwd 或身份变化仍须暂停后重新接入。
320
320
 
321
321
  每次检查读取 `inbox`:成员没有进行中的执行且有待投递消息时,先持久化本地 `client_id` 再领取执行,然后用原适配器续接该成员的绑定会话;任务文件列出执行编号、待处理消息和已读起点,凭据文件只含成员凭证。进程退出而网站未收到回答、软件报错或超过单次执行时限时报告失败;只有确认未送达才退回队列重试,最多 3 次。领取后、调用前中断的投递不自动重发。主持人的任务文件带 `role: "host"` 和每条请求的 `request_purposes`(`ask` / `route` / `handoff`),说明里写明如何用 `--to` 派活与何时结束。网站上执行被打断、成员离开或被移除后,只停止该成员的进程;不同成员各自投递,可以并行。成员配置了 cwd 且位于 Git 工作区时,执行前后各做一次快照,执行结束且进程退出后报告改动的文件,供网站提示可能冲突;服务重启会丢失尚未报告的快照。只能在网页创建自由房间,不支持对话入口。
322
322
 
@@ -326,14 +326,14 @@ Codex 要求 CLI 0.163.0 或更新版本,使用 permission profiles 和本地
326
326
 
327
327
  `runner/scheduler.mjs` 用中立的题目(`choice` 带选项,`predicate` 为是非题)组装两家的请求,并把答案统一为 `{choice, confidence, probabilities}` 或 `{probability}`。上下文是房间标题、目标与用户原文、最近 10 条消息(每条截断到 600 字)和待处理的那条用户消息。把握程度取所选项的概率(缺失时才用 `confidence`;Jev 的两项选择 `confidence` 明显低于该概率,实测概率 0.82 时只有 0.64),阈值 0.8。请求 30 秒超时,错误只报 HTTP 状态,不含响应正文或密钥。
328
328
 
329
- - **自由房间(推荐,用户决定)**:未点名的消息照常交给主持人,判断在后台进行,不阻塞本次检查和任何投递。每条 `route` 请求只判断一次,选项为各位本机唤醒的非主持成员(描述为软件与项目读写权限)和“交回主持人”;只有选中成员且 `confidence ≥ 0.8` 时,才用 `POST recommendations` 记录推荐。网页在消息旁显示“智能调度建议让 B 回答”与 60 秒倒计时,用户可选“打断主持人,改由它回答”或“主持人答完后再回答”(主持人已答完时直接请 B 回答),过期后提示消失。判断失败、超时或置信度不足时不显示推荐,行为与未开启相同。
330
- - **讨论房间(有把握即执行)**:投递主持事件前先只读判断,置信度 ≥ 0.8 才执行,否则照常唤醒主持人:
329
+ - **自由房间(推荐,用户决定)**:未点名的消息照常交给主持人,判断在后台进行,不阻塞本次检查和任何投递。每条 `route` 请求只判断一次,选项为各位本机唤醒的非主持成员(描述为软件与项目读写权限)和“交回主持人”;只有选中成员且 `confidence ≥ 0.7` 时,才用 `POST recommendations` 记录推荐。网页在消息旁显示“智能调度建议让 B 回答”与 60 秒倒计时,用户可选“打断主持人,改由它回答”或“主持人答完后再回答”(主持人已答完时直接请 B 回答),过期后提示消失。判断失败、超时或置信度不足时不显示推荐,行为与未开启相同。
330
+ - **讨论房间(有把握即执行)**:投递主持事件前先只读判断,置信度 ≥ 0.7 才执行,否则照常唤醒主持人:
331
331
  - **插话**:`held_since` 后恰有一条用户留言时判断。仅补充信息且有排队安排 → `turns/continue`;在问某位非主持专家 → 新计划先请该专家回应这条留言(问题引用原文 300 字),原排队安排按原顺序跟在后面(最多 10 项);改变方向、多条留言或话题修改 → 主持人。仅补充但计划已结束时,按下一条处理。
332
332
  - **轮次衔接**:仅 `named_followup` 为 false、无排队、上一轮已完成时判断。继续 → 先请模型选出的专家(不是上一轮最后发言者),其余按房间专家顺序,统一使用 `peer_round_question`;第 1 轮后自动把阶段从 `initial` 改为 `discussing`。可收束、跑偏、重复或卡住 → 主持人。
333
333
  - **执行方式**:有把握时 runner 领取该主持事件,以 `smart-scheduler` 会话进入,用本次执行凭据以主持人身份领取发言权、改阶段、创建或继续安排;网站的发言权、上限与凭据校验照常生效。到限、阶段不在进行中或已有进行中安排时不判断。执行中任一写入失败,把同一事件交回主持人软件并标记已进入(任务说明与 API 主持都跳过 `execution enter`)。判断失败或置信度不足时,主持任务附 `scheduler_suggestions` 供参考。
334
- - **成员变化**:新邀请的专家由网站在入场事务中追加到现有队列末尾,原安排与当前执行保持不变,runner 到它的安排生效时才交付;手动专家仍由原会话提交。暂离、移除、请回等需要重排的变化会作废排队安排(留下最近一次实际安排之后的 `skipped`)。本轮未完成且无排队时,runner 不调用模型,按原顺序补回仍在场专家的安排,再请本轮尚未发言的专家开场,不唤醒主持人。
335
- - 每次判断(含交回主持人)用控制凭据写入 `POST scheduler-decisions`,`after_message_id` 取最近一条已回答安排的回答,对话中显示为该发言卡片与下一张卡片之间的独立调度卡片(“智能调度:……”)。交回主持人时写明原因:建议收束、拿不准,或“未判断:已到讨论上限 / 同时有多条留言 / 话题设置有修改 / 点名追问已开启 / 本轮未发言的专家都是手动接入”;主持人的开场任务不记录。本轮未完成且没有后续安排时(如点“跳过”后),先补回因成员变动作废的计划,否则由决策模型在本轮未发言、可自动唤醒的专家中选下一位(置信度 ≥ 0.8),拿不准或建议调整方向才交回主持人。无论开新一轮还是本轮继续,每次只安排一位,答完队列为空再判断下一位;队列里已有安排时直接接力,不判断。刚被用户“跳过”的专家本轮不再被选(除非没有别人);从未发言的专家(如新邀请)使用开场提示词;手动接入的专家不在候选内,也不会被留言分流选中。
336
- - 每次判断与结果写入 `service.log`;阈值 0.8 未经实测校准,两家的置信度不可互相套用。面板提示:开启后房间标题与最近消息会发给所选供应商。
334
+ - **成员变化**:新邀请的专家由网站在入场事务中加入现有队列的补齐位置(本轮未发言的专家之后、会再次发言的专家之前,没有重复时在队尾),当前执行与其余安排的相对顺序不变,runner 到它的安排生效时才交付;手动专家仍由原会话提交。暂离、移除、请回等需要重排的变化会作废排队安排(留下最近一次实际安排之后的 `skipped`)。本轮未完成且无排队时,runner 不调用模型,按原顺序补回仍在场专家的安排,再请本轮尚未发言的专家开场,不唤醒主持人。
335
+ - 每次判断(含交回主持人)用控制凭据写入 `POST scheduler-decisions`,`after_message_id` 取最近一条已回答安排的回答,对话中显示为该发言卡片与下一张卡片之间的独立调度卡片(“智能调度:……”)。交回主持人时写明原因:建议收束、拿不准,或“未判断:已到讨论上限 / 同时有多条留言 / 话题设置有修改 / 点名追问已开启 / 本轮未发言的专家都是手动接入”;主持人的开场任务不记录。本轮未完成且没有后续安排时(如点“跳过”后),先补回因成员变动作废的计划,否则由决策模型在本轮未发言、可自动唤醒的专家中选下一位(置信度 ≥ 0.7),拿不准或建议调整方向才交回主持人。无论开新一轮还是本轮继续,每次只安排一位,答完队列为空再判断下一位;队列里已有安排时直接接力,不判断。刚被用户“跳过”的专家本轮不再被选(除非没有别人);从未发言的专家(如新邀请)使用开场提示词;手动接入的专家不在候选内,也不会被留言分流选中。
336
+ - 每次判断与结果写入 `service.log`;阈值 0.7(所有判断统一)未经实测校准,两家的置信度不可互相套用。面板提示:开启后房间标题与最近消息会发给所选供应商。
337
337
  - 2026-10-23 用真实 Jev 密钥核对:`/v1/systemone` 的请求与答案格式与本模块一致;4 条自由房间分派全部合理(直接派出的置信度 0.91),3 条讨论建议中 2 条符合预期,单次 0.5–1.1 秒(首次连接约 5 秒)。该密钥的 `GET /v1/models` 只列出 `jev-latest` 与 `jev-preview`,没有可锁定的版本,默认仍用 `jev-latest`;Jev 升级可能改变判断结果。OpenAI Decisions 仅按文档实现,未用真实密钥调用。
338
338
 
339
339
  普通成员的回应建议不会出现在待投递队列。房主转换后沿用同一请求和执行路径:任务文件的 `request_messages` 指向原成员回答,`chain_id` 属于这次用户确认;重复转换不会再次投递,手动与在线目标仍不进入自动投递。
package/docs/workflow.md CHANGED
@@ -8,12 +8,14 @@
8
8
  - **自由协作**:你或 AI 主持人点名一位或多位成员,各自在原会话处理请求;没有讨论发言权、安排和轮次。每位成员单独选择本机唤醒、手动接入或网页代发,见下方 [自由协作房间](#自由协作房间)。
9
9
  - **手动接入**:自由协作成员的一种接入方式。本地成员持自己的凭据文件自行读写,可由人工或软件原生定时触发;网页成员只能阅读房间,回答需转交代发;网页 AI 从其服务器读取阅读链接,本机网站对它不可达,此时由用户粘贴材料。只有这两种成员时建房无需本机接入服务。复制参与指令、下载凭据文件或复制凭证文本都不代表已经送达、接入或唤醒。
10
10
 
11
- 讨论运行中可在进度看板邀请专家(经本机服务配置后加入;Codex CLI / App、Claude 可填写已有会话 ID,续接这个带有自己上下文的原会话,标注为借用会话),或从成员菜单让专家暂离、请回或移除。暂离会取消它当前与排队中的安排,保留位置与会话,不计入之后的轮次;请回换发新凭证、续接原会话。邀请保留已有安排,新专家追加到队尾;暂离、请回或移除不暂停讨论,但排队中的安排作废,由主持人重新安排;主持人本身不能暂离或移除,更换主持人仍会暂停;正在发言(有进行中安排)的专家须先暂停讨论才能暂离或移除。暂停后可对本轮已发言的专家选择“作废本轮发言”:彻底删除其最近一条观点,该专家重新计为本轮未发言(不新增轮次),恢复后由主持人重新安排。故障只暂停并保留原因,不静默换软件、身份或创建第二个会话。
11
+ 讨论运行中可在进度看板邀请专家(经本机服务配置后加入;Codex CLI / App、Claude 可填写已有会话 ID,续接这个带有自己上下文的原会话,标注为借用会话),或从成员菜单让专家暂离、请回或移除。暂离会取消它当前与排队中的安排,保留位置与会话,不计入之后的轮次;请回换发新凭证、续接原会话。邀请保留已有安排,新专家排在本轮的补齐位置;暂离、请回或移除不暂停讨论,但排队中的安排作废,由主持人重新安排;主持人本身不能暂离或移除,更换主持人仍会暂停;正在发言(有进行中安排)的专家须先暂停讨论才能暂离或移除。暂停后可对本轮已发言的专家选择“作废本轮发言”:彻底删除其最近一条观点,该专家重新计为本轮未发言(不新增轮次),恢复后由主持人重新安排。故障只暂停并保留原因,不静默换软件、身份或创建第二个会话。
12
12
 
13
- 邀请弹窗在卡片上方选择自动或手动接入,手动卡片按内容收缩。已有本轮发言安排时,新邀请的自动和手动专家都追加到队列末尾,保留当前发言、原有问题和顺序;重复邀请请求不重复排队。暂停或尚无安排时,由主持人在继续推进时将新专家安排在本轮尚未发言的原专家之后;进入收束后不再追加安排。邀请不会启动或恢复讨论,也不会清除待处理的用户留言。
13
+ 邀请弹窗与新建房间使用同一种角色卡片(同宽、正反面等高):可一次选多位 AI(每个平台一张卡片,已在场的平台以新实例加入),在卡片正面切换“程序启动 / 手动启动”,点“邀请”后先加入程序启动的成员、再加入手动启动的成员(各自保持选择顺序),避免尚未注册的手动成员堵住自动成员;遇到第一个失败即停止并保留未处理的卡片。手动成员加入后弹窗列出它们的复制按钮和实时注册状态。手动启动卡片的背面只写已实测的接管差异:Codex 的消息可实时送入已开窗口;agy 的后台发言写入同一会话、已开窗口需重启才显示;Claude Code 的后台接管会让会话分叉、原窗口看不到。已有本轮发言安排时,新邀请的自动和手动专家都加入队列的补齐位置:排在本轮未发言的专家之后、会再次发言的专家之前,没有重复时在队尾;保留当前发言和原有相对顺序、问题;重复邀请请求不重复排队。暂停或尚无安排时,由主持人在继续推进时将新专家安排在本轮尚未发言的原专家之后;进入收束后不再追加安排。邀请不会启动或恢复讨论,也不会清除待处理的用户留言。
14
14
 
15
15
  邀请手动专家后,弹窗提供“复制启动指令”;复制成功后自动关闭,复制失败则保留弹窗并显示错误,方便重试。关闭后仍可在参与者列表中再次复制,不另设“稍后再复制”按钮。复制只交付指令,不改变发言顺序。轮到手动专家时,输入框上方也提供该按钮,复制成功后显示勾号与“已复制”,2 秒后恢复。
16
16
 
17
+ 手动专家的“注册”:专家须在自己的会话里执行 `member register`,网站才把它记为已注册;会话 ID 由 CLI 自动采集,不由专家填写。进度看板中该专家显示“待注册”(警示色,并提示把启动指令交给它的会话),注册后显示“已注册”,轮到与发言后沿用排队、发言中、本轮已发言等状态。存在待注册的手动成员时,输入框上方持续提示并提供复制启动指令,直到它注册;换发新凭证(请回)后需重新注册。只有已注册且上报了 Claude Code / Codex 会话的专家才会被本机服务接管。注册不改变发言顺序。
18
+
17
19
  ## 自由协作房间
18
20
 
19
21
  成员回答下的执行报告列出请求编号、耗时、终态与工作区观察文件;失败或取消的报告保留在原请求下。报告展示最近 100 次执行;“未提供自动改动检测”表示非 Git 目录或未上报,不能据此判断没有文件变化。
@@ -110,7 +112,7 @@ stateDiagram-v2
110
112
 
111
113
  主持人可以一次排好本轮顺序(`turn create --plan-file`):第一项立即生效,其余为 `queued`。专家保存回答后,网站在同一事务中把发言权交给下一位排队专家;末位答完才交回主持人。接力不发通知,需要逐个转交的在线专家不放进整轮。
112
114
 
113
- 用户留言或修改目标、背景、边界、焦点会记为待主持人处理(`held_since`),不打断正在发言的专家;该专家保存回答后交回主持人而不接力。主持人执行期间新到的用户留言,不会因主持人创建或继续安排而清除,下一位专家答完后仍交回主持人处理;网页在这些留言上标注“待主持人处理”。要立即生效,用户在进度看板点「跳过」:当前安排取消、本次执行作废并交回主持人,排队安排保留。主持人处理后用 `turn continue` 按原计划继续,或直接创建新安排,剩余排队改为 `skipped`。建房时开启“智能调度”的讨论,留言由决策模型先判断:有把握是“仅补充信息”就按原计划继续,是在问某位专家就先请该专家回应,其余情况才交回主持人;每次判断都在对话中留一行“智能调度”记录。暂停保留当前与排队安排,点“重启”由原专家继续;若要跳过当前安排,可点“跳过”,取消该安排后恢复讨论并交回主持人,后续队列保留;邀请只在队尾追加,提前结束、到限处理、收束、更换主持人或专家暂离、请回、移除才会作废全部排队安排。
115
+ 用户留言或修改目标、背景、边界、焦点会记为待主持人处理(`held_since`),不打断正在发言的专家;该专家保存回答后交回主持人而不接力。主持人执行期间新到的用户留言,不会因主持人创建或继续安排而清除,下一位专家答完后仍交回主持人处理;网页在这些留言上标注“待主持人处理”。要立即生效,用户在进度看板点「跳过」:当前安排取消、本次执行作废并交回主持人,排队安排保留。主持人处理后用 `turn continue` 按原计划继续,或直接创建新安排,剩余排队改为 `skipped`。建房时开启“智能调度”的讨论,留言由决策模型先判断:有把握是“仅补充信息”就按原计划继续,是在问某位专家就先请该专家回应,其余情况才交回主持人;每次判断都在对话中留一行“智能调度”记录。暂停保留当前与排队安排,点“重启”由原专家继续;若要跳过当前安排,可点“跳过”,取消该安排后恢复讨论并交回主持人,后续队列保留;邀请只插入补齐位置,提前结束、到限处理、收束、更换主持人或专家暂离、请回、移除才会作废全部排队安排。
114
116
 
115
117
  本地专家自行领取与归还;在线专家由主持人以自身操作者身份、指定原专家代操作。主持人不能代领另一位本地专家的发言权。主动归还后未完成安排仍存在,主持人需取消或处理。正常推进不超时夺取;配置的到限处理会取消未完成安排并撤销旧自动执行,再交回主持人。
116
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.2.37",
3
+ "version": "0.2.39",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
package/runner/discuss.py CHANGED
@@ -314,6 +314,11 @@ def execution_commands(subcommands, registry):
314
314
  parser.add_argument("--reason", required=True)
315
315
 
316
316
 
317
+ def member_commands(subcommands, registry):
318
+ """Let a manual member announce itself; the CLI attaches the hosting session on its own."""
319
+ add_leaf(subcommands, registry, "member register", "手动接入成员用自己的凭据向房间注册;会话 ID 由本程序自动附带,无需填写", True)
320
+
321
+
317
322
  def build_parser(prog="discuss"):
318
323
  """Build help and schema from the same command definitions."""
319
324
  parser = Parser(prog=prog, description="AI CoDiscuss CLI:通过 HTTP 操作现有讨论网站。",
@@ -325,7 +330,7 @@ def build_parser(prog="discuss"):
325
330
  for name, register in (("room", room_commands), ("message", message_commands),
326
331
  ("attachment", attachment_commands),
327
332
  ("turn", turn_commands), ("state", state_commands), ("floor", floor_commands),
328
- ("run", run_commands), ("execution", execution_commands)):
333
+ ("run", run_commands), ("execution", execution_commands), ("member", member_commands)):
329
334
  group = subcommands.add_parser(name, help=f"{name} 操作", allow_abbrev=False)
330
335
  add_common(group)
331
336
  register(group.add_subparsers(dest="action", required=True), registry)
@@ -621,6 +626,13 @@ def plan_execution(args):
621
626
  return RequestPlan(f"/api/rooms/{args.room}/events/{args.id}", "POST", body)
622
627
 
623
628
 
629
+ def plan_member(args):
630
+ """Register with the credential file; the token itself decides which member this is."""
631
+ if not load_access(args):
632
+ raise CliError("注册须使用成员自己的凭据文件(--access-file 或 DISCUSS_ACCESS_FILE)")
633
+ return RequestPlan(f"/api/rooms/{args.room}/register", "POST", {})
634
+
635
+
624
636
  def build_plan(args):
625
637
  """Validate local input before constructing any network request."""
626
638
  stdin_count = sum(value == "-" for key, value in vars(args).items()
@@ -630,7 +642,7 @@ def build_plan(args):
630
642
  handlers = {"room": plan_room, "message": plan_message, "turn": plan_turn,
631
643
  "attachment": plan_attachment,
632
644
  "state": plan_state, "floor": plan_floor, "api": plan_api,
633
- "run": plan_run, "execution": plan_execution}
645
+ "run": plan_run, "execution": plan_execution, "member": plan_member}
634
646
  plan = RequestPlan("/api/health") if args.resource == "health" else handlers[args.resource](args)
635
647
  if getattr(args, "dry_run", False) and plan.method != "POST" and plan.download_file is None:
636
648
  raise CliError("--dry-run 仅用于写入命令;读取命令本身不会修改数据")
@@ -387,7 +387,7 @@ export class DiscussionRunner {
387
387
  if (restored) return restored;
388
388
  const spoken = new Set(room.rounds?.at(-1)?.speakers || []);
389
389
  // Experts without local software cannot be woken by the runner, so only automatic ones are candidates.
390
- const waiting = experts.filter(name => name !== room.host && !spoken.has(name) && this.config.participants[name]);
390
+ const waiting = automatic.filter(name => !spoken.has(name));
391
391
  if (!waiting.length) return skip('round', '本轮未发言的专家都是手动接入,无法自动安排');
392
392
  // An expert the user just skipped is not picked again unless nobody else is left.
393
393
  const skipped = room.current_turn?.status === 'cancelled' ? room.current_turn.expert : null;
@@ -444,7 +444,7 @@ export class DiscussionRunner {
444
444
  async record_decision(plan, applied) {
445
445
  try {
446
446
  await api(this.config, '/scheduler-decisions', { kind: plan.kind, label: plan.label, confidence: plan.confidence ?? null,
447
- applied, provider: this.config.scheduler.provider, after_message_id: plan.after_message_id || 0 });
447
+ applied, provider: this.config.scheduler.provider, after_message_id: plan.after_message_id || 0, detail: plan.detail });
448
448
  } catch (error) { this.log(`智能调度决策记录未保存:${error.message}`); }
449
449
  }
450
450
  /** Enter once per delivery; a failure is recorded so reconcile revokes the execution. */
@@ -220,12 +220,12 @@ class RoomWorker {
220
220
  }
221
221
  this.state.config_version = snapshot.version;
222
222
  }
223
- /** A manual Claude or Codex expert whose CLI reported its session is handed to the service, which resumes that session from the next turn on. */
223
+ /** A manual Claude or Codex expert that has registered and whose CLI reported its session is handed to the service, which resumes that session from the next turn on. */
224
224
  async adopt_manual_members() {
225
225
  if (this.config.mode === 'free') return;
226
226
  for (const member of this.runner.members || []) {
227
227
  const key = `${member.name}:${member.reported_session}`;
228
- if (member.access !== 'manual' || member.status !== 'active' || !member.reported_session || this.adopt_failed.has(key)) continue;
228
+ if (member.access !== 'manual' || member.status !== 'active' || !member.registered_at || !member.reported_session || this.adopt_failed.has(key)) continue;
229
229
  try {
230
230
  const { adopt_member_profile } = await import('./discussion_launch.mjs');
231
231
  const adapter = adopt_member_profile(this.home, this.path, member.name, member.reported_session, member.reported_client);
@@ -7,8 +7,8 @@ export const SCHEDULER_PROVIDERS = {
7
7
  decisions: { label: 'OpenAI Decisions', base_url: 'https://api.openai.com/v1', path: '/decisions', models: ['gpt-6-luna'] },
8
8
  };
9
9
  const REQUEST_TIMEOUT_MS = 30_000;
10
- /** Below this confidence a free-room message stays with the host, exactly as without scheduling. */
11
- export const ROUTE_CONFIDENCE = 0.8;
10
+ /** Below this confidence a decision stays with the host, exactly as without scheduling. */
11
+ export const ROUTE_CONFIDENCE = 0.7;
12
12
  const HOST_CHOICE = '__host__';
13
13
  const NOTE_CHOICE = '__note__';
14
14
  const CONTEXT_MESSAGES = 10;
@@ -67,6 +67,17 @@ export async function scheduler_check(home, input, fetch_impl = fetch) {
67
67
  return { ok: true };
68
68
  }
69
69
 
70
+ /** What was asked (the instruction and options only, never the room excerpts) and what the provider chose, for the folded record. */
71
+ function detail_of(questions, answers) {
72
+ return questions.map(question => ({ question: question.instructions, choices: question.choices.map(choice => choice.value),
73
+ choice: answers[question.name]?.choice ?? null, probabilities: answers[question.name]?.probabilities ?? {} }));
74
+ }
75
+
76
+ /** Say which option the provider leaned to when it was not sure enough, so the transcript shows how close it was. */
77
+ function unsure_label(lean) {
78
+ return lean ? `倾向${lean},把握不足,交给主持人` : '拿不准,交给主持人';
79
+ }
80
+
70
81
  /** Shorten a message so recent context stays small; the decision never needs whole answers. */
71
82
  function excerpt(message) {
72
83
  const text = String(message.text || '').replace(/\s+/g, ' ').trim();
@@ -116,14 +127,16 @@ export async function decide_interjection(home, scheduler, { title, goal, user_r
116
127
  const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言` })),
117
128
  { value: HOST_CHOICE, description: `由主持人 ${host} 处理:调整目标、方向、安排,或需要主持人决定` },
118
129
  { value: NOTE_CHOICE, description: '仅补充信息:不需要专人回应,按原计划继续' }];
119
- const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, user_request), [{ type: 'choice', name: 'handling',
120
- instructions: '讨论进行中用户发来这条留言。结合讨论目标判断它最适合怎样处理。', choices }], fetch_impl);
130
+ const questions = [{ type: 'choice', name: 'handling', instructions: '讨论进行中用户发来这条留言。结合讨论目标判断它最适合怎样处理。', choices }];
131
+ const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, user_request), questions, fetch_impl);
132
+ const detail = detail_of(questions, answers);
121
133
  const choice = answers.handling?.choice;
122
134
  const confidence = certainty(answers.handling);
123
135
  const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
124
- if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, label: '仅补充信息,按原计划继续' };
125
- if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, label: `先请 ${choice} 回应这条留言` };
126
- return { action: 'host', confidence, label: choice === HOST_CHOICE && sure ? '这条留言需要主持人处理' : '拿不准这条留言如何处理,交给主持人' };
136
+ if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, detail, label: '仅补充信息,按原计划继续' };
137
+ if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, detail, label: `先请 ${choice} 回应这条留言` };
138
+ const lean = choice === NOTE_CHOICE ? '按原计划继续' : experts.includes(choice) ? `请 ${choice} 回应` : choice === HOST_CHOICE ? '由主持人处理' : '';
139
+ return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && sure ? '这条留言需要主持人处理' : unsure_label(lean) };
127
140
  }
128
141
 
129
142
  /**
@@ -135,14 +148,16 @@ export async function decide_next_speaker(home, scheduler, { title, goal, user_r
135
148
  const state = [topic_lines(title, goal, user_request), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
136
149
  const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言` })),
137
150
  { value: HOST_CHOICE, description: `交给主持人 ${host}:讨论偏离目标、卡住,或需要主持人调整安排` }];
138
- const answers = await decide(home, scheduler, state, [{ type: 'choice', name: 'speaker',
139
- instructions: '本轮还有专家没发言,安排被取消或用完。结合已有发言判断下一位应请谁发言:优先选被点名追问、最能回应当前焦点的人。', choices }], fetch_impl);
151
+ const questions = [{ type: 'choice', name: 'speaker',
152
+ instructions: '本轮还有专家没发言,安排被取消或用完。结合已有发言判断下一位应请谁发言:优先选被点名追问、最能回应当前焦点的人。', choices }];
153
+ const answers = await decide(home, scheduler, state, questions, fetch_impl);
154
+ const detail = detail_of(questions, answers);
140
155
  const choice = answers.speaker?.choice;
141
156
  const confidence = certainty(answers.speaker);
142
157
  if (candidates.includes(choice) && confidence !== null && confidence >= ROUTE_CONFIDENCE) {
143
- return { action: 'next', expert: choice, confidence, label: `本轮继续,请 ${choice} 接着发言` };
158
+ return { action: 'next', expert: choice, confidence, detail, label: `本轮继续,请 ${choice} 接着发言` };
144
159
  }
145
- return { action: 'host', confidence, label: '拿不准下一位该请谁发言,交给主持人' };
160
+ return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && confidence >= ROUTE_CONFIDENCE ? '建议由主持人调整安排,交给主持人' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
146
161
  }
147
162
 
148
163
  /**
@@ -159,12 +174,13 @@ export async function decide_round(home, scheduler, { title, goal, user_request,
159
174
  if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?优先选被其他专家点名追问、最需要回应的人。',
160
175
  choices: candidates.map(name => ({ value: name, description: `先请 ${name}` })) });
161
176
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
177
+ const detail = detail_of(questions, answers);
162
178
  const choice = answers.next?.choice;
163
179
  const confidence = certainty(answers.next);
164
180
  if (choice !== 'continue' || confidence === null || confidence < ROUTE_CONFIDENCE || !candidates.length) {
165
181
  const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
166
- return { action: 'host', confidence, label: choice === HOST_CHOICE && sure ? '建议收束或调整方向,交给主持人' : '拿不准是否继续下一轮,交给主持人' };
182
+ return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && sure ? '建议收束或调整方向,交给主持人' : unsure_label(choice === 'continue' ? '继续下一轮' : choice === HOST_CHOICE ? '收束或调整方向' : '') };
167
183
  }
168
184
  const first = candidates.includes(answers.first?.choice) ? answers.first.choice : candidates[0];
169
- return { action: 'continue', first, confidence, label: `继续下一轮,先请 ${first} 发言` };
185
+ return { action: 'continue', first, confidence, detail, label: `继续下一轮,先请 ${first} 发言` };
170
186
  }
package/web/app.js CHANGED
@@ -2236,8 +2236,8 @@ async function copy_member_join(name, with_credential = false) {
2236
2236
  let instruction;
2237
2237
  if (member.access === "online") instruction = "你是在线只读成员,请在原聊天回答,由用户或授权主持人代发;不要尝试直接写入房间。模型与思考强度在原网页设置。";
2238
2238
  else if (member.access === "runner") instruction = `${skills}等待本机程序交付有效任务,按 task 中的 access_file 和 execution 凭据操作${room.mode === "free" ? ";自由协作的回答必须绑定本次有效 execution_id" : ""}。不要另建定时任务;只处理本次有效执行,失效或撤销后停止提交。下载的持久成员私有文件不能替代自动执行凭据。`;
2239
- else if (with_credential) instruction = `${skills}凭证文本在下方:每条 codiscuss 命令都在同一命令内把它原样设为环境变量 DISCUSS_ACCESS(不回显;首条 room view 也要带,否则无法识别你),命令省略 --access-file;先执行 codiscuss room view ${room.id} --site ${location.origin} --as ${name},后续命令保持本站点、房间和身份。模型与思考强度在原软件中设置。${manual_trigger_instruction(room)}${workspace_instruction()}`;
2240
- else instruction = `${skills}使用属于你的私有文件 member-${room.id}-${name}.json,由用户提供它在本机的实际完整路径;先执行 codiscuss room view ${room.id} --site ${location.origin} --as ${name} --access-file "实际完整路径",后续命令保持本站点、房间和身份。不要读取或公开文件中的 token;若用户给的是凭证文本而非文件,把它设为环境变量 DISCUSS_ACCESS(不要回显)并省略 --access-file。模型与思考强度在原软件中设置。${manual_trigger_instruction(room)}${workspace_instruction()}`;
2239
+ else if (with_credential) instruction = `${skills}凭证文本在下方:每条 codiscuss 命令都在同一命令内把它原样设为环境变量 DISCUSS_ACCESS(不回显;首条 room view 也要带,否则无法识别你),命令省略 --access-file;先执行 codiscuss room view ${room.id} --site ${location.origin} --as ${name},随后立即执行 codiscuss member register ${room.id} --site ${location.origin} --as ${name} 完成注册(只需一次,会话 ID 由程序自动附带,不要自己填写),之后命令保持本站点、房间和身份。模型与思考强度在原软件中设置。${manual_trigger_instruction(room)}${workspace_instruction()}`;
2240
+ else instruction = `${skills}使用属于你的私有文件 member-${room.id}-${name}.json,由用户提供它在本机的实际完整路径;先执行 codiscuss room view ${room.id} --site ${location.origin} --as ${name} --access-file "实际完整路径";随后立即执行 codiscuss member register ${room.id} --site ${location.origin} --as ${name} --access-file "实际完整路径" 完成注册(只需一次,会话 ID 由程序自动附带,不要自己填写),之后命令保持本站点、房间和身份。不要读取或公开文件中的 token;若用户给的是凭证文本而非文件,把它设为环境变量 DISCUSS_ACCESS(不要回显)并省略 --access-file。模型与思考强度在原软件中设置。${manual_trigger_instruction(room)}${workspace_instruction()}`;
2241
2241
  const credential = with_credential ? `
2242
2242
  凭证文本(含 token,仅限你使用):${JSON.stringify({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token })}` : "";
2243
2243
  await copy_text(`请以 ${name} 身份参与房间。