ai_co_discuss 0.2.32 → 0.2.34

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.
@@ -21,6 +21,7 @@ description: "AI CoDiscuss 的主持技能,仅主持人调用,且须同时
21
21
 
22
22
  - `initial`:必要专家先独立判断。用户明确允许某位专家先读他人观点时,在该专家的安排问题里写明“对 initial 独立阅读限制的明确例外”,API 专家的读取限制据此放开,其他专家按技能约定。主持人兼任专家时也要贡献自己的观点;尽量先安排自己,避免先读他人回答再声称独立。
23
23
  - `discussing`:找出会改变结论的事实、假设、标准或偏好差异,请最相关的人回应。一轮内每位专家各回应一次,轮次以此计数;不连续安排刚发言的专家,新一轮也不先安排上一轮最后发言者。只有需要其本人澄清刚发表内容的事实或原意时才例外,并在简报中说明原因。
24
+ - 房间的 `named_followup`(点名追问)为 false 时,首轮照常拆题出题;之后各轮不给每位专家写针对性追问,统一请专家先读上一轮回答、回应不同意或需补充的观点、可 @ 彼此提问。需要引导方向时用自己的主持说明表达,不改写成逐人点名。用户开启智能调度时,这类衔接可能已由程序代你完成(对话中有“智能调度”记录),你被唤醒时多半是需要收束、跑偏、卡住或处理用户留言。
24
25
  - `reviewing`:仅在需要时,用拟定结论请相关当事人核对原意、条件和剩余阻碍;不是让全员再赞同一次。
25
26
  - `concluded`:已有足够依据完成本次目标,或依约交付有限结论。明确哪些已确认、哪些仍有分歧、缺什么证据;到限不等于达成共识。
26
27
 
@@ -55,7 +56,7 @@ description: "AI CoDiscuss 的主持技能,仅主持人调用,且须同时
55
56
 
56
57
  收到自动任务后,按参与技能进入本次执行;若当前调用已成功进入,不重复认领。不创建原生定时,也不手动通知已配置的本地专家,通知程序负责后续交付与续租。
57
58
 
58
- 阅读新增的相关回答、用户约束及必要原文,判断下一步是追问、补证、复核还是收束。任务带 `scheduler_suggestions` 时,那是用户开启的智能调度对未处理留言给出的分流建议(请某位专家回应、主持人处理或仅补充信息),附置信度;读原文后自行决定是否采纳,不当作用户指令或已核实的判断。不要只回复“已就绪”或只写一段摘要就退出;本次执行需要以以下一种结果交接:
59
+ 阅读新增的相关回答、用户约束及必要原文,判断下一步是追问、补证、复核还是收束。用户开启智能调度后,程序有把握时会代你完成插话分流(按原计划继续或先请某位专家回应)和无点名追问时的下一轮衔接,并在 `room view` 的 `scheduler_decisions` 留下记录;没把握时才唤醒你。任务带 `scheduler_suggestions` 时,那是它对未处理留言的判断,附置信度;读原文后自行决定是否采纳,不当作用户指令或已核实的判断。不要只回复“已就绪”或只写一段摘要就退出;本次执行需要以以下一种结果交接:
59
60
 
60
61
  | 判断 | 保存与结束边界 |
61
62
  | --- | --- |
@@ -27,6 +27,9 @@ MAX_PATH_LENGTH = 500
27
27
  ACTIVE = ("dispatched", "started")
28
28
  ACCESS_MODES = ("runner", "manual", "online")
29
29
  SCHEDULER_PROVIDERS = ("jev", "decisions")
30
+ RECOMMEND_SECONDS = 60
31
+ # A click sent just before the hint disappears still counts.
32
+ RECOMMEND_GRACE_SECONDS = 5
30
33
  # AI-created requests (host dispatches and hand-offs) allowed per chain; each owner message starts a chain.
31
34
  MAX_AUTO_HOPS = 8
32
35
  # A host that left gives up the role; the first active runner member (else manual) then takes it.
@@ -236,14 +239,13 @@ def free_view(connection, room_id):
236
239
  members.append(member)
237
240
  names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
238
241
  requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
239
- """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,
240
- (SELECT provider FROM request_schedules WHERE request_id=member_requests.id) AS schedule_provider,
241
- (SELECT confidence FROM request_schedules WHERE request_id=member_requests.id) AS schedule_confidence FROM member_requests
242
+ """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by FROM member_requests
242
243
  WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
243
244
  chains = [dict(row) for row in connection.execute(
244
245
  "SELECT chain_id,reason FROM free_chains WHERE room_id=? ORDER BY chain_id DESC LIMIT 10", (room_id,))]
245
246
  return {"members": members, "requests": requests[::-1], "executions": recent_executions(connection, room_id), "execution_minutes": execution_minutes(connection, room_id),
246
- "conflicts": possible_conflicts(connection, room_id, names), "halted_chains": chains, "max_auto_hops": MAX_AUTO_HOPS}
247
+ "conflicts": possible_conflicts(connection, room_id, names), "halted_chains": chains, "max_auto_hops": MAX_AUTO_HOPS,
248
+ "recommendations": recent_recommendations(connection, room_id, now)[::-1]}
247
249
 
248
250
 
249
251
  def require_member(connection, room_id, token, name=None):
@@ -557,6 +559,7 @@ def inbox(connection, room_id, token):
557
559
  require_owner(connection, room_id, token)
558
560
  begin_write(connection)
559
561
  expire_executions(connection, room_id)
562
+ release_recommendations(connection, room_id)
560
563
  archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
561
564
  members = []
562
565
  for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status='active' AND access='runner' ORDER BY id", (room_id,)):
@@ -570,28 +573,76 @@ def inbox(connection, room_id, token):
570
573
  "execution_minutes": execution_minutes(connection, room_id)}
571
574
 
572
575
 
573
- def schedule_request(connection, room_id, token, request_id, payload):
574
- """Smart scheduling moves one unnamed message from the host to a woken member before the host takes it."""
576
+ def host_route_request(connection, message_id):
577
+ """The host's request for one unnamed owner message, if the message was routed."""
578
+ return connection.execute("SELECT * FROM member_requests WHERE message_id=? AND purpose='route'", (message_id,)).fetchone()
579
+
580
+
581
+ def recommend_member(connection, room_id, token, payload):
582
+ """Smart scheduling suggests another member for an unnamed message; the host keeps it until the user adopts."""
575
583
  require_owner(connection, room_id, token)
576
584
  begin_write(connection)
577
- if payload.get("action") != "schedule":
578
- raise ValueError("只支持 action=schedule")
579
585
  confidence = payload.get("confidence")
580
586
  if payload.get("provider") not in SCHEDULER_PROVIDERS or type(confidence) not in (int, float) or not 0 <= confidence <= 1:
581
587
  raise ValueError("须提供智能调度供应商 provider 与 0–1 的置信度 confidence")
