ai_co_discuss 0.3.18 → 0.3.19

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.
@@ -1,4 +1,4 @@
1
- interface:
2
- display_name: "讨论主持"
3
- short_description: "主持人同时调用参与技能,围绕关键争点推进讨论并形成有依据的结论"
4
- default_prompt: "使用 $discuss-host 和 $discuss-participant,由当前会话主持这个话题,沿用用户选择的运行方式,组织观点交锋并形成有依据的结论,保留重要分歧。"
1
+ interface:
2
+ display_name: "讨论主持"
3
+ short_description: "主持人同时调用参与技能,围绕关键争点推进讨论并形成有依据的结论"
4
+ default_prompt: "使用 $discuss-host 和 $discuss-participant,由当前会话主持这个话题,沿用用户选择的推进方式,组织观点交锋并形成有依据的结论,保留重要分歧。"
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 定位与新建
6
6
 
7
- 当前实际会话承担主持,不另建调度聊天。沿用用户指定的房间和运行方式;新讨论尚未选择时,只确认“全自动讨论 / 手动复制冷启动”。入口与推进方式、CLI / App 客户端分别判断。
7
+ 当前实际会话承担主持,不另建调度聊天。沿用用户指定的房间和推进方式;新讨论尚未选择时,只确认“全自动讨论 / 手动复制冷启动”。入口与推进方式、CLI / App 客户端分别判断。
8
8
 
9
9
  未指定房间时,保存目标、背景、参与配置和上限,核验后再接入专家。全自动使用下文启动入口,手动模式沿用 CLI 建房与接入流程。全自动要求主持和专家均具备本地 CLI 读写能力;配置完成后依据用户的启动指令运行,普通建房本身不代表已启动。在线只读专家使用手动接入分工,不静默转换模式。
10
10
 
@@ -48,7 +48,7 @@
48
48
 
49
49
  此示例的主持在 Codex App;若当前是独立 Codex CLI,应使用 `codex_cli`。可执行文件、App 来源聊天或 Sidecar 等缺项仍须来自实际本机配置。主持也须出现在 `experts` 中并设为 `cli`。示例中的上限不能覆盖用户已经指定的参数;总时长为 0 时可不限总时长运行,按轮次上限或手动停止结束。单次执行默认 0(不限),主动设置的单次执行超时仍有效。只有用户另设本次时长时才提供顶层 `minutes`,不能用它延长房间既定截止。
50
50
 
51
- 已有房间改为指定顶层 `room_id`,省略 `room`;受保护房间另提供原私有配置的 `access_file` 路径,不读取或打印令牌。首次启动命令不替代已暂停房间的继续操作,已有运行按当前房间状态接续。
51
+ 已有房间改为指定顶层 `room_id`,省略 `room`,并提供原私有配置的 `access_file` 路径(含房间控制凭据),不读取或打印令牌。首次启动命令不替代已暂停房间的继续操作,已有运行按当前房间状态接续。
52
52
 
53
53
  ### 启动与交接
54
54
 
@@ -27,7 +27,7 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
27
27
  1. **不读取或打印凭据内容**,token 不进入提示或房间。每个成员只用自己的凭据,AI 不借用用户身份发言,房主控制凭据不能代替成员署名。(身份与权限由网站按凭据校验。)
28
28
  2. **执行失效后不再写入**:暂停、到期或被打断的旧任务保留草稿并结束,不换身份、职责或编号绕过拒绝,也不去掉 `--execution` 改作普通发言。任务文件的 `stopped_executions` 列出你此前被用户停止或跳过的执行:会话里如有它们未完成的工作,不接着做、不为它们补交,只处理本次请求。(失效的执行若还能产生可见结果,用户的打断与暂停就失去意义。)
29
29
  3. **写入前定好 `--submission-id`**:同次重试沿用编号和原内容,新内容换新编号;结果不明时先按原编号查询,再按 [写入与恢复](references/writing.md) 处理。(网站靠它去重,换号会产生重复发言。)
30
- 4. **不创建定时任务、不操作其他软件、不发运行回执**(就绪、开始、完成、停止等说明),除非用户要求或出现需要记录的异常;回执不能作为观点、完成或共识的证据。(这些动作有副作用,回执只会淹没真正的回答。)
30
+ 4. **不创建定时任务、不操作其他软件、不发状态回执**(就绪、开始、完成、停止等说明),除非用户要求或出现需要记录的异常;回执不能作为观点、完成或共识的证据。(这些动作有副作用,回执只会淹没真正的回答。)
31
31
 
32
32
  ## 命令入口与身份
33
33
 
@@ -38,6 +38,6 @@ codiscuss message post ROOM --as 本人 --kind chat --execution 执行编号 --b
38
38
  codiscuss message post ROOM --as 本人 --kind chat --request 请求编号 --body-file draft.md --submission-id 新UUID --access-file FILE
