ai_co_discuss 0.2.37 → 0.2.38

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,
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,
@@ -211,13 +211,16 @@ def require_room(connection, room_id):
211
211
  room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
212
212
  room["mode"] = room_mode(connection, room_id)
213
213
  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,))]
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
+ COALESCE(r.registered_at,'') AS registered_at
216
+ FROM room_members AS m LEFT JOIN member_sessions AS s ON s.member_id=m.id
217
+ 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
218
  option = connection.execute("SELECT named_followup FROM discussion_options WHERE room_id=?", (room_id,)).fetchone()
217
219
  # Rooms created before the option keep the host's named follow-ups.
218
220
  room["named_followup"] = bool(option[0]) if option else True
219
221
  room["scheduler_decisions"] = [dict(row) for row in connection.execute(
220
222
  "SELECT * FROM scheduler_decisions WHERE room_id=? ORDER BY id DESC LIMIT ?", (room_id, RECENT_DECISIONS))][::-1]
223
+ room["host_arrangements"] = host_arrangements(connection, room_id, room["scheduler_decisions"])
221
224
  parent = connection.execute("""SELECT rooms.id,rooms.title FROM room_links JOIN rooms ON rooms.id=room_links.parent_id
222
225
  WHERE room_links.room_id=?""", (room_id,)).fetchone()
223
226
  room["parent_room"] = dict(parent) if parent else None
@@ -264,6 +267,23 @@ def require_floor(room, speaker):
264
267
  raise FileExistsError("请先领取本人发言权;他人持有或尚未被点名时不能发言或安排")
265
268
 
266
269
 
270
+ def host_arrangements(connection, room_id, decisions):
271
+ """Each plan the host created (turns saved in one batch), anchored after the latest answer before it.
272
+
273
+ Invitation turns are not plans, and a plan the smart scheduler applied is already shown by its own decision.
274
+ """
275
+ plans, latest_answer = [], 0
276
+ 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,)):
277
+ if not row["submission_id"].startswith("invite:"):
278
+ if plans and plans[-1]["created_at"] == row["created_at"]:
279
+ plans[-1]["turns"].append({"expert": row["expert"], "question": row["question"]})
280
+ else:
281
+ plans.append({"id": row["id"], "after_message_id": latest_answer, "created_at": row["created_at"], "turns": [{"expert": row["expert"], "question": row["question"]}]})
282
+ latest_answer = max(latest_answer, row["answer_id"] or 0)
283
+ applied = {item["after_message_id"] for item in decisions if item["applied"]}
284
+ return [plan for plan in plans if plan["after_message_id"] and plan["after_message_id"] not in applied][-RECENT_DECISIONS:]
285
+
286
+
267
287
  def discussion_rounds(connection, room_id, experts):
268
288
  """Group complete history metadata until every configured expert has spoken.
269
289
 
@@ -549,7 +569,7 @@ def manage_room(connection, room_id, action, payload):
549
569
 
550
570
  def delete_room_records(connection, room_id):
551
571
  """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",
572
+ for table in ("member_recommendations", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
553
573
  "discussion_options", "scheduler_decisions"):
554
574
  connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
555
575
  connection.execute("DELETE FROM message_timings WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
@@ -1614,6 +1634,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1614
1634
  record_reported_session(connection, room_id, token, {"session_id": self.headers.get("X-Discussion-Session"),
1615
1635
  "session_client": self.headers.get("X-Discussion-Session-Client")})
1616
1636
  connection.commit()
1637
+ if parts[3:] == ["register"] and method == "POST":
1638
+ result = register_member(connection, room_id, token, utc_now())
1639
+ connection.commit()
1640
+ return self.respond(result)
1617
1641
  free = room_mode(connection, room_id) == "free"
1618
1642
  if free and parts[3:4] in (["inbox"], ["executions"], ["members"], ["suggestions"], ["recommendations"]):
1619
1643
  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 条。房间视图另附 `host_arrangements`:由 `discussion_turns` 按同一批创建时间分组得到的主持人安排(不含邀请追加的开场安排,也不含已由智能调度写入决策的那一批),`after_message_id` 取该批之前最新的回答,网页在该回答后显示“主持人安排”卡片,逐条列出专家与主持人写的任务说明(`turns`,默认前 3 条,其余可展开),样式同智能调度卡片;没有可跟随的回答(讨论开场)时不显示。不新增表。
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
 
@@ -331,7 +331,7 @@ Codex 要求 CLI 0.163.0 或更新版本,使用 permission profiles 和本地
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 不调用模型,按原顺序补回仍在场专家的安排,再请本轮尚未发言的专家开场,不唤醒主持人。
334
+ - **成员变化**:新邀请的专家由网站在入场事务中加入现有队列的补齐位置(本轮未发言的专家之后、会再次发言的专家之前,没有重复时在队尾),当前执行与其余安排的相对顺序不变,runner 到它的安排生效时才交付;手动专家仍由原会话提交。暂离、移除、请回等需要重排的变化会作废排队安排(留下最近一次实际安排之后的 `skipped`)。本轮未完成且无排队时,runner 不调用模型,按原顺序补回仍在场专家的安排,再请本轮尚未发言的专家开场,不唤醒主持人。
335
335
  - 每次判断(含交回主持人)用控制凭据写入 `POST scheduler-decisions`,`after_message_id` 取最近一条已回答安排的回答,对话中显示为该发言卡片与下一张卡片之间的独立调度卡片(“智能调度:……”)。交回主持人时写明原因:建议收束、拿不准,或“未判断:已到讨论上限 / 同时有多条留言 / 话题设置有修改 / 点名追问已开启 / 本轮未发言的专家都是手动接入”;主持人的开场任务不记录。本轮未完成且没有后续安排时(如点“跳过”后),先补回因成员变动作废的计划,否则由决策模型在本轮未发言、可自动唤醒的专家中选下一位(置信度 ≥ 0.8),拿不准或建议调整方向才交回主持人。无论开新一轮还是本轮继续,每次只安排一位,答完队列为空再判断下一位;队列里已有安排时直接接力,不判断。刚被用户“跳过”的专家本轮不再被选(除非没有别人);从未发言的专家(如新邀请)使用开场提示词;手动接入的专家不在候选内,也不会被留言分流选中。
336
336
  - 每次判断与结果写入 `service.log`;阈值 0.8 未经实测校准,两家的置信度不可互相套用。面板提示:开启后房间标题与最近消息会发给所选供应商。
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 仅按文档实现,未用真实密钥调用。
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
+ 邀请弹窗在卡片上方选择自动或手动接入,手动卡片按内容收缩。已有本轮发言安排时,新邀请的自动和手动专家都加入队列的补齐位置:排在本轮未发言的专家之后、会再次发言的专家之前,没有重复时在队尾;保留当前发言和原有相对顺序、问题;重复邀请请求不重复排队。暂停或尚无安排时,由主持人在继续推进时将新专家安排在本轮尚未发言的原专家之后;进入收束后不再追加安排。邀请不会启动或恢复讨论,也不会清除待处理的用户留言。
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.38",
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;
@@ -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);
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} 身份参与房间。