582
- request = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
583
- if not request:
584
- raise LookupError("请求不存在")
585
- if request["purpose"] != "route" or request["status"] != "pending":
586
- raise FileExistsError("请求已由主持人接手或已处理,不能改派")
588
+ message_id = payload.get("message_id")
589
+ route = host_route_request(connection, message_id) if type(message_id) is int else None
590
+ if not route or route["room_id"] != room_id:
591
+ raise LookupError("只能为交给主持人的未点名消息给出推荐")
587
592
  target = connection.execute("""SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active'
588
- AND access='runner' AND role<>'host'""", (room_id, payload.get("member_id"))).fetchone()
593
+ AND access='runner' AND id<>?""", (room_id, payload.get("member_id"), route["member_id"])).fetchone()
589
594
  if not target:
590
- raise LookupError("改派对象须为在场、由本机唤醒的非主持成员")
591
- connection.execute("UPDATE member_requests SET member_id=?,purpose='scheduled' WHERE id=?", (target["id"], request_id))
592
- connection.execute("INSERT OR REPLACE INTO request_schedules (request_id,room_id,provider,confidence) VALUES (?,?,?,?)",
593
- (request_id, room_id, payload["provider"], float(confidence)))
594
- return dict(connection.execute("SELECT * FROM member_requests WHERE id=?", (request_id,)).fetchone())
595
+ raise LookupError("推荐对象须为在场、由本机唤醒的其他成员")
596
+ now = int(time.time())
597
+ connection.execute("""INSERT OR IGNORE INTO member_recommendations (message_id,room_id,member_id,provider,confidence,created_at,expires_at)
598
+ VALUES (?,?,?,?,?,?,?)""", (message_id, room_id, target["id"], payload["provider"], float(confidence), now, now + RECOMMEND_SECONDS))
599
+ return dict(connection.execute("SELECT * FROM member_recommendations WHERE message_id=?", (message_id,)).fetchone())
600
+
601
+
602
+ def release_recommendations(connection, room_id):
603
+ """Ask each adopted-later member once the host's request for that message has ended in any way."""
604
+ for row in connection.execute("""SELECT rec.* FROM member_recommendations rec JOIN member_requests r ON r.message_id=rec.message_id AND r.purpose='route'
605
+ WHERE rec.room_id=? AND rec.status='waiting' AND r.status IN ('completed','cancelled','failed')""", (room_id,)).fetchall():
606
+ connection.execute("UPDATE member_recommendations SET status='adopted' WHERE message_id=?", (row["message_id"],))
607
+ if connection.execute("SELECT 1 FROM room_members WHERE id=? AND status='active'", (row["member_id"],)).fetchone():
608
+ connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
609
+ VALUES (?,?,?,?,'ask','user',?)""", (room_id, row["member_id"], row["message_id"], row["message_id"], int(time.time())))
610
+
611
+
612
+ def adopt_recommendation(connection, room_id, token, message_id, payload):
613
+ """The user adopts a recommendation before it expires: interrupt the host now, or ask the member after the host."""
614
+ require_owner(connection, room_id, token)
615
+ begin_write(connection)
616
+ if payload.get("action") != "adopt" or payload.get("mode") not in ("interrupt", "after"):
617
+ raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after")
618
+ row = connection.execute("SELECT * FROM member_recommendations WHERE room_id=? AND message_id=?", (room_id, message_id)).fetchone()
619
+ if not row:
620
+ raise LookupError("这条消息没有智能调度推荐")
621
+ if row["status"] != "open":
622
+ return {"recommendation": dict(row), "duplicate": True}
623
+ if int(time.time()) > row["expires_at"] + RECOMMEND_GRACE_SECONDS:
624
+ raise FileExistsError("推荐已过期,可直接 @ 该成员")
625
+ if not connection.execute("SELECT 1 FROM room_members WHERE id=? AND status='active'", (row["member_id"],)).fetchone():
626
+ raise FileExistsError("推荐的成员已离开")
627
+ route = host_route_request(connection, message_id)
628
+ mode = payload["mode"]
629
+ if mode == "interrupt" and route["status"] == "delivering":
630
+ # The host's execution may carry other messages too; stopping it voids that whole execution.
631
+ finish_execution(connection, route["execution_id"], "cancelled", "用户采纳智能调度推荐,改由其他成员回答", "cancelled")
632
+ elif mode == "interrupt" and route["status"] == "pending":
633
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
634
+ connection.execute("UPDATE member_recommendations SET status='waiting',mode=? WHERE message_id=?", (mode, message_id))
635
+ release_recommendations(connection, room_id)
636
+ settle_batches(connection, room_id)
637
+ return {"recommendation": dict(connection.execute("SELECT * FROM member_recommendations WHERE message_id=?", (message_id,)).fetchone()),
638
+ "duplicate": False}
639
+
640
+
641
+ def recent_recommendations(connection, room_id, now):
642
+ """Recommendations shown beside their messages, with seconds left to adopt an open one."""
643
+ return [{**dict(row), "expires_in": max(0, row["expires_at"] - now)} for row in connection.execute(
644
+ """SELECT rec.*,m.name AS member FROM member_recommendations rec JOIN room_members m ON m.id=rec.member_id
645
+ WHERE rec.room_id=? ORDER BY rec.message_id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
595
646
 
596
647
 
597
648
  def reserve_execution(connection, room_id, token, payload):
@@ -790,11 +841,26 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
790
841
  require_fresh_member_token(connection, room_id, payload.get("token"))
791
842
  connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
792
843
  experts[member["name"]] = "online" if member["access"] == "online" else "cli"
844
+ elif action == "adopt":
845
+ adopt_manual_member(connection, room_id, member, payload)
846
+ return
793
847
  else:
794
- raise ValueError("action 须为 away、active、remove 或 redo")
848
+ raise ValueError("action 须为 away、active、remove、redo 或 adopt")
795
849
  save_discussion_experts(connection, room_id, experts)
796
850
 
797
851
 
852
+ def adopt_manual_member(connection, room_id, member, payload):
853
+ """Hand a manual expert to the local service once its CLI reported the session it runs in; the owner's service resumes that session."""
854
+ reported = connection.execute("SELECT session_id FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
855
+ run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
856
+ if not reported or reported["session_id"] != payload.get("session_id") or payload.get("adapter") != "claude":
857
+ raise ValueError("成员尚未上报与之相符的会话,不能转为托管")
858
+ if member["access"] != "manual" or member["status"] != "active" or not run or run["mode"] != "automatic":
859
+ raise ValueError("只有全自动讨论中在场的手动专家可以转为托管")
860
+ connection.execute("UPDATE room_members SET access='runner',adapter='claude',session_mode='borrowed' WHERE id=?", (member["id"],))
861
+ connection.execute("DELETE FROM member_sessions WHERE member_id=?", (member["id"],))
862
+
863
+
798
864
  def void_latest_opinion(connection, room_id, name, phase):
799
865
  """Delete an expert's latest opinion while paused; its assignment is cancelled and the round no longer counts it."""
800
866
  if phase != "paused":
@@ -137,6 +137,12 @@ def settings_statements(tables, room, maps, now):
137
137
  for row in tables.get("room_briefs", [])[:1]:
138
138
  statements.append(("INSERT INTO room_briefs (room_id,text,until,updated_at) VALUES (?,?,?,?)",
139
139
  (room_id, remap_text(row["text"], messages), remap_until(row["until"], messages), row["updated_at"])))
140
+ for row in tables.get("discussion_options", [])[:1]:
141
+ statements.append(("INSERT INTO discussion_options (room_id,named_followup) VALUES (?,?)", (room_id, row["named_followup"])))
142
+ statements += [("""INSERT INTO scheduler_decisions (room_id,after_message_id,kind,label,confidence,applied,provider,created_at)
143
+ VALUES (?,?,?,?,?,?,?,?)""", (room_id, messages.get(row["after_message_id"], 0), row["kind"], row["label"],
144
+ row.get("confidence"), row["applied"], row["provider"], row["created_at"]))
145
+ for row in tables.get("scheduler_decisions", [])]
140
146
  return statements
141
147
 
142
148
 
@@ -159,8 +165,11 @@ def record_statements(tables, room_id, maps):
159
165
  for row in tables.get("member_requests", [])]
