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.
- package/.agents/skills/discuss-host/SKILL.md +2 -1
- package/backend/free_room.py +86 -20
- package/backend/room_import.py +11 -2
- package/backend/schema.sql +35 -4
- package/backend/server.py +66 -10
- package/docs/architecture.md +8 -2
- package/docs/cli.md +1 -1
- package/docs/runner.md +11 -6
- package/docs/workflow.md +157 -150
- package/package.json +1 -1
- package/runner/api_participant.mjs +4 -2
- package/runner/discuss.py +5 -0
- package/runner/discussion_launch.mjs +22 -2
- package/runner/discussion_runner.mjs +149 -36
- package/runner/discussion_service.mjs +19 -0
- package/runner/free_runner.mjs +24 -28
- package/runner/scheduler.mjs +67 -15
- package/web/app.js +29 -9
- package/web/modern.css +1 -1
- package/web/modern.js +1229 -988
|
@@ -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
|
-
|
|
59
|
+
阅读新增的相关回答、用户约束及必要原文,判断下一步是追问、补证、复核还是收束。用户开启智能调度后,程序有把握时会代你完成插话分流(按原计划继续或先请某位专家回应)和无点名追问时的下一轮衔接,并在 `room view` 的 `scheduler_decisions` 留下记录;没把握时才唤醒你。任务带 `scheduler_suggestions` 时,那是它对未处理留言的判断,附置信度;读原文后自行决定是否采纳,不当作用户指令或已核实的判断。不要只回复“已就绪”或只写一段摘要就退出;本次执行需要以以下一种结果交接:
|
|
59
60
|
|
|
60
61
|
| 判断 | 保存与结束边界 |
|
|
61
62
|
| --- | --- |
|
package/backend/free_room.py
CHANGED
|
@@ -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
|
|
574
|
-
"""
|
|
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
|
-
|
|
583
|
-
if
|
|
584
|
-
|
|
585
|
-
|
|
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
|
|
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
|
-
|
|
592
|
-
connection.execute("INSERT OR
|
|
593
|
-
|
|
594
|
-
return dict(connection.execute("SELECT * FROM
|
|
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 或
|
|
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":
|
package/backend/room_import.py
CHANGED
|
@@ -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
|
-
|
|
163
|
-
|
|
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
|
|
package/backend/schema.sql
CHANGED
|
@@ -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
|
-
--
|
|
573
|
-
CREATE TABLE IF NOT EXISTS
|
|
574
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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 ("
|
|
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"],
|
|
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"], ["
|
|
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
|
|
1721
|
-
result =
|
|
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"
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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}
|
|
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
|
-
-
|
|
328
|
-
-
|
|
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
|
|