39
39
  ```
40
40
 
41
- 需要摘要时一并使用 `--summary` 或 `--summary-file`。成功提交后当前请求即完成;不要再补一条普通 chat 或运行回执。
41
+ 需要摘要时一并使用 `--summary` 或 `--summary-file`。成功提交后当前请求即完成;不要再补一条普通 chat 或状态回执。
42
42
 
43
43
  任务执行失败时,用 `execution fail ROOM 执行编号 --reason 原因 --access-file FILE` 失败本次执行并保留草稿。该操作只处理当前请求,不自动暂停整场讨论;主持人会收到专家请求失败并判断后续安排。不要改用其他成员、请求编号或身份绕过拒绝。
@@ -21,7 +21,7 @@
21
21
  | 自由协作回答或讨论专家回答 | `message post --kind chat`;自动执行使用 `--execution 执行编号`,手动成员使用 `--request 请求编号` |
22
22
  | 主持说明 | `message post --kind moderation`,仅主持人在允许的房间状态下使用;该消息本身不结束讨论 |
23
23
  | 网页来源成员代发(自由协作或讨论) | 使用 `message post --kind chat --author 网页成员`,讨论回答同时用 `--request 请求编号` 关联安排 |
24
- | 创建 / 维护话题 | `room create` / `room update`;`--experts-file` 会替换完整参与配置 |
24
+ | 创建 / 维护话题 | `room create` / `room update`;成员请通过成员管理调整 |
25
25
  | 安排发言 / 取消 | `turn create` / `turn cancel`;同房间只能有一个未完成安排 |
26
26
  | 创建或继续安排 | `turn roster` 读取最新名单与 `revision`,随后 `turn create` 或 `turn continue` 携带该版本 |
27
27
  | 保存摘要 | `state set`,`--until` 只覆盖实际已读消息 |
@@ -49,4 +49,4 @@
49
49
 
50
50
  这些核对覆盖网站 HTTP 写入;浏览器向在线专家发送问题时,由主持人另行核对实际送达结果。
51
51
 
52
- 自动任务的状态以本次执行及服务端请求状态为准;不要创建原生定时任务或补写运行回执。影响协作的异常直接向用户报告。
52
+ 自动任务的状态以本次执行及服务端请求状态为准;不要创建原生定时任务或补写状态回执。影响协作的异常直接向用户报告。
@@ -49,12 +49,8 @@ CHAIN_PROGRESS_SQL = """SELECT b.chain_id,COUNT(*) AS used,? + COALESCE((SELECT
49
49
  GROUP BY b.chain_id ORDER BY b.chain_id DESC LIMIT 10"""
50
50
  TIMELINE_STATES = {"completed": "done", "failed": "failed", "cancelled": "cancelled"}
51
51
  TIMELINE_FREE_IDS = "SELECT id FROM member_executions WHERE room_id=?1 AND (?3=0 OR finished_at=0 OR finished_at>=?3) ORDER BY id DESC LIMIT ?2"
52
- TIMELINE_DISCUSSION_IDS = """SELECT e.id FROM discussion_events e LEFT JOIN event_timings t ON t.event_id=e.id
53
- WHERE e.room_id=?1 AND (t.dispatched_at>0 OR e.status='pending') AND (?3=0 OR COALESCE(t.finished_at,0)=0 OR t.finished_at>=?3)
54
- ORDER BY e.id DESC LIMIT ?2"""
55
- # Besides times: the task an execution took on (its request message, or the discussion turn's question), its result
56
- # (the answer's summary, else the answer's opening), its latest progress note, how many files it changed (NULL when
57
- # unobserved), its last output time (free executions only) and the discussion run it belongs to (discussion only).
52
+ # Besides times: the task an execution took on (its request message), its result (the answer's summary, else the
53
+ # answer's opening), its latest progress note, how many files it changed (NULL when unobserved) and its last output time.
58
54
  TIMELINE_SQL = f"""SELECT x.*,(SELECT COALESCE(NULLIF(s.text,''),substr(a.text,1,200)) FROM messages a
59
55
  LEFT JOIN message_summaries s ON s.message_id=a.id WHERE a.id=x.answer_id) AS result FROM (
60
56
  SELECT 'free' AS source,e.id,m.name AS member,m.role,e.status,
@@ -67,16 +63,10 @@ TIMELINE_SQL = f"""SELECT x.*,(SELECT COALESCE(NULLIF(s.text,''),substr(a.text,1
67
63
  (SELECT substr(q.text,1,160) FROM messages q WHERE q.id=(SELECT MIN(r.message_id) FROM member_requests r WHERE r.execution_id=e.id)) AS task,
68
64
  (SELECT MAX(r.answer_id) FROM member_requests r WHERE r.execution_id=e.id) AS answer_id,
69
65
  (SELECT n.text FROM execution_notes n WHERE n.execution_id=e.id ORDER BY n.seq DESC LIMIT 1) AS last_note,
70
- CASE WHEN e.changes='' THEN NULL ELSE json_array_length(e.changes) END AS changes,NULL AS run_id
71
- FROM member_executions e JOIN room_members m ON m.id=e.member_id WHERE e.id IN ({TIMELINE_FREE_IDS})
72
- UNION ALL SELECT 'discussion',e.id,e.participant,e.role,e.status,CAST(strftime('%s',e.created_at) AS INTEGER),
73
- COALESCE(t.dispatched_at,0),e.started_at,COALESCE(t.finished_at,0),CASE WHEN e.status='failed' THEN e.result ELSE '' END,NULL,
74
- 0,NULL,(SELECT substr(q.question,1,160) FROM discussion_turns q WHERE q.id=e.turn_id),
75
- CASE WHEN e.result LIKE 'message:%' THEN CAST(substr(e.result,9) AS INTEGER) END,NULL,NULL,e.run_id
76
- FROM discussion_events e LEFT JOIN event_timings t ON t.event_id=e.id WHERE e.id IN ({TIMELINE_DISCUSSION_IDS})) x"""
77
- TIMELINE_SEGMENTS_SQL = f"""SELECT source,execution_id,phase,tool,start_ms,end_ms FROM execution_segments WHERE room_id=?1 AND (
78
- (source='free' AND execution_id IN ({TIMELINE_FREE_IDS})) OR (source='discussion' AND execution_id IN ({TIMELINE_DISCUSSION_IDS})))
79
- ORDER BY source,execution_id,seq"""
66
+ CASE WHEN e.changes='' THEN NULL ELSE json_array_length(e.changes) END AS changes
67
+ FROM member_executions e JOIN room_members m ON m.id=e.member_id WHERE e.id IN ({TIMELINE_FREE_IDS})) x"""
68
+ TIMELINE_SEGMENTS_SQL = f"""SELECT source,execution_id,phase,tool,start_ms,end_ms FROM execution_segments WHERE room_id=?1
69
+ AND source='free' AND execution_id IN ({TIMELINE_FREE_IDS}) ORDER BY source,execution_id,seq"""
80
70
  # Only the times of progress notes: the texts are read when the reader hovers one.
81
71
  TIMELINE_NOTES_SQL = f"""SELECT execution_id,created_at FROM execution_notes
82
72
  WHERE room_id=?1 AND execution_id IN ({TIMELINE_FREE_IDS}) ORDER BY execution_id,seq"""
@@ -102,6 +92,7 @@ MAX_RETRIES = 3
102
92
  MAX_RETRY_REASON = 500
103
93
  ACTIVE = ("dispatched", "started")
104
94
  ACCESS_MODES = ("runner", "manual")
95
+ # A member given as `online` (older CLI room creation) becomes a manual member relayed from the web.
105
96
  LEGACY_ACCESS_MODES = ("runner", "manual", "online")
106
97
  SCHEDULER_PROVIDERS = ("jev", "decisions")
107
98
  RECOMMEND_SECONDS = 60
@@ -159,10 +150,6 @@ def migrate_free_tables(connection):
159
150
  connection.execute("""CREATE TABLE IF NOT EXISTS member_manual_sources (
160
151
  member_id INTEGER PRIMARY KEY REFERENCES room_members(id) ON DELETE CASCADE,
161
152
  source TEXT NOT NULL CHECK(source='web'))""")
162
- connection.execute("""INSERT OR IGNORE INTO member_manual_sources(member_id,source)
163
- SELECT id,'web' FROM room_members WHERE access='online'""")
164
- connection.execute("UPDATE room_members SET access='manual' WHERE access='online'")
165
- connection.execute("UPDATE room_modes SET mode='free' WHERE mode='brainstorm'")
166
153
  migrate_stop_results(connection)
167
154
 
168
155
 
@@ -178,12 +165,6 @@ def migrate_stop_results(connection):
178
165
  connection.execute("ALTER TABLE execution_capabilities_next RENAME TO execution_capabilities")
179
166
 
180
167
 
181
- def room_mode(connection, room_id):
182
- """Return 'free' for free rooms; every legacy room is a brainstorm room."""
183
- row = connection.execute("SELECT mode FROM room_modes WHERE room_id=?", (room_id,)).fetchone()
184
- return row[0] if row else "brainstorm"
185
-
186
-
187
168
  def review_member_revision(member, generation=0):
188
169
  """Bind an attestation to the exact member identity and access configuration without exposing credentials."""
189
170
  state = ":".join(str(member[key]) for key in ("id", "status", "adapter", "access", "session_mode", "token_hash")) + f":{generation}"
@@ -458,7 +439,7 @@ def insert_member(connection, room_id, member, now):
458
439
  connection.execute("""INSERT INTO room_members (room_id,name,adapter,token_hash,access,session_mode,created_at)
459
440
  VALUES (?,?,?,?,?,?,?)""", (room_id, member["name"], member["adapter"], token_hash(member["token"]),
460
441
  member["access"], member["session_mode"], now))
461
- if member.get("manual_source") == "web" or member.get("access") == "online":
442
+ if member.get("manual_source") == "web":
462
443
  member_id = connection.execute("SELECT id FROM room_members WHERE room_id=? AND name=? AND token_hash=?",
463
444
  (room_id, member["name"], token_hash(member["token"]))).fetchone()[0]
464
445
  connection.execute("INSERT OR IGNORE INTO member_manual_sources(member_id,source) VALUES(?,'web')", (member_id,))
@@ -471,69 +452,12 @@ def save_room_access(connection, room_id, control_token, members, settings):
471
452
  raise ValueError("建房须提供 32–256 字符的房间控制凭据")
472
453
  if any(token_hash(member["token"]) == control for member in members):
473
454
  raise ValueError("成员凭证不能与房主控制凭据相同")
474
- connection.execute("INSERT INTO room_modes (room_id,mode,control_hash) VALUES (?,?,?)", (room_id, settings["mode"], control))
455
+ connection.execute("INSERT INTO room_modes (room_id,mode,control_hash) VALUES (?,'free',?)", (room_id, control))
475
456
  for member in members:
476
457
  insert_member(connection, room_id, member, settings["created_at"])
477
458
  connection.execute("UPDATE room_members SET role='host' WHERE room_id=? AND name=?", (room_id, settings["host"]))
478
459
 
479
460
 
480
- def discussion_access_ok(access, capability, automatic, is_host, manual_source=""):
481
- """Require web relays to stay read-only while local participants use runner or manual access."""
482
- if capability == "online":
483
- return access == "manual" and manual_source == "web" and not is_host
484
- if manual_source == "web":
485
- return False
486
- if not automatic:
487
- return access == "manual"
488
- return access == "runner" or (access == "manual" and not is_host)
489
-
490
-
491
- def mark_manual(member, automatic):
492
- """A manual expert of a woken discussion carries adapter 'manual', so mode switches never turn it into a woken member."""
493
- return {**member, "adapter": "manual"} if automatic and member["access"] == "manual" else member
494
-
495
-
496
- def discussion_members(payload, settings):
497
- """Validate credentials against the room's declared participants and delivery mode."""
498
- members = member_values(payload.get("members")) if settings["experts"] else []
499
- if {member["name"] for member in members} != set(settings["experts"]):
500
- raise ValueError("成员凭证名单须与参与者名单一致")
501
- for member in members:
502
- if not discussion_access_ok(member["access"], settings["experts"][member["name"]],
503
- payload.get("runtime_mode") == "automatic", member["name"] == settings["host"],
504
- member["manual_source"]):
505
- raise ValueError("成员接入方式与房间参与能力、任务交付方式不符")
506
- return [mark_manual(member, payload.get("runtime_mode") == "automatic") for member in members]
507
-
508
-
509
- def sync_discussion_members(connection, room_id, payload, settings):
510
- """Keep existing credentials; require fresh credentials for added or changed members."""
511
- current = {row["name"]: row for row in connection.execute("""SELECT m.*,
512
- COALESCE(s.source,'') AS manual_source FROM room_members m
513
- LEFT JOIN member_manual_sources s ON s.member_id=m.id WHERE m.room_id=? AND m.status='active'""", (room_id,))}
514
- supplied = {member["name"]: member_value(member) for member in payload.get("members", [])}
515
- run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
516
- for name, capability in settings["experts"].items():
517
- automatic = bool(run and run[0] == "automatic")
518
- old = current.get(name)
519
- if old and (old["manual_source"] == "web") == (capability == "online"):
520
- if name in supplied and token_hash(supplied[name]["token"]) != old["token_hash"]:
521
- raise FileExistsError("成员凭证与已登记身份不符;请核对原接入文件")
522
- continue
523
- if name not in supplied or not discussion_access_ok(supplied[name]["access"], capability, automatic,
524
- name == settings["host"], supplied[name]["manual_source"]):
525
- raise ValueError(f"新增或改变接入方式的成员 {name} 须提供独立的新凭证")
526
- member = mark_manual(supplied[name], automatic)
527
- hashed = token_hash(member["token"])
528
- if connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND token_hash=? UNION ALL SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?", (room_id, hashed, room_id, hashed)).fetchone():
529
- raise ValueError("新成员凭证不能复用已有凭证")
530
- connection.execute("UPDATE room_members SET status='removed' WHERE room_id=? AND name=?", (room_id, name))
531
- insert_member(connection, room_id, member, int(time.time()))
532
- for name in current.keys() - settings["experts"].keys():
533
- connection.execute("UPDATE room_members SET status='removed' WHERE room_id=? AND name=?", (room_id, name))
534
- connection.execute("UPDATE room_members SET role=CASE WHEN name=? THEN 'host' ELSE 'member' END WHERE room_id=?", (settings["host"], room_id))
535
-
536
-
537
461
  def sync_participants(connection, room_id):
538
462
  """Keep one active AI host, then mirror member capabilities and the host into shared room metadata."""
539
463
  connection.execute(RELEASE_HOST, (room_id,))
@@ -660,10 +584,10 @@ def execution_notes(connection, room_id, execution_id, token=""):
660
584
 
661
585
 
662
586
  def execution_timeline(connection, room_id, after=0):
663
- """Read the room's recent executions of both kinds with their phase segments and note times, for the timeline page.
587
+ """Read the room's recent executions with their phase segments and note times, for the timeline page.
664
588
 
665
- Times are epoch seconds and segments are millisecond offsets from `dispatched_at`. A free execution's parent is the
666
- execution that wrote the message it answers; discussion executions have none. `state` is running, done, failed
589
+ Times are epoch seconds and segments are millisecond offsets from `dispatched_at`. An execution's parent is the
590
+ execution that wrote the message it answers. `state` is running, done, failed
667
591
  or cancelled, so the page never interprets execution statuses itself. A positive `after` reads only executions
668
592
  still running or ended since then, so a page following live work rereads little.
669
593
  """
@@ -680,7 +604,7 @@ def execution_timeline(connection, room_id, after=0):
680
604
  for row in rows:
681
605
  row["state"] = TIMELINE_STATES.get(row["status"], "running")
682
606
  row["segments"] = segments.get((row["source"], row["id"]), [])
683
- row["notes"] = notes.get(row["id"], []) if row["source"] == "free" else []
607
+ row["notes"] = notes.get(row["id"], [])
684
608
  return {"now": int(time.time()), "after": params[2], "executions": rows, "execution_limit": TIMELINE_EXECUTIONS,
685
609
  "segment_limit": MAX_EXECUTION_SEGMENTS}
686
610
 
@@ -906,20 +830,6 @@ def recipients(connection, room_id, payload):
906
830
  return [(host["id"], "route")] if host else []
907
831
 
908
832
 
909
- def route_held_messages(connection, room_id, host):
910
- """When a discussion ends, hand the user's messages its host never answered to the free room's recipients, as if just sent."""
911
- held = connection.execute("SELECT held_since FROM turn_relay WHERE room_id=?", (room_id,)).fetchone()
912
- if held and held[0]:
913
- rows = connection.execute("""SELECT id FROM messages m WHERE room_id=? AND author='用户' AND id>=?
914
- AND id>COALESCE((SELECT MAX(id) FROM messages WHERE room_id=? AND author=?),0)
915
- AND NOT EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id=m.id) ORDER BY id""",
916
- (room_id, held[0], room_id, host)).fetchall()
917
- for row in rows:
918
- for member_id, purpose in recipients(connection, room_id, {}):
919
- insert_request(connection, room_id, member_id, row["id"], row["id"], purpose, "user")
920
- connection.execute("UPDATE turn_relay SET held_since=0 WHERE room_id=?", (room_id,))
921
-
922
-
923
833
  def proxy_member(connection, room_id, author):
924
834
  """Return the active online member the owner may forward an answer for."""
925
835
  row = connection.execute("""SELECT m.*,s.source AS manual_source FROM room_members m
@@ -940,8 +850,6 @@ def check_answer(connection, room_id, member, payload):
940
850
  named_members(active_members(connection, room_id), payload["to"], exclude=member["name"])
941
851
  if payload.get("handoff") not in (None, False) and (payload["handoff"] is not True or member["role"] != "host" or not payload.get("to")):
942
852
  raise ValueError("handoff 只能由主持人与 to 一起使用,值为 true")
943
- if member["access"] == "online":
944
- raise PermissionError("在线成员只读,回答由用户代发")
945
853
  if member["access"] == "manual":
946
854
  request_ids = payload.get("request_ids", [])
947
855
  if payload.get("execution_id") is not None or not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
@@ -1216,46 +1124,33 @@ def default_order(connection, room_id, members):
1216
1124
 
1217
1125
 
1218
1126
  def speaking_order(connection, room):
1219
- """Who spoke, who speaks now and who is next, shared by free rooms and discussions; only the not-yet-spoken part may be reordered.
1127
+ """Who spoke, who speaks now and who is next; only the not-yet-spoken part may be reordered.
1220
1128
 
1221
- A free room shows its latest unfinished one-after-another queue, else every member in the room's default order;
1222
- a discussion shows the open round: speakers so far, the current turn, then this round's queued turns; a plan reaching into
1223
- later rounds lists them apart in `later` (one name list per round), read-only. The revision changes with any of it.
1129
+ Without an open discussion the room shows its latest unfinished one-after-another queue, else every member in the room's
1130
+ default order; an open discussion shows its open round: speakers so far, the current speaker, then this round's queued
1131
+ speakers; a plan reaching into later rounds lists them apart in `later` (one name list per round), read-only. The
1132
+ revision changes with any of it.
1224
1133
  """
1225
1134
  active = [member for member in room["members"] if member["status"] == "active"]
1226
1135
  by_id = {member["id"]: member for member in active}
1227
1136
  ordered = [member["name"] for member in default_order(connection, room["id"], active)]
1228
1137
  items, kind, target, later = [], "default", 0, []
1229
1138
  session_plan_state = []
1230
- if room["mode"] == "free":
1231
- session = discussion.current_session(connection, room["id"], active_only=True)
1232
- if session:
1233
- kind = "turns"
1234
- target = session["id"]
1235
- items, later, session_plan_state = discussion.session_speaking_order(connection, session)
1236
- else:
1237
- target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
1238
- AND r.member_id=q.member_id WHERE q.room_id=? AND (r.id IS NULL OR r.status IN ('pending','delivering'))""", (room["id"],)).fetchone()[0] or 0
1239
- if target:
1240
- kind = "queue"
1241
- for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
1242
- ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
1243
- if row["member_id"] in by_id:
1244
- state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
1245
- items.append({"name": by_id[row["member_id"]]["name"], "state": state})
1246
- else:
1139
+ session = discussion.current_session(connection, room["id"], active_only=True)
1140
+ if session:
1247
1141
  kind = "turns"
1248
- last = room["rounds"][-1] if room["rounds"] else None
1249
- turn = room["current_turn"]
1250
- speaking = turn["expert"] if turn and turn["status"] in ("pending", "notified") else None
1251
- items = [{"name": name, "state": "done"} for name in (last["speakers"] if last and not last["completed"] else []) if name != speaking]
1252
- if speaking:
1253
- items.append({"name": speaking, "state": "speaking"})
1254
- rounds = split_rounds([item["expert"] for item in room["queued_turns"]], {item["name"] for item in items})
1255
- items += [{"name": name, "state": "queued"} for name in rounds[0]]
1256
- later = rounds[1:]
1257
- # The user may still hold the host seat of an old discussion; only AI participants have a place.
1258
- ordered = [name for name in [room["host"], *ordered, *room["experts"]] if (name in room["experts"] or name == room["host"]) and name != "用户"]
1142
+ target = session["id"]
1143
+ items, later, session_plan_state = discussion.session_speaking_order(connection, session)
1144
+ else:
1145
+ target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
1146
+ AND r.member_id=q.member_id WHERE q.room_id=? AND (r.id IS NULL OR r.status IN ('pending','delivering'))""", (room["id"],)).fetchone()[0] or 0
1147
+ if target:
1148
+ kind = "queue"
1149
+ for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
1150
+ ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
1151
+ if row["member_id"] in by_id:
1152
+ state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
1153
+ items.append({"name": by_id[row["member_id"]]["name"], "state": state})
1259
1154
  listed = {item["name"] for item in items}
1260
1155
  items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
1261
1156
  movable = "queued" if kind != "default" else "idle"
@@ -1265,18 +1160,6 @@ def speaking_order(connection, room):
1265
1160
  "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
1266
1161
 
1267
1162
 
1268
- def split_rounds(names, spoken):
1269
- """Cut a turn queue into rounds: an expert who already spoke in the round being filled starts the next one."""
1270
- rounds, seen = [[]], set(spoken)
1271
- for name in names:
1272
- if name in seen:
1273
- rounds.append([])
1274
- seen = set()
1275
- rounds[-1].append(name)
1276
- seen.add(name)
1277
- return rounds
1278
-
1279
-
1280
1163
  def reorder_speakers(connection, room, token, payload):
1281
1164
  """Owner reorders the not-yet-spoken part of the speaking order; who already spoke or speaks now stays put.
1282
1165
 
@@ -1302,34 +1185,12 @@ def reorder_speakers(connection, room, token, payload):
1302
1185
  for name in order:
1303
1186
  connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
1304
1187
  elif current["kind"] == "turns":
1305
- session = discussion.current_session(connection, room["id"], active_only=True)
1306
- if session:
1307
- discussion.reorder_session_turns(connection, session, order, names)
1308
- else:
1309
- reorder_turns(connection, room["id"], order)
1188
+ discussion.reorder_session_turns(connection, discussion.current_session(connection, room["id"], active_only=True), order, names)
1310
1189
  else:
1311
1190
  connection.execute("INSERT OR REPLACE INTO speaking_orders (room_id,member_ids) VALUES (?,?)",
1312
1191
  (room["id"], json.dumps([names[name] for name in order])))
1313
1192
 
1314
1193
 
1315
- def reorder_turns(connection, room_id, order):
1316
- """Move this round's queued turns into the new order; ids keep the relay order, so each turn's content moves to the id at its new place.
1317
-
1318
- This round's turns lead the queue, so they are its first rows; later rounds keep theirs.
1319
- """
1320
- rows = [dict(row) for row in connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id LIMIT ?",
1321
- (room_id, len(order)))]
1322
- remaining, moved = list(rows), []
1323
- for name in order:
1324
- moved.append(remaining.pop(next(index for index, row in enumerate(remaining) if row["expert"] == name)))
1325
- # Submission ids are unique, so free them before writing them to their new rows.
1326
- for slot in rows:
1327
- connection.execute("UPDATE discussion_turns SET submission_id=submission_id||':moving:'||id WHERE id=?", (slot["id"],))
1328
- for slot, content in zip(rows, moved):
1329
- connection.execute("UPDATE discussion_turns SET expert=?,question=?,submission_id=?,created_at=? WHERE id=?",
1330
- (content["expert"], content["question"], content["submission_id"], content["created_at"], slot["id"]))
1331
-
1332
-
1333
1194
  def answered_chain(connection, payload, actor):
1334
1195
  """The chain an answer continues: the newest chain among the requests it completes."""
1335
1196
  if actor["access"] == "manual":
@@ -1375,40 +1236,6 @@ def after_free_message(connection, room_id, message_id, actor, payload):
1375
1236
  settle_batches(connection, room_id)
1376
1237
 
1377
1238
 
1378
- def discussion_host_handles(connection, room):
1379
- """A running discussion already wakes its host for every user message, so a question to the host needs no request of its own."""
1380
- return room["phase"] not in ("paused", "concluded") and bool(connection.execute(
1381
- "SELECT 1 FROM discussion_runtime WHERE room_id=? AND status='running'", (room["id"],)).fetchone())
1382
-
1383
-
1384
- def checked_asks(connection, room, payload):
1385
- """Validate the user's direct questions to members of a discussion before the message is stored."""
1386
- names = payload.get("to")
1387
- if names is None:
1388
- return []
1389
- return named_members(active_members(connection, room["id"]), names)
1390
-
1391
-
1392
- def ask_members(connection, room, message_id, member_ids):
1393
- """Queue one 'ask' request per named member, outside the discussion's turns, rounds and limits."""
1394
- host_busy = discussion_host_handles(connection, room)
1395
- for member_id in member_ids:
1396
- row = connection.execute("SELECT name FROM room_members WHERE id=?", (member_id,)).fetchone()
1397
- if host_busy and row["name"] == room["host"]:
1398
- continue
1399
- insert_request(connection, room["id"], member_id, message_id, message_id, "ask", "user")
1400
-
1401
-
1402
- def asks_view(connection, room_id):
1403
- """Direct questions of a discussion room: recent requests with their member, for status badges beside messages."""
1404
- names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
1405
- requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
1406
- """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
1407
- (SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
1408
- WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
1409
- return {"requests": requests[::-1], "executions": recent_executions(connection, room_id)}
1410
-
1411
-
1412
1239
  def expire_executions(connection, room_id):
1413
1240
  """Fail executions whose lease lapsed; their requests stay visible as failed, never silently retried."""
1414
1241
  now = int(time.time())
@@ -2001,8 +1828,6 @@ def owner_message(connection, room_id, message_id):
2001
1828
  raise LookupError("消息不存在")
2002
1829
  if row["author"] != "用户":
2003
1830
  raise ValueError("只能撤回或编辑用户自己的消息")
2004
- if room_mode(connection, room_id) != "free":
2005
- raise ValueError("只有协作房间的消息可以撤回或编辑")
2006
1831
  if connection.execute("SELECT 1 FROM room_directory WHERE room_id=? AND archived=1", (room_id,)).fetchone():
2007
1832
  raise ValueError("房间已存档;恢复后才能撤回或编辑")
2008
1833
  if discussion.blocks_message_change(connection, room_id, message_id):
@@ -2135,17 +1960,6 @@ def add_member(connection, room_id, token, payload, now):
2135
1960
  return free_view(connection, room_id)
2136
1961
 
2137
1962
 
2138
- def discussion_roster(connection, room_id):
2139
- """Return a discussion's host, active experts and phase; away experts keep only their member row."""
2140
- row = connection.execute("SELECT host,experts,phase FROM topic_settings WHERE room_id=?", (room_id,)).fetchone()
2141
- return row["host"], json.loads(row["experts"]), row["phase"]
2142
-
2143
-
2144
- def save_discussion_experts(connection, room_id, experts):
2145
- """Write the active roster; additions preserve its plan, other changes ask the host to re-plan."""
2146
- connection.execute("UPDATE topic_settings SET experts=? WHERE room_id=?", (json.dumps(experts, ensure_ascii=False), room_id))
2147
-
2148
-
2149
1963
  def queue_invited_expert(connection, room_id, name, now):
2150
1964
  """Queue a new expert at the tail of an existing live plan, without changing the current speaker or hold.
2151
1965
 
@@ -2157,94 +1971,19 @@ def queue_invited_expert(connection, room_id, name, now):
2157
1971
  session = discussion.current_session(connection, room_id)
2158
1972
  if session and session["status"] != "concluded":
2159
1973
  discussion.queue_session_invite(connection, session, member)
2160
- return
2161
- connection.execute("""INSERT OR IGNORE INTO discussion_turns (room_id,expert,question,status,submission_id,created_at)
2162
- SELECT settings.room_id,member.name,
2163
- CASE WHEN settings.phase='initial' THEN '欢迎加入:请先读取议题,独立给出你的开场观点。'
2164
- ELSE '欢迎加入:请先读取议题和已有回答,给出你的观点。' END,
2165
- 'queued','invite:'||member.id,?
2166
- FROM topic_settings AS settings JOIN room_members AS member ON member.room_id=settings.room_id
2167
- WHERE settings.room_id=? AND member.name=? AND member.status='active'
2168
- AND settings.phase NOT IN ('paused','concluded')
2169
- AND EXISTS (SELECT 1 FROM discussion_turns WHERE room_id=settings.room_id AND status IN ('pending','notified','queued'))""",
2170
- (now, room_id, name))
2171
-
2172
-
2173
- def add_discussion_member(connection, room_id, token, payload, now):
2174
- """Owner invites one expert into a discussion using the room's delivery method."""
2175
- require_owner(connection, room_id, token)
2176
- begin_write(connection)
2177
- member = member_value(payload)
2178
- _, experts, phase = discussion_roster(connection, room_id)
2179
- run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
2180
- if not discussion_access_ok(member["access"], "cli", bool(run and run["mode"] == "automatic"), False):
2181
- raise ValueError("新专家的接入方式须与讨论的推进方式一致")
2182
- existing = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status<>'removed'", (room_id, member["name"])).fetchone()
2183
- if existing and existing["status"] == "active" and existing["token_hash"] == token_hash(member["token"]):
2184
- return
2185
- if existing:
2186
- raise FileExistsError(f"{member['name']} 已是本房间成员;离开的成员请用请回")
2187
- if phase == "concluded":
2188
- raise ValueError("讨论已收束,不能再邀请专家")
2189
- if len(experts) >= MAX_ROOM_AGENTS:
2190
- raise ValueError("房间成员已达上限")
2191
- require_fresh_member_token(connection, room_id, member["token"])
2192
- insert_member(connection, room_id, mark_manual(member, bool(run and run["mode"] == "automatic")), now)
2193
- save_discussion_experts(connection, room_id, {**experts, member["name"]: "cli"})
2194
- queue_invited_expert(connection, room_id, member["name"], now)
2195
-
2196
-
2197
- def discussion_member_action(connection, room_id, member_id, token, payload):
2198
- """Owner sends an expert away (keeping its seat), brings it back with a new credential, or removes it.
2199
-
2200
- Leaving cancels the expert's open assignments; the host keeps its role and cannot leave, and an
2201
- leaving cancels its active plan and asks the host to re-plan without pausing the discussion.
2202
- """
2203
- member = owned_member(connection, room_id, member_id, token)
2204
- host, experts, phase = discussion_roster(connection, room_id)
2205
- action = payload.get("action")
2206
- if action in ("away", "remove"):
2207
- if member["name"] == host:
2208
- raise ValueError("主持人不能离开或移除;请先更换主持人")
2209
- discussion.session_member_change(connection, room_id, member, action)
2210
- connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
2211
- (room_id, member["name"]))
2212
- connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND expert=? AND status='queued'", (room_id, member["name"]))
2213
- connection.execute("UPDATE room_members SET status=? WHERE id=?", ("away" if action == "away" else "removed", member_id))
2214
- experts.pop(member["name"], None)
2215
- elif action == "active":
2216
- hashed = token_hash(payload.get("token"))
2217
- if member["status"] == "active" and hashed and hmac.compare_digest(hashed, member["token_hash"]):
2218
- return
2219
- if member["status"] != "away" or not hashed or hmac.compare_digest(hashed, member["token_hash"]):
2220
- raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
2221
- require_fresh_member_token(connection, room_id, payload.get("token"))
2222
- connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
2223
- experts[member["name"]] = "online" if member["access"] == "online" else "cli"
2224
- if discussion.current_session(connection, room_id, active_only=True):
2225
- discussion.session_member_change(connection, room_id, member, action)
2226
- else:
2227
- queue_invited_expert(connection, room_id, member["name"], int(time.time()))
2228
- elif action == "adopt":
2229
- adopt_manual_member(connection, room_id, member, payload)
2230
- return
2231
- else:
2232
- raise ValueError("action 须为 away、active、remove 或 adopt")
2233
- save_discussion_experts(connection, room_id, experts)
2234
1974
 
2235
1975
 
2236
1976
  def adopt_manual_member(connection, room_id, member, payload):
2237
1977
  """Hand a manual expert to the local service once its CLI reported the session it runs in; the owner's service resumes that session."""
2238
1978
  reported = connection.execute("SELECT session_id FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
2239
- run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
2240
1979
  if not registered_at(connection, member):
2241
1980
  raise ValueError("成员尚未注册,不能转为托管")
2242
1981
  adapter = payload.get("adapter")
2243
1982
  if (not reported or reported["session_id"] != payload.get("session_id") or not isinstance(adapter, str)
2244
1983
  or not ADAPTER_VALUE.fullmatch(adapter) or adapter == "manual"):
2245
1984
  raise ValueError("成员尚未上报与之相符的会话,不能转为托管")
2246
- if member["access"] != "manual" or member["status"] != "active" or not (room_mode(connection, room_id) == "free" or (run and run["mode"] == "automatic")):
2247
- raise ValueError("只有自由协作或全自动讨论中在场的手动专家可以转为托管")
1985
+ if member["access"] != "manual" or member["status"] != "active":
1986
+ raise ValueError("只有在场的手动成员可以转为托管")
2248
1987
  connection.execute("UPDATE room_members SET access='runner',adapter=?,session_mode='borrowed' WHERE id=?", (payload["adapter"], member["id"]))
2249
1988
  connection.execute("DELETE FROM member_sessions WHERE member_id=?", (member["id"],))
2250
1989
  # A free room's service answers as the member, so it takes over with a credential the owner's side generated.
@@ -2299,7 +2038,7 @@ def member_action(connection, room_id, member_id, token, payload):
2299
2038
  adopt_manual_member(connection, room_id, member, payload)
2300
2039
  elif action == "role":
2301
2040
  role = payload.get("role")
2302
- if role not in ("host", "member") or (role == "host" and (member["access"] == "online" or is_web_member)):
2041
+ if role not in ("host", "member") or (role == "host" and is_web_member):
2303
2042
  raise ValueError("role 须为 host 或 member;在线成员不能担任主持人")
2304
2043
  if role == "member" and member["role"] == "host":
2305
2044
  raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")