160
166
  statements += [("INSERT INTO member_suggestions (message_id,member_id) VALUES (?,?)", (messages[row["message_id"]], members[row["member_id"]]))
161
167
  for row in tables.get("member_suggestions", [])]
162
- statements += [("INSERT INTO request_schedules (request_id,room_id,provider,confidence) VALUES (?,?,?,?)",
163
- (requests[row["request_id"]], room_id, row["provider"], row["confidence"])) for row in tables.get("request_schedules", [])]
168
+ # Imported recommendations are history only: an open or waiting one no longer acts.
169
+ statements += [("""INSERT INTO member_recommendations (message_id,room_id,member_id,provider,confidence,created_at,expires_at,status,mode)
170
+ VALUES (?,?,?,?,?,?,?,?,?)""", (messages[row["message_id"]], room_id, members[row["member_id"]], row["provider"], row["confidence"],
171
+ row["created_at"], row["expires_at"], "adopted" if row["status"] == "adopted" else "open", row.get("mode", "")))
172
+ for row in tables.get("member_recommendations", [])]
164
173
  return statements
165
174
 
166
175
 
@@ -533,6 +533,13 @@ CREATE TABLE IF NOT EXISTS room_members (
533
533
  );
534
534
  CREATE UNIQUE INDEX IF NOT EXISTS member_name ON room_members(room_id,name) WHERE status<>'removed';
535
535
  CREATE UNIQUE INDEX IF NOT EXISTS member_credential ON room_members(room_id,token_hash);
536
+ -- A manual member's own software session, reported by its CLI on posting; the local service uses it to take the member over.
537
+ CREATE TABLE IF NOT EXISTS member_sessions (
538
+ member_id INTEGER PRIMARY KEY REFERENCES room_members(id) ON DELETE CASCADE,
539
+ room_id TEXT NOT NULL,
540
+ session_id TEXT NOT NULL,
541
+ client TEXT NOT NULL
542
+ );
536
543
  CREATE TABLE IF NOT EXISTS member_executions (
537
544
  id INTEGER PRIMARY KEY AUTOINCREMENT,
538
545
  room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
@@ -569,12 +576,36 @@ CREATE TABLE IF NOT EXISTS member_requests (
569
576
  UNIQUE(message_id,member_id)
570
577
  );
571
578
  CREATE INDEX IF NOT EXISTS member_requests_open ON member_requests(member_id,status);
572
- -- Smart scheduling's decision for a request it moved from the host to a member; a separate table so D1 needs no column change.
573
- CREATE TABLE IF NOT EXISTS request_schedules (
574
- request_id INTEGER PRIMARY KEY REFERENCES member_requests(id) ON DELETE CASCADE,
579
+ -- Discussion options chosen at creation. A room without a row predates the option and keeps named follow-ups.
580
+ CREATE TABLE IF NOT EXISTS discussion_options (
581
+ room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
582
+ named_followup INTEGER NOT NULL DEFAULT 0 CHECK(named_followup IN (0,1))
583
+ );
584
+ -- Visible record of each smart-scheduling decision in a discussion; it is not a message and moves no round or floor.
585
+ CREATE TABLE IF NOT EXISTS scheduler_decisions (
586
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
575
587
  room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
588
+ after_message_id INTEGER NOT NULL DEFAULT 0,
589
+ kind TEXT NOT NULL CHECK(kind IN ('interjection','round')),
590
+ label TEXT NOT NULL,
591
+ confidence REAL,
592
+ applied INTEGER NOT NULL DEFAULT 0 CHECK(applied IN (0,1)),
576
593
  provider TEXT NOT NULL,
577
- confidence REAL NOT NULL
594
+ created_at TEXT NOT NULL
595
+ );
596
+ CREATE INDEX IF NOT EXISTS scheduler_decisions_room ON scheduler_decisions(room_id,id);
597
+ -- Smart scheduling's suggestion that another member answer an unnamed message. The host keeps the message;
598
+ -- the user may adopt before expires_at (waiting until the host's request ends for mode 'after'), otherwise it lapses.
599
+ CREATE TABLE IF NOT EXISTS member_recommendations (
600
+ message_id INTEGER PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
601
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
602
+ member_id INTEGER NOT NULL REFERENCES room_members(id) ON DELETE CASCADE,
603
+ provider TEXT NOT NULL,
604
+ confidence REAL NOT NULL,
605
+ created_at INTEGER NOT NULL,
606
+ expires_at INTEGER NOT NULL,
607
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','waiting','adopted')),
608
+ mode TEXT NOT NULL DEFAULT ''
578
609
  );
579
610
  -- Ordinary members can suggest a response without creating work or spending a chain budget.
580
611
  CREATE TABLE IF NOT EXISTS member_suggestions (
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 schedule_request
26
+ from .free_room import adopt_recommendation, recommend_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,
@@ -44,6 +44,7 @@ LOCAL_LAUNCH_TIMEOUT = 60
44
44
  DIRECTORY_OPERATIONS = ("move", "folder")
45
45
  DIRECTORY_PICKER_TIMEOUT = 600 # the folder dialog waits for the person at the computer
46
46
  MAX_TEXT_LENGTH = 32000
47
+ SESSION_ID_PATTERN = re.compile(r"[0-9A-Za-z_-]{8,128}")
47
48
  MAX_STATE_LENGTH = 4000
48
49
  MAX_BRIEF_LENGTH = 1600
49
50
  MAX_SUMMARY_LENGTH = 1000
@@ -71,11 +72,15 @@ TURN_STATUSES = {"pending": "待处理", "notified": "已通知,待回答", "a
71
72
  MAX_TOPIC_DETAIL = 4000
72
73
  MAX_QUESTION_LENGTH = 1000
73
74
  MAX_PLAN_TURNS = 10
75
+ RECENT_DECISIONS = 100
76
+ MAX_DECISION_LABEL = 200
77
+ SCHEDULER_PROVIDERS = ("jev", "decisions")
74
78
  ROOM_BACKUP_QUERIES = {
75
79
  "rooms": "SELECT * FROM rooms WHERE id=?",
76
80
  **{table: f"SELECT * FROM {table} WHERE room_id=?" for table in (
77
81
  "room_directory", "room_folder_assignments", "room_creations", "room_briefs", "room_user_requests",
78
- "topic_settings", "room_floor", "discussion_limits")},
82
+ "topic_settings", "room_floor", "discussion_limits", "discussion_options")},
83
+ "scheduler_decisions": "SELECT * FROM scheduler_decisions WHERE room_id=? ORDER BY id",
79
84
  "topic_folders": "SELECT * FROM topic_folders WHERE id IN (SELECT folder_id FROM room_folder_assignments WHERE room_id=?)",
80
85
  "messages": "SELECT * FROM messages WHERE room_id=? ORDER BY id",
81
86
  "attachments": "SELECT * FROM attachments WHERE room_id=? AND message_id IS NOT NULL ORDER BY message_id,position",
@@ -90,7 +95,7 @@ FREE_BACKUP_QUERIES = {
90
95
  "room_members": "SELECT id,room_id,name,adapter,role,status,read_until,created_at,access,session_mode FROM room_members WHERE room_id=? ORDER BY id",
91
96
  "member_requests": "SELECT * FROM member_requests WHERE room_id=? ORDER BY id",
92
97
  "member_suggestions": "SELECT s.* FROM member_suggestions s JOIN messages m ON m.id=s.message_id WHERE m.room_id=? ORDER BY s.message_id,s.member_id",
93
- "request_schedules": "SELECT * FROM request_schedules WHERE room_id=? ORDER BY request_id",
98
+ "member_recommendations": "SELECT * FROM member_recommendations WHERE room_id=? ORDER BY message_id",
94
99
  }
95
100
  MESSAGE_QUERY = """SELECT messages.*, COALESCE(details.kind,'opinion') AS kind,
96
101
  COALESCE(details.submitted_by, CASE WHEN messages.source IN ('cli','api','url','user')
@@ -206,7 +211,13 @@ def require_room(connection, room_id):
206
211
  room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
207
212
  room["mode"] = room_mode(connection, room_id)
208
213
  room["members"] = [dict(row) for row in connection.execute(
209
- "SELECT id,name,adapter,role,status,access,session_mode FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY 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
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,))]
216
+ option = connection.execute("SELECT named_followup FROM discussion_options WHERE room_id=?", (room_id,)).fetchone()
217
+ # Rooms created before the option keep the host's named follow-ups.
218
+ room["named_followup"] = bool(option[0]) if option else True
219
+ room["scheduler_decisions"] = [dict(row) for row in connection.execute(
220
+ "SELECT * FROM scheduler_decisions WHERE room_id=? ORDER BY id DESC LIMIT ?", (room_id, RECENT_DECISIONS))][::-1]
210
221
  parent = connection.execute("""SELECT rooms.id,rooms.title FROM room_links JOIN rooms ON rooms.id=room_links.parent_id
211
222
  WHERE room_links.room_id=?""", (room_id,)).fetchone()
212
223
  room["parent_room"] = dict(parent) if parent else None
@@ -538,7 +549,8 @@ def manage_room(connection, room_id, action, payload):
538
549
 
539
550
  def delete_room_records(connection, room_id):
540
551
  """Remove a room's dependent records in the caller's transaction."""
541
- for table in ("request_schedules", "member_requests", "member_executions", "room_members", "room_modes", "free_batches", "free_chains", "room_links"):
552
+ for table in ("member_recommendations", "member_requests", "member_executions", "member_sessions", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
553
+ "discussion_options", "scheduler_decisions"):
542
554
  connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
543
555
  connection.execute("DELETE FROM message_timings WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
544
556
  connection.execute("DELETE FROM message_summaries WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
@@ -578,6 +590,9 @@ def create_room(connection, payload, control_token=""):
578
590
  raise ValueError("建房须提供有效的运行模式和房主控制凭据")
579
591
  if free and (automatic or not token_hash(control_token)):
580
592
  raise ValueError("自由房间须提供房间控制凭据,且不使用讨论运行模式")
593
+ named_followup = payload.get("named_followup", False)
594
+ if type(named_followup) is not bool:
595
+ raise ValueError("named_followup 须为布尔值")
581
596
  parent_id = payload.get("parent_room_id")
582
597
  if parent_id is not None and (free or not isinstance(parent_id, str) or room_mode(connection, parent_id) != "free"):
583
598
  raise ValueError("只有从自由协作房间发起的讨论房间可以关联来源房间")
@@ -590,6 +605,8 @@ def create_room(connection, payload, control_token=""):
590
605
  [[member["name"], member["adapter"], member["access"], member["session_mode"], token_hash(member["token"])] for member in members], ensure_ascii=False)
591
606
  if free:
592
607
  request_text += ":free"
608
+ elif named_followup:
609
+ request_text += ":named_followup"
593
610
  if parent_id is not None:
594
611
  request_text += ":parent:" + parent_id
595
612
  if folder_id is not None:
@@ -613,6 +630,8 @@ def create_room(connection, payload, control_token=""):
613
630
  {"mode": "free" if free else "brainstorm", "host": settings["host"], "created_at": utc_now()})
614
631
  if free:
615
632
  sync_participants(connection, room_id)
633
+ else:
634
+ connection.execute("INSERT INTO discussion_options (room_id,named_followup) VALUES (?,?)", (room_id, int(named_followup)))
616
635
  if parent_id is not None:
617
636
  connection.execute("INSERT INTO room_links (room_id,parent_id,created_at) VALUES (?,?,?)", (room_id, parent_id, utc_now()))
618
637
  if automatic:
@@ -855,6 +874,18 @@ def append_free_message(connection, room, payload, actor):
855
874
  return {"message": get_message(connection, room_id, cursor.lastrowid), "duplicate": False}
856
875
 
857
876
 
877
+ def record_reported_session(connection, room_id, token, payload):
878
+ """Keep the software session a manual member posted from, so the local service can take the member over."""
879
+ session_id, client = payload.get("session_id"), payload.get("session_client")
880
+ if client != "claude" or not isinstance(session_id, str) or not SESSION_ID_PATTERN.fullmatch(session_id) or not token:
881
+ return
882
+ member = connection.execute("SELECT id FROM room_members WHERE room_id=? AND token_hash=? AND status='active' AND access='manual' AND adapter='manual'",
883
+ (room_id, token_hash(token))).fetchone()
884
+ if member:
885
+ connection.execute("""INSERT INTO member_sessions (member_id,room_id,session_id,client) VALUES (?,?,?,?)
886
+ ON CONFLICT(member_id) DO UPDATE SET session_id=excluded.session_id,client=excluded.client""", (member["id"], room_id, session_id, client))
887
+
888
+
858
889
  def append_message(connection, room_id, payload, token="", free_actor=None):
859
890
  """Atomically append an attributed message and complete its scheduled turn."""
860
891
  author, text, submission_id, reply_to = message_values(payload)
@@ -893,6 +924,7 @@ def append_message(connection, room_id, payload, token="", free_actor=None):
893
924
  connection.execute("UPDATE attachments SET message_id=?,position=? WHERE id=?",
894
925
  (cursor.lastrowid, position, identifier))
895
926
  record_message_timing(connection, cursor.lastrowid, token)
927
+ record_reported_session(connection, room_id, token, payload)
896
928
  if details["turn_id"]:
897
929
  connection.execute("UPDATE discussion_turns SET status='answered',answer_id=? WHERE id=?", (cursor.lastrowid, details["turn_id"]))
898
930
  return {"message": get_message(connection, room_id, cursor.lastrowid), "duplicate": False}
@@ -967,6 +999,23 @@ def create_turn(connection, room_id, payload):
967
999
  "queued": [get_turn(connection, room_id, turn_id) for turn_id in ids[1:]], "duplicate": False}
968
1000
 
969
1001
 
1002
+ def record_scheduler_decision(connection, room_id, token, payload):
1003
+ """The dispatcher records one smart-scheduling decision so the transcript shows what it did and how sure it was."""
1004
+ begin_write(connection)
1005
+ require_control(connection, room_id, token)
1006
+ kind, label, confidence = payload.get("kind"), payload.get("label"), payload.get("confidence")
1007
+ after = payload.get("after_message_id", 0)
1008
+ if kind not in ("interjection", "round") or not isinstance(label, str) or not 0 < len(label.strip()) <= MAX_DECISION_LABEL:
1009
+ raise ValueError(f"决策记录须有 kind(interjection/round)与 1–{MAX_DECISION_LABEL} 字的 label")
1010
+ if confidence is not None and (type(confidence) not in (int, float) or not 0 <= confidence <= 1):
1011
+ raise ValueError("confidence 须为 0–1 或 null")
1012
+ if payload.get("provider") not in SCHEDULER_PROVIDERS or type(payload.get("applied")) is not bool or type(after) is not int or after < 0:
1013
+ raise ValueError("须提供供应商 provider、是否执行 applied 与非负的 after_message_id")
1014
+ cursor = connection.execute("""INSERT INTO scheduler_decisions (room_id,after_message_id,kind,label,confidence,applied,provider,created_at)
1015
+ VALUES (?,?,?,?,?,?,?,?)""", (room_id, after, kind, label.strip(), confidence, int(payload["applied"]), payload["provider"], utc_now()))
1016
+ return dict(connection.execute("SELECT * FROM scheduler_decisions WHERE id=?", (cursor.lastrowid,)).fetchone())
1017
+
1018
+
970
1019
  def continue_turns(connection, room_id, payload):
971
1020
  """Resume the remaining round plan after the host has handled user actions or an interruption."""
972
1021
  begin_write(connection)
@@ -1446,7 +1495,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1446
1495
  """Forward authenticated browser requests to the shared Node entrypoint and pin their website."""
1447
1496
  site = self.local_request_site(method)
1448
1497
  route = parts[2:]
1449
- if method == "OPTIONS" and route in (["settings"], ["launch"], ["room-models"], ["room-member"], ["models"], ["api-key"], ["api-check"], ["pick-directory"]):
1498
+ if method == "OPTIONS" and route in (["settings"], ["launch"], ["room-models"], ["room-member"], ["models"], ["api-key"], ["api-check"],
1499
+ ["scheduler-check"], ["pick-directory"]):
1450
1500
  return self.respond({"ok": True})
1451
1501
  if route == ["settings"] and method in ("GET", "POST"):
1452
1502
  command = "settings"
@@ -1462,6 +1512,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1462
1512
  command = "api-key"
1463
1513
  elif route == ["api-check"] and method == "POST":
1464
1514
  command = "api-check"
1515
+ elif route == ["scheduler-check"] and method == "POST":
1516
+ command = "scheduler-check"
1465
1517
  elif route == ["room-member"] and method == "POST":
1466
1518
  command = "room-member"
1467
1519
  elif route == ["pick-directory"] and method == "POST":
@@ -1559,7 +1611,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1559
1611
  room_id = parts[2]
1560
1612
  token = self.headers.get("X-Discussion-Token", "")
1561
1613
  free = room_mode(connection, room_id) == "free"
1562
- if free and parts[3:4] in (["inbox"], ["executions"], ["members"], ["suggestions"], ["requests"]):
1614
+ if free and parts[3:4] in (["inbox"], ["executions"], ["members"], ["suggestions"], ["recommendations"]):
1563
1615
  return self.handle_free_api(connection, parts, method)
1564
1616
  if parts[3:4] == ["members"] and method == "POST" and len(parts) in (4, 5):
1565
1617
  if len(parts) == 4:
@@ -1571,7 +1623,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1571
1623
  return self.respond(result)
1572
1624
  if parts[3:4] == ["attachments"]:
1573
1625
  return self.handle_attachments(connection, parts, params, method)
1574
- if parts[3:4] in (["runtime"], ["events"]):
1626
+ if parts[3:4] in (["runtime"], ["events"], ["scheduler-decisions"]):
1575
1627
  if free and method == "POST":
1576
1628
  raise ValueError("自由房间不使用讨论运行控制")
1577
1629
  return self.handle_runtime_api(connection, parts, params, method)
@@ -1702,6 +1754,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1702
1754
  result = list_events(connection, room_id, token, params.get("after", 0))
1703
1755
  elif len(parts) == 5 and parts[3] == "events" and method == "POST":
1704
1756
  result = event_action(connection, room_id, int(parts[4]), token, self.read_payload())
1757
+ elif parts[3:] == ["scheduler-decisions"] and method == "POST":
1758
+ result = record_scheduler_decision(connection, room_id, token, self.read_payload())
1705
1759
  else:
1706
1760
  raise LookupError("地址不存在")
1707
1761
  connection.commit()
@@ -1717,8 +1771,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1717
1771
  result = convert_suggestion(connection, room_id, token, self.read_payload())
1718
1772
  elif parts[3:] == ["executions"] and method == "POST":
1719
1773
  result = reserve_execution(connection, room_id, token, self.read_payload())
1720
- elif len(parts) == 5 and parts[3] == "requests" and method == "POST":
1721
- result = schedule_request(connection, room_id, token, int(parts[4]), self.read_payload())
1774
+ elif parts[3:] == ["recommendations"] and method == "POST":
1775
+ result = recommend_member(connection, room_id, token, self.read_payload())
1776
+ elif len(parts) == 5 and parts[3] == "recommendations" and method == "POST":
1777
+ result = adopt_recommendation(connection, room_id, token, int(parts[4]), self.read_payload())
1722
1778
  elif len(parts) == 5 and parts[3] == "executions":
1723
1779
  execution_id = int(parts[4])
1724
1780
  result = (view_execution(connection, room_id, execution_id, token) if method == "GET"
@@ -215,6 +215,12 @@ Python 通过进程内条件变量唤醒订阅者,不占用数据库连接,
215
215
 
216
216
  未提供用户登录、讨论并行发言队列或跨站数据同步。自动执行保护不替代账号认证。Skill 行为、浏览器实际送达、软件原生任务启停与 API 账号的真实可用性必须通过实际环境验证;测试替身成功不能替代这些外部结果。
217
217
 
218
+ ## 讨论选项与智能调度记录
219
+
220
+ - `discussion_options`:建房时的讨论选项,目前只有 `named_followup`(0/1,建房 `named_followup` 布尔值,默认 false,计入建房幂等比对)。没有记录的旧房间视为 true;房间视图返回 `named_followup`。
221
+ - `scheduler_decisions`:讨论中每次智能调度判断的可见记录(`kind` 为 `interjection` / `round`,`label`、`confidence`、`applied`、`provider`、`after_message_id`),由接入程序用运行控制凭据 `POST scheduler-decisions` 写入,不是消息,不影响轮次、发言权与暂扣;房间视图 `scheduler_decisions` 返回最近 100 条。
222
+ - 两表都是独立表,D1 重新应用 schema 即可;备份、导入(`after_message_id` 随消息重编号)与删除一并处理。智能调度代主持人执行的写入不新增权限,沿用该主持事件的执行凭据。
223
+
218
224
  ## 自动运行协议
219
225
 
220
226
  `discussion_limits` 保存房间上限及开始/收束时间。房间响应附 `limits` 的只读进度;`POST /api/rooms/:id/limits` 由声明的主持人或用户检查并执行到限处理,沿用房间凭据保护。配置通过创建房间/更新话题的 `limits` 对象写入,仅准备或暂停阶段可修改;时钟不重置。SQLite 在写事务中校验,D1 在同一批次断言参数版本、消息截止及时间边界,避免并发越界。到限处理取消未完成安排并撤销旧代次;总结需真实主持消息与阶段更新,不由服务器生成。GET 不改变阶段。请求附 `force: true` 时,用户可在进行中的讨论提前收尾:不论是否到限、到限方式为何,均按 `stop_reason=user_stop` 进入收束,由主持人在收束预留内总结;准备、暂停、收束或存档状态返回 400。
@@ -240,11 +246,11 @@ running 状态即使只有 pending 事件,也必须先暂停才能重新启动
240
246
  自由房间(`room_modes.mode='free'`)与讨论房间共用房间、成员登记、消息、附件与目录,没有发言权、安排、阶段推进和轮次。没有控制凭据登记的旧房间只供阅读,不支持通过首次运行配置认领;本次不迁移历史房间。
241
247
 
242
248
  - **凭据**:建房时由客户端提供房间控制凭据和每位成员的成员凭证,服务端只存哈希。用户身份的房间写入(发言、附件、存档、恢复、删除、打断)都须控制凭据;房间排序和归入文件夹只改共享目录,与站点级文件夹操作一样不需要凭据;无凭据、错误凭据、成员凭证或他房凭据一律只读。成员凭证只能以该成员身份发言或上传附件。声明字段(`author`、`source`、localhost)不代替凭据。
243
- - **成员**:`room_members` 以 `id` 为稳定主键,`access` 为 `runner`(接入程序投递)、`manual`(成员自行读写)或 `online`(只读、原文代发),`session_mode` 标记是否借用用户正在使用的会话。房主用控制凭据 `POST members` 运行中加入成员,`POST members/ID {action}` 让成员离开(`away`,作废其进行中执行与未投递请求)、请回(`active`,必须提供不同于原凭证的新凭证,旧凭证副本不再有效)或移除(`remove`,保留历史发言)。同一成员凭证不能复用于其他成员或房主;相同凭证与配置的邀请、请回重试返回已有结果。会话续接由本机接入程序负责,服务端不保存会话绑定。
249
+ - **成员**:`room_members` 以 `id` 为稳定主键,`access` 为 `runner`(接入程序投递)、`manual`(成员自行读写)或 `online`(只读、原文代发),`session_mode` 标记是否借用用户正在使用的会话。房主用控制凭据 `POST members` 运行中加入成员,`POST members/ID {action}` 让成员离开(`away`,作废其进行中执行与未投递请求)、请回(`active`,必须提供不同于原凭证的新凭证,旧凭证副本不再有效)或移除(`remove`,保留历史发言)。同一成员凭证不能复用于其他成员或房主;相同凭证与配置的邀请、请回重试返回已有结果。会话续接由本机接入程序负责,服务端不保存会话绑定;唯一例外是手动成员上报的会话:`message post` 可带 `session_id` 与 `session_client`(CLI 读取 `CLAUDE_CODE_SESSION_ID`,目前仅 `claude`),服务端在 `member_sessions` 保存该手动成员最近上报的会话,成员视图以 `reported_session` 暴露。房主用 `POST members/ID {action:"adopt", adapter:"claude", session_id}`(须与上报一致、仅全自动讨论中在场的手动成员)把成员改为 `runner`、`borrowed` 并清除上报,本机接入程序据此接管。两套后端行为一致。
244
250
  - **请求**:用户消息按结构化接收者 `to`(成员名)为每位成员生成一条请求;省略且仅有一位在场成员时默认点名该成员,多位成员时交给主持人(`route`),只有在线成员而没有主持人时只作留言,不广播。网页只从正文中代码与引用之外的 `@成员` 生成 `to`,没有 @ 时省略 `to`。请求状态为 `pending`(待投递)→ `delivering`(处理中)→ `completed` / `cancelled` / `failed`。
245
251
  - **执行**:`member_executions` 每位成员最多一个 `dispatched/started`(部分唯一索引),不同成员可并行。接入程序用控制凭据 `GET inbox`、`POST executions {member_id, client_id}`(同一 `client_id` 返回同一执行)把成员的全部待投递请求打包;`POST executions/ID {action}` 支持 `renew`(租约 120 秒,不超过单次执行时限,默认 20 分钟)、`fail`(请求标记失败)和 `release`(仅确认未送达时退回队列)。租约过期的执行在下次读取或领取时标记失败,不自动重发。
246
252
  - **主持人与任务链**:用户是需求方,不担任主持人。只要有在场的非在线成员,房间就保持恰好一位主持人:成员变动时,离开或移出的主持人失去角色,由第一位在场的 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 完成回交,回交请求按(派活消息,主持人)唯一,重复执行不会重复唤醒。
247
- - **智能调度改派**:接入程序用控制凭据 `POST requests/ID {action:"schedule", member_id, provider, confidence}` 把一条仍为 `pending` 的 `route` 请求改给一位在场、由本机唤醒的非主持成员,`purpose` 改为 `scheduled`,`created_by` 与 `chain_id` 不变,不消耗任务链预算;同一事务把供应商(`jev` / `decisions`)与 0–1 置信度写入 `request_schedules`(独立表,D1 重新应用 schema 即可,无需改旧表列),房间视图的请求带 `schedule_provider`、`schedule_confidence`,备份、导入与删除一并处理。请求已被主持人领取或已处理返回 409,目标不合格返回 404,供应商或置信度无效返回 400;Worker 用带条件的 `UPDATE` 与同批次 `INSERT … SELECT` 防止与主持人领取并发时丢失请求或留下孤立记录。`inbox` 的待投递项带 `purpose` 供接入程序识别。
253
+ - **智能调度推荐**:接入程序用控制凭据 `POST recommendations {message_id, member_id, provider, confidence}` 为一条已交给主持人(`route`)的未点名消息记录推荐对象(在场、由本机唤醒、非该主持人),写入 `member_recommendations`(每条消息一条,重复提交忽略;独立表,D1 重新应用 schema 即可),有效期 60 秒,主持人的请求不受影响。房主用 `POST recommendations/MESSAGE_ID {action:"adopt", mode}` 采纳:`interrupt` 取消主持人这条请求(已投递则作废主持人本次整个执行),`after` 等主持人这条请求进入终态。两种方式都先置为 `waiting`,由 `release`(采纳时与每次 `inbox` 读取时执行)在主持人请求已终态时为推荐成员建一条 `ask` 请求(`created_by=user`、`chain_id` 为原消息),状态改为 `adopted`。过期 5 秒宽限后采纳返回 409,重复采纳返回原结果。房间视图 `free.recommendations` 带成员名、状态、方式、置信度与剩余秒数 `expires_in`;备份、导入(仅作历史,不再生效)与删除一并处理。`inbox` 的待投递项带 `purpose` 供接入程序识别。
248
254
  - **回应建议**:普通成员新发言的结构化 `to` 与正文在场成员 @ 合并保存到 `member_suggestions`(原消息编号、稳定成员编号),排除自己、代码与引用;不会创建请求。消息读取返回 `suggestions`,包含目标在场状态及已转换的请求编号。房主用 `POST suggestions {message_id,member_id}` 转换:同一事务记录用户确认消息,调用现有建请求路径,`message_id` 保留原回答、`chain_id` 使用用户确认消息编号、`created_by=user`。`UNIQUE(message_id,member_id)` 使重复或并发点击只生成一个请求及一条确认,终态不重新派发。权限、在场状态和未存档条件在写入事务内校验;Worker 使用批次断言。升级须应用包含新表的 schema;Python 重启时自动建表,D1 发布前显式执行。已有历史消息不补写建议。
249
255
  - **在线成员**:`access=online` 的成员只读,不进入 `inbox`,不能用自己的凭证发言;房主以控制凭据代发其回答(`author` 为该成员、`submitted_by` 为用户、`source=proxy`),可带 `request_ids` 关闭它的待处理请求。
250
256
  - **关联房间**:从自由房间发起的讨论房间在建房时带 `parent_room_id`,记录在 `room_links`;讨论房间视图有 `parent_room`,自由房间视图列出 `linked_rooms`。头脑风暴的轮次、时长与名单因此天然独立于自由房间。
package/docs/cli.md CHANGED
@@ -35,7 +35,7 @@ Bash 对应 `export DISCUSS_URL=http://127.0.0.1:8765`,CMD 对应 `set DISCUSS
35
35
 
36
36
  建房与更新资料支持 `--user-request` / `--user-request-file`,将用户原文与 `--background` 分开保存,各限 4000 字。`room brief` 返回两者,概要和通知不自动展开原文。
37
37
 
38
- `room create` 必须指定 `--access-file 私有文件`,CLI 在请求前原子保存建房编号、房主和各成员凭据,确认房间后另写各本地成员的独立文件,回执只返回 `member_access_files` 路径。结果不明时沿用同一文件、编号和创建参数;不能换文件重建。后续每次写入使用对应身份的文件,不能拿房主文件冒充成员;自动讨论使用本次任务文件中的执行凭据。文件含 `site`、`room`、`token`,CLI 检查站点与房间匹配。同样内容的单行 JSON 文本可放入环境变量 `DISCUSS_ACCESS`(或 `--access-file -` 从标准输入读取),未传 `--access-file` 时使用;成员菜单的“复制凭证文本”复制的就是这段文本,含 token,只交给该成员,不要写进命令行参数或回显。`--dry-run` 不生成或写入凭据,预览会隐藏凭据字段。
38
+ `room create` 必须指定 `--access-file 私有文件`,CLI 在请求前原子保存建房编号、房主和各成员凭据,确认房间后另写各本地成员的独立文件,回执只返回 `member_access_files` 路径。结果不明时沿用同一文件、编号和创建参数;不能换文件重建。后续每次写入使用对应身份的文件,不能拿房主文件冒充成员;自动讨论使用本次任务文件中的执行凭据。文件含 `site`、`room`、`token`,CLI 检查站点与房间匹配。同样内容的单行 JSON 文本可放入环境变量 `DISCUSS_ACCESS`(或 `--access-file -` 从标准输入读取),未传 `--access-file` 时使用;成员菜单的“复制凭证文本”复制的就是这段文本,含 token,只交给该成员,不要写进命令行参数或回显。`--dry-run` 不生成或写入凭据,预览会隐藏凭据字段。`message post` 在 Claude Code 会话中运行时自动附带 `session_id` 与 `session_client: "claude"`(取自 `CLAUDE_CODE_SESSION_ID`),服务端仅对手动成员保存,供本机服务转为托管;其他情况忽略。
39
39
 
40
40
  消息 JSON 中的 `elapsed_seconds` 为只读耗时:自动执行从交付唤醒到保存该消息的秒数,不含排队,保存后固定。用户留言、手动或代发内容及没有计时记录的旧消息返回 `null`;发布命令无需提供耗时。
41
41
 
package/docs/runner.md CHANGED
@@ -29,7 +29,7 @@
29
29
 
30
30
  API 参与者不安装技能,也不靠手写的另一套规则:每次执行时,程序从包内读取与本地软件相同的 `discuss-participant`(讨论主持人另加 `discuss-host`)正文作为系统提示词,前面附一段 API 环境说明,列出 CLI 命令对应的房间工具,并说明定时任务、凭据文件、执行认领等段落不适用。技能更新后下一次执行即生效;主持人一次约多出 1 万字符输入。在线网页 AI 同样不加载技能,阅读页 `/r/房间` 末尾附有精简的在线成员守则;本地 AI 的阅读页只指引其调用技能与 `codiscuss room protocol`。
31
31
 
32
- 每个有效任务重新读取房间与当前安排,按需读取原文。独立观点阶段不会把他人观点或主持总结送给专家;专家仅可提交本次安排,主持人可写主持说明、整理状态、调整阶段、安排整轮或收束。API 与网页专家的项目权限固定只读,只有 CLI 专家可选择只读或写入;API 配置即使残留 `project_access="write"` 或本场设置为可写,执行器也强制归一为 `read`。API 没有本机文件、浏览器、附件解析或任意 HTTP 工具,不继承 cwd 的读写权限;此权限不限制保存讨论发言及履行主持职责。模型看不到网站令牌和 API Key;房间 HTTP 请求由程序绑定身份与执行。
32
+ 每个有效任务重新读取房间与当前安排,按需读取原文。独立观点阶段不会把他人观点或主持总结送给专家;专家仅可提交本次安排,主持人可写主持说明、整理状态、调整阶段、安排整轮或收束。API 与网页专家的项目权限固定只读,只有 CLI 专家可选择只读或写入;API 配置即使残留 `project_access="write"` 或本场设置为可写,执行器也强制归一为 `read`。API 没有本机文件、浏览器、附件解析或任意 HTTP 工具,不继承 cwd 的读写权限;消息里的附件对它只是名称、大小与 ID,系统提示词要求它说明未读取、不得凭文件名猜测内容;此权限不限制保存讨论发言及履行主持职责。模型看不到网站令牌和 API Key;房间 HTTP 请求由程序绑定身份与执行。
33
33
 
34
34
  执行器在后台请求模型,主循环继续续租和处理暂停。单次模型请求超时 180 秒,每个执行最多 24 次模型请求;需显式输出上限的协议使用 8192 tokens。Responses 保留加密 reasoning items,Anthropic 保留签名块,Gemini 保留 thoughtSignature,Chat Completions 保留工具历史;这些推理内容不作为公开发言。未调用保存工具、请求失败或超限时,讨论暂停并保留错误,自由房间只结束该成员的失败执行。暂停或撤销会取消本机请求,服务端继续校验执行有效性;不把迟到回答改作普通发言。每次写入前保存原提交编号和正文,网络结果不明时停止,不换号自动重试。
35
35
 
@@ -314,18 +314,23 @@ Codex 要求 CLI 0.163.0 或更新版本,使用 permission profiles 和本地
314
314
 
315
315
  `codiscuss launch room-member` 在运行中的自由房间邀请(`add`)或请回(`rejoin`)自动成员:先用房间控制凭据核对房主身份,在登记配置中写入成员的软件配置与新凭证,再通知网站;网页通过 `member_token` 提供其已保存的新凭证,结果不明时沿用同一凭证重试。网页邀请默认项目只读,显式选择可写后通过该成员的 `participants[name].project_access` 传递,不改变其他成员;重试保留最初选择。该命令也可为手动成员生成配置旁的 `manual-成员名.access.json`,回执只含文件路径;网页手动邀请和请回则直接在网站完成。常驻服务读取更新后的配置时逐个成员核对:新成员直接采用,已绑定会话的成员若项目、cwd 或接入方式变化,只阻止该成员并报告原因,不影响其他成员。
316
316
 
317
- 运行中的讨论同样可用 `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”,保存采用后可在弹窗内重启。讨论配置只新增参与者时常驻服务直接采用,讨论不暂停;已有参与者的项目、cwd 或身份变化仍须暂停后重新接入。
317
+ 运行中的讨论同样可用 `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 或身份变化仍须暂停后重新接入。
318
318
 
319
319
  每次检查读取 `inbox`:成员没有进行中的执行且有待投递消息时,先持久化本地 `client_id` 再领取执行,然后用原适配器续接该成员的绑定会话;任务文件列出执行编号、待处理消息和已读起点,凭据文件只含成员凭证。进程退出而网站未收到回答、软件报错或超过单次执行时限时报告失败;只有确认未送达才退回队列重试,最多 3 次。领取后、调用前中断的投递不自动重发。主持人的任务文件带 `role: "host"` 和每条请求的 `request_purposes`(`ask` / `route` / `handoff`),说明里写明如何用 `--to` 派活与何时结束。网站上执行被打断、成员离开或被移除后,只停止该成员的进程;不同成员各自投递,可以并行。成员配置了 cwd 且位于 Git 工作区时,执行前后各做一次快照,执行结束且进程退出后报告改动的文件,供网站提示可能冲突;服务重启会丢失尚未报告的快照。只能在网页创建自由房间,不支持对话入口。
320
320
 
321
321
  ### 智能调度(可选)
322
322
 
323
- 新建房间面板在选好 AI 后可勾选“智能调度”,选择供应商(TypeSafe Jev 的 `POST /v1/systemone`,或 OpenAI Decisions 的 `POST /v1/decisions`)、模型(预设 `jev-latest` / `gpt-6-luna`)并填写密钥。密钥经 `api-key` 保存到服务目录,绑定供应商官方根地址;启动请求只带 `scheduler: {provider, model, api_key_ref}`,`validate_config` 核对密钥存在后写入私有房间配置。只在本次由本机服务启动时提供:自由房间至少有一位本机唤醒成员,讨论须勾选“创建后立即启动”。目前只能在建房时开启,已建房间不能中途开启或关闭。
323
+ 新建房间面板始终显示“智能调度”,可选供应商(TypeSafe Jev 的 `POST /v1/systemone`,或 OpenAI Decisions 的 `POST /v1/decisions`)、模型(预设 `jev-latest` / `gpt-6-luna`)并填写密钥。密钥经 `api-key` 保存到服务目录,绑定供应商官方根地址;密钥旁的闪电按钮调用 `scheduler-check`(`codiscuss launch scheduler-check`),向决策模型发一道是非题核对供应商、模型与密钥。启动请求只带 `scheduler: {provider, model, api_key_ref}`,`validate_config` 核对密钥存在后写入私有房间配置。只在本次由本机服务启动时可开启(自由房间至少有一位本机唤醒成员,讨论须勾选“创建后立即启动”),否则选项禁用并说明原因。目前只能在建房时开启,已建房间不能中途开启或关闭。
324
324
 
325
- `runner/scheduler.mjs` 用中立的题目(`choice` 带选项,`predicate` 为是非题)组装两家的请求,并把答案统一为 `{choice, confidence, probabilities}` 或 `{probability}`。上下文是房间标题、最近 10 条消息(每条截断到 600 字)和待处理的那条用户消息。请求 15 秒超时,错误只报 HTTP 状态,不含响应正文或密钥。
325
+ `runner/scheduler.mjs` 用中立的题目(`choice` 带选项,`predicate` 为是非题)组装两家的请求,并把答案统一为 `{choice, confidence, probabilities}` 或 `{probability}`。上下文是房间标题、目标与用户原文、最近 10 条消息(每条截断到 600 字)和待处理的那条用户消息。把握程度取所选项的概率(缺失时才用 `confidence`;Jev 的两项选择 `confidence` 明显低于该概率,实测概率 0.82 时只有 0.64),阈值 0.8。请求 30 秒超时,错误只报 HTTP 状态,不含响应正文或密钥。
326
326
 
327
- - **自由房间**:每次检查先看主持人名下的 `route` 请求,每条只判断一次。选项为各位本机唤醒的非主持成员(描述为软件与项目读写权限)和“交回主持人”;只有选中成员且 `confidence ≥ 0.8` 时,才用 `POST requests/ID {action:"schedule"}` 改派(带供应商与置信度,网站记入请求),随后重读 `inbox`,本轮不再唤醒主持人。判断失败、超时、置信度不足、选择交回主持人或改派返回 409 时,请求留给主持人,行为与未开启相同。成员任务文件对 `scheduled` 请求说明“用户没有点名,由智能调度交给你”。网页在消息上显示“智能调度 → 成员”,悬停显示供应商与置信度;主持人读 `room view` 时也能看到这些字段。
328
- - **讨论房间**:只给建议,不改变房间。投递主持事件前,若 `held_since` 非 0,对其后的每条用户留言判断一次(请某位专家回应 / 主持人处理 / 仅补充信息),结果以 `scheduler_suggestions` 写入主持任务文件(API 主持人写入首个提示),说明仅供参考;建议在记录投递前完成,失败只少一项建议。
327
+ - **自由房间(推荐,用户决定)**:未点名的消息照常交给主持人,判断在后台进行,不阻塞本次检查和任何投递。每条 `route` 请求只判断一次,选项为各位本机唤醒的非主持成员(描述为软件与项目读写权限)和“交回主持人”;只有选中成员且 `confidence ≥ 0.8` 时,才用 `POST recommendations` 记录推荐。网页在消息旁显示“智能调度建议让 B 回答”与 60 秒倒计时,用户可选“打断主持人,改由它回答”或“主持人答完后再回答”(主持人已答完时直接请 B 回答),过期后提示消失。判断失败、超时或置信度不足时不显示推荐,行为与未开启相同。
328
+ - **讨论房间(有把握即执行)**:投递主持事件前先只读判断,置信度 ≥ 0.8 才执行,否则照常唤醒主持人:
329
+ - **插话**:`held_since` 后恰有一条用户留言时判断。仅补充信息且有排队安排 → `turns/continue`;在问某位非主持专家 → 新计划先请该专家回应这条留言(问题引用原文 300 字),原排队安排按原顺序跟在后面(最多 10 项);改变方向、多条留言或话题修改 → 主持人。仅补充但计划已结束时,按下一条处理。
330
+ - **轮次衔接**:仅 `named_followup` 为 false、无排队、上一轮已完成时判断。继续 → 先请模型选出的专家(不是上一轮最后发言者),其余按房间专家顺序,统一使用 `peer_round_question`;第 1 轮后自动把阶段从 `initial` 改为 `discussing`。可收束、跑偏、重复或卡住 → 主持人。
331
+ - **执行方式**:有把握时 runner 领取该主持事件,以 `smart-scheduler` 会话进入,用本次执行凭据以主持人身份领取发言权、改阶段、创建或继续安排;网站的发言权、上限与凭据校验照常生效。到限、阶段不在进行中或已有进行中安排时不判断。执行中任一写入失败,把同一事件交回主持人软件并标记已进入(任务说明与 API 主持都跳过 `execution enter`)。判断失败或置信度不足时,主持任务附 `scheduler_suggestions` 供参考。
332
+ - **成员变化**:名单变化会作废排队安排(留下最近一次实际安排之后的 `skipped`)。本轮未完成且无排队时,runner 不调用模型,按原顺序补回仍在场专家的安排,再请本轮尚未发言的专家(如新成员)开场,不唤醒主持人。
333
+ - 每次判断(含交回主持人)用控制凭据写入 `POST scheduler-decisions`,`after_message_id` 取最近一条已回答安排的回答,对话中显示在这张发言卡片的末尾(“智能调度:……”)。交回主持人时写明原因:建议收束、拿不准,或“未判断:已到讨论上限 / 同时有多条留言 / 话题设置有修改 / 点名追问已开启 / 本轮还有专家未发言但没有后续安排”;主持人的开场任务不记录。
329
334
  - 每次判断与结果写入 `service.log`;阈值 0.8 未经实测校准,两家的置信度不可互相套用。面板提示:开启后房间标题与最近消息会发给所选供应商。
330
335
  - 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 仅按文档实现,未用真实密钥调用。
331
336