ai_co_discuss 0.2.62 → 0.2.63

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.
@@ -12,7 +12,7 @@
12
12
 
13
13
  ## 主持人
14
14
 
15
- 任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff` 表示你派出的成员都已结束。先 `room view` 看成员,需要他人时在回答中加 `--to 成员名` 派活并说明各自负责的文件或模块,不需要时不加,本次即结束;不点名自己,不扩大用户给的权限。
15
+ 任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff` 表示你派出的成员都已结束,`all` 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 `--to`)。先 `room view` 看成员,需要他人时在回答中加 `--to 成员名` 派活并说明各自负责的文件或模块,不需要时不加,本次即结束;不点名自己,不扩大用户给的权限。
16
16
 
17
17
  多位成员共享同一工作目录时,只改分给自己的文件;只提交自己的改动,切分支、reset、全仓 stash 或 checkout 须用户确认。
18
18
 
@@ -358,10 +358,13 @@ def check_answer(connection, room_id, member, payload):
358
358
  request_ids = payload.get("request_ids", [])
359
359
  if payload.get("execution_id") is not None or not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
360
360
  raise ValueError("手动接入成员用 request_ids 关联请求,不使用执行编号")
361
+ purposes = []
361
362
  for request_id in request_ids:
362
363
  row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
363
364
  if not row or row["member_id"] != member["id"] or row["status"] != "pending":
364
365
  raise FileExistsError(f"请求 {request_id} 不属于本成员或已处理")
366
+ purposes.append(row["purpose"])
367
+ check_dispatch(member, payload, purposes)
365
368
  return None
366
369
  execution_id = payload.get("execution_id")
367
370
  if type(execution_id) is not int:
@@ -371,9 +374,17 @@ def check_answer(connection, room_id, member, payload):
371
374
  raise PermissionError("执行编号不属于本成员")
372
375
  if row["status"] not in ACTIVE or row["lease_until"] <= int(time.time()):
373
376
  raise FileExistsError("本次执行已结束、被打断或已过期;回答不能改作普通发言,请保留草稿")
377
+ check_dispatch(member, payload, [item[0] for item in connection.execute(
378
+ "SELECT purpose FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,))])
374
379
  return row
375
380
 
376
381
 
382
+ def check_dispatch(member, payload, purposes):
383
+ """A host answering only its share of an everyone request answers as a member, so it may not dispatch others."""
384
+ if member["role"] == "host" and payload.get("to") and purposes and all(purpose == "all" for purpose in purposes):
385
+ raise ValueError("这次是请全体成员各自回应,其他成员已收到请求;主持人只回答自己的部分,不能用 --to 派活")
386
+
387
+
377
388
  def check_proxy(connection, room_id, member, payload):
378
389
  """The owner forwards an online member's answer and may close that member's open requests."""
379
390
  request_ids = payload.get("request_ids", [])
@@ -672,12 +683,39 @@ def release_recommendations(connection, room_id):
672
683
  VALUES (?,?,?,?,'ask','user',?)""", (room_id, row["member_id"], row["message_id"], row["message_id"], int(time.time())))
673
684
 
674
685
 
686
+ def route_wide(connection, room_id, message_id, action):
687
+ """Smart scheduling answers an unnamed message with everyone (each member once, as if the user named them all) or no one.
688
+
689
+ Only a host request that has not started changes; a started host already handles the message its own way.
690
+ """
691
+ route = host_route_request(connection, message_id)
692
+ if not route or route["room_id"] != room_id:
693
+ raise LookupError("只能处理交给主持人的未点名消息")
694
+ if route["status"] != "pending":
695
+ raise FileExistsError("主持人已开始处理这条消息,不再改派")
696
+ if action == "none":
697
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
698
+ else:
699
+ # The host keeps its row (one request per message and member) and now answers its own share, without dispatching.
700
+ connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (route["id"],))
701
+ connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
702
+ SELECT room_id,id,?,?,'all','user',? FROM room_members WHERE room_id=? AND status='active' ORDER BY id""",
703
+ (message_id, message_id, int(time.time()), room_id))
704
+ requests = connection.execute("SELECT * FROM member_requests WHERE message_id=? ORDER BY id", (message_id,)).fetchall()
705
+ return {"action": action, "requests": [dict(row) for row in requests]}
706
+
707
+
675
708
  def adopt_recommendation(connection, room_id, token, message_id, payload):
676
- """The user adopts a recommendation before it expires: interrupt the host now, or ask the member after the host."""
709
+ """The user adopts a recommendation before it expires: interrupt the host now, or ask the member after the host.
710
+
711
+ Smart scheduling also sends action all or none here to have everyone or no one answer an unnamed message.
712
+ """
677
713
  require_owner(connection, room_id, token)
678
714
  begin_write(connection)
715
+ if payload.get("action") in ("all", "none"):
716
+ return route_wide(connection, room_id, message_id, payload["action"])
679
717
  if payload.get("action") != "adopt" or payload.get("mode") not in ("interrupt", "after"):
680
- raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after")
718
+ raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体或无需回应为 action=all 或 none")
681
719
  row = connection.execute("SELECT * FROM member_recommendations WHERE room_id=? AND message_id=?", (room_id, message_id)).fetchone()
682
720
  if not row:
683
721
  raise LookupError("这条消息没有智能调度推荐")
@@ -258,7 +258,7 @@ running 状态即使只有 pending 事件,也必须先暂停才能重新启动
258
258
  - **请求**:用户消息按结构化接收者 `to`(成员名)为每位成员生成一条请求;省略且仅有一位在场成员时默认点名该成员,多位成员时交给主持人(`route`),只有在线成员而没有主持人时只作留言,不广播。网页只从正文中代码与引用之外的 `@成员` 生成 `to`,没有 @ 时省略 `to`。请求状态为 `pending`(待投递)→ `delivering`(处理中)→ `completed` / `cancelled` / `failed`。
259
259
  - **执行**:`member_executions` 每位成员最多一个 `dispatched/started`(部分唯一索引),不同成员可并行。接入程序用控制凭据 `GET inbox`、`POST executions {member_id, client_id}`(同一 `client_id` 返回同一执行)把成员的全部待投递请求打包;`POST executions/ID {action}` 支持 `renew`(租约 120 秒,不超过单次执行时限,默认 20 分钟)、`fail`(请求标记失败)和 `release`(仅确认未送达时退回队列)。租约过期的执行在下次读取或领取时标记失败,不自动重发。
260
260
  - **主持人与任务链**:用户是需求方,不担任主持人。只要有在场的非在线成员,房间就保持恰好一位主持人:成员变动时,离开或移出的主持人失去角色,由第一位在场的 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 完成回交,回交请求按(派活消息,主持人)唯一,重复执行不会重复唤醒。
261
- - **智能调度推荐**:接入程序用控制凭据 `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` 供接入程序识别。`inbox.members` 只列由本机唤醒的成员;手动与在线成员另列于 `inbox.observers`(含待处理请求,供智能调度识别主持人与推荐对象),接入程序从不向它们投递。
261
+ - **智能调度推荐**:接入程序用控制凭据 `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,重复采纳返回原结果。同一入口的 `{action:"all"}` / `{action:"none"}` 不经推荐表:全体把主持人尚未开始的 `route` 请求原地改为 `purpose=all`(每条消息每位成员只有一条请求),并为其余在场成员各建一条 `all` 请求(`created_by=user`、`chain_id` 为原消息、不进 `free_batches`,因此没有 `handoff`);无需回应取消该 `route` 请求;主持人请求已不是 `pending` 时返回 409。主持人本次回答的请求全是 `all` 时,带 `to` 的回答被拒绝(400),避免重复请求其他成员。房间视图 `free.recommendations` 带成员名、状态、方式、置信度与剩余秒数 `expires_in`;备份、导入(仅作历史,不再生效)与删除一并处理。`inbox` 的待投递项带 `purpose` 供接入程序识别。`inbox.members` 只列由本机唤醒的成员;手动与在线成员另列于 `inbox.observers`(含待处理请求,供智能调度识别主持人与推荐对象),接入程序从不向它们投递。
262
262
  - **回应建议**:普通成员新发言的结构化 `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 发布前显式执行。已有历史消息不补写建议。
263
263
  - **讨论中的直接提问**:讨论房间的用户消息带 `to`(点名成员名列表)时,除保存消息外为每位被点名的在场成员建一条 `ask` 请求(`created_by=user`,不入 `free_batches`,不占安排);讨论运行中点名主持人不重复建请求。`GET inbox`、`POST executions` 与 `executions/ID` 对讨论房间同样可用,成员以 `execution_id`(接入程序交付)或 `request_ids`(手动 / 代发)提交 `chat` 回答,走自由房间的写入校验。同一成员的 `member_executions` 与 `discussion_events` 互斥(任一方领取时另一方有进行中则返回 409)。房间视图 `asks` 给出最近请求与执行,供消息旁的状态徽标。本机服务以 `DiscussionWithQuestions`(`runner/discussion_service.mjs`)并行运行讨论 runner 与 `FreeRoomRunner`,共用同一成员会话绑定。
264
264
  - **在线成员**:`access=online` 的成员只读,不进入 `inbox`,不能用自己的凭证发言;房主以控制凭据代发其回答(`author` 为该成员、`submitted_by` 为用户、`source=proxy`),可带 `request_ids` 关闭它的待处理请求。
package/docs/cli.md CHANGED
@@ -150,7 +150,7 @@ codiscuss message post ROOM --as Claude --kind chat --execution 执行编号 --b
150
150
 
151
151
  手动接入成员首次读取房间后执行一次 `member register ROOM --as 本人 --access-file FILE`:网站记录该成员已用自己的凭证注册,重复执行保留首次时间,房主凭据或他人凭证被拒绝(403),自动接入成员不需要注册(400)。命令不带会话 ID,CLI 在每次请求头中自动附带宿主会话(`CLAUDE_CODE_SESSION_ID` / `CODEX_THREAD_ID`)。未注册的成员在网页显示“待注册”,也不会被本机服务接管。
152
152
 
153
- 主持人在回答中用 `--to 成员名`(可重复)点名派活,例如 `--to Codex --to Gemini`;不能点名自己。只点名一位成员时,主持人不再收到后续请求;点名多位成员时,都结束后主持人会再收到一次 `handoff` 请求。普通成员也可用 `--to` 或正文 `@成员` 提出回应建议,但不会派发;房主在网页点击“转为请求”后才建立关联原消息的新用户任务链。代码与引用里的正文 @ 不生成建议。
153
+ 主持人在回答中用 `--to 成员名`(可重复)点名派活,例如 `--to Codex --to Gemini`;不能点名自己。只点名一位成员时,主持人不再收到后续请求;点名多位成员时,都结束后主持人会再收到一次 `handoff` 请求。本次回答的请求全是 `all`(智能调度请全体各自回应,其他成员已同时收到)时,主持人只回答自己的部分,带 `--to` 会被拒绝(400)。普通成员也可用 `--to` 或正文 `@成员` 提出回应建议,但不会派发;房主在网页点击“转为请求”后才建立关联原消息的新用户任务链。代码与引用里的正文 @ 不生成建议。
154
154
 
155
155
  ## 写入核对与错误处理
156
156
 
package/docs/runner.md CHANGED
@@ -330,7 +330,13 @@ Codex 要求 CLI 0.163.0 或更新版本,使用 permission profiles 和本地
330
330
 
331
331
  `runner/scheduler.mjs` 用中立的题目(`choice` 带选项,`predicate` 为是非题)组装两家的请求,并把答案统一为 `{choice, confidence, probabilities}` 或 `{probability}`。上下文是房间标题、目标与用户原文、最近 10 条消息(自由房间的路由判断为 20 条;每条截断到 600 字)和待处理的那条用户消息。把握程度取所选项的概率(缺失时才用 `confidence`;Jev 的两项选择 `confidence` 明显低于该概率,实测概率 0.82 时只有 0.64),阈值 0.7。讨论中的判断(留言、下一位发言人、下一轮)另带当前焦点、主持简报(前 1200 字)、主持人最近的安排,下一位发言人还带本轮已发言/未发言名单,并多问一道是非题“是否有明确依据要求候选人发言”(简报分工或流程轮到、或被点名追问;仅“本轮还没发言”不算),低于 0.7 即交回主持人,因为主持人预排的安排用完可能是有意结束;选项描述写明各选项的适用条件,“交给主持人”只在偏离目标、卡住或候选都不合适时选。每次调用计时,毫秒数写入决策记录 detail 的 `duration_ms`,决策卡片显示“耗时”;主持人安排卡片的耗时为上一条回答到安排保存的间隔,两种卡片样式一致。请求 30 秒超时,错误只报 HTTP 状态,不含响应正文或密钥。
332
332
 
333
- - **自由房间(推荐,用户决定)**:未点名的消息照常交给主持人,判断在后台进行,不阻塞本次检查和任何投递。每条 `route` 请求只判断一次,选项为各位本机唤醒的非主持成员(描述为软件与项目读写权限)和“交回主持人”;只有选中成员且 `confidence ≥ 0.7` 时,才用 `POST recommendations` 记录推荐,并随即由接入程序以 `adopt` + `interrupt` 自动采纳:打断主持人,改由该成员回答,自动采纳失败时保留原推荐与 60 秒倒计时,用户仍可手动选“打断主持人”或“主持人答完后再回答”。每次判断(含交回主持人)都用房主凭据写入 `POST scheduler-decisions`(`kind` 为 `interjection`,`after_message_id` 为该用户消息),在消息下显示为“智能调度:改由 B 回答 / 交给主持人 A”及置信度;房主可点 👍 / 👎 评价(`POST scheduler-decisions/ID`,`rating` 为 `good` / `bad` / `null` 撤回);评为 👎 时可再选填“应该由谁回答”(`should_be`)和不超过 200 字的说明(`note`)。评价存于 `scheduler_feedback`,每条判断产生时的模型、阈值、提示词版本(`ROUTE_PROMPT_VERSION`,改动题目或选项描述时递增)和包版本存于 `scheduler_decision_context`;两者随房间备份导出与导入,用于日后跨版本校准 0.7 阈值。判断失败、超时或置信度不足时不显示推荐,行为与未开启相同。
333
+ - **自由房间(推荐,用户决定)**:未点名的消息先判断、后投递:主持人要等判断结果(含失败)出来才启动,因此改派时主持人不会先启动再被打断;判断失败或超时(30 秒)时按未开启处理。每条 `route` 请求只判断一次,题目是“回应对象是否已确定”(正文叫了某位成员、在延续某位成员的回答、在补充或纠正正由某位成员处理的消息),上下文为房间标题、目标和该消息前最近 20 条消息(每条带作者,用“回复”发出的标“回复 #N”)。选项:
334
+ - 每位非主持成员:描述为软件、项目读写权限(未设项目目录写明“未指定项目目录”)、手动或网页成员注明“由用户代为触发”,以及正在处理 / 待处理的其他消息编号;
335
+ - `__host__` 主持人:延续主持人自己的回答,或对象不明确,或需要分工、排先后、互相回应;
336
+ - `__all__` 全体:全体成员(含主持人)各自回应一次,彼此不需要配合;
337
+ - `__none__` 无需回应:只是确认、致谢或旁注。
338
+
339
+ 选中成员且 `confidence ≥ 0.7` 时,用 `POST recommendations` 记录推荐,并随即由接入程序以 `adopt` + `interrupt` 自动采纳:打断主持人,改由该成员回答,自动采纳失败时保留原推荐与 60 秒倒计时,用户仍可手动选“打断主持人”或“主持人答完后再回答”。选中全体或无需回应时阈值为 0.85(`ROUTE_WIDE_CONFIDENCE`,选错分别浪费 N 次执行或让用户的话没人理),由 `POST recommendations/MESSAGE_ID {action:"all"|"none"}` 一步生效:全体把主持人这条请求原地改为 `all`,并为其他在场成员各建一条 `all` 请求(以用户名义,不进派发批次,所以不会唤醒主持人汇总);无需回应取消主持人这条请求。主持人已开始处理时两者都返回 409,消息仍由主持人处理。每次判断(含交回主持人)都用房主凭据写入 `POST scheduler-decisions`(`kind` 为 `interjection`,`after_message_id` 为该用户消息,`detail` 为题目、选项与各选项概率),在消息下显示为“智能调度:改由 B 回答 / 请全体各自回应 / 无需回应 / 交给主持人 A”及置信度;房主可点 👍 / 👎 评价(`POST scheduler-decisions/ID`,`rating` 为 `good` / `bad` / `null` 撤回);评为 👎 时可再选填“应该由谁回答”(`should_be`)和不超过 200 字的说明(`note`)。评价存于 `scheduler_feedback`,每条判断产生时的模型、阈值、提示词版本(`ROUTE_PROMPT_VERSION`,改动题目或选项描述时递增)和包版本存于 `scheduler_decision_context`;两者随房间备份导出与导入,用于日后跨版本校准 0.7 阈值。判断失败、超时或置信度不足时不显示推荐,行为与未开启相同。
334
340
  - **讨论房间(有把握即执行)**:投递主持事件前先只读判断,置信度 ≥ 0.7 才执行,否则照常唤醒主持人:
335
341
  - **插话**:`held_since` 后恰有一条用户留言时判断。仅补充信息且有排队安排 → `turns/continue`;在问某位非主持专家 → 新计划先请该专家回应这条留言(问题引用原文 300 字),原排队安排按原顺序跟在后面(最多 10 项);改变方向、多条留言或话题修改 → 主持人。仅补充但计划已结束时,按下一条处理。
336
342
  - **轮次衔接**:仅 `named_followup` 为 false、无排队、上一轮已完成时判断。继续 → 先请模型选出的专家(不是上一轮最后发言者),其余按房间专家顺序,统一使用 `peer_round_question`;第 1 轮后自动把阶段从 `initial` 改为 `discussing`。可收束、跑偏、重复或卡住 → 主持人。
package/docs/workflow.md CHANGED
@@ -33,7 +33,7 @@
33
33
 
34
34
  在线 AI 以“网页代发”成员加入:点名后,在成员栏复制阅读通知交给它;拿到回答后,在输入框选择“代发”该成员并粘贴原文,记录保留它为作者、你为提交者,并关闭它的待处理请求。
35
35
 
36
- 新建房间时可勾选“智能调度”(可选,需本机服务,供应商为 TypeSafe Jev 或 OpenAI Decisions,按次计费)。开启后,没点名的消息仍照常交给主持人;决策模型同时在后台判断,若有更适合回答的成员,(置信度 ≥ 0.7)就直接打断主持人,改由 B 回答,拿不准则仍由主持人回答。每次判断都在该消息下留一行“智能调度”记录,可点 👍 / 👎 评价这次判断是否合适。自动改派失败时退回为 60 秒内可手动采纳的建议。目前只能在建房时开启。开启智能调度只需连接本机服务,成员可以全是手动接入或网页成员:本机服务仍登记该房间并运行决策;被推荐的手动或网页成员与其他成员一样,采纳后在成员栏出现待处理请求,由你或其自行处理(网页成员需你复制通知并代发)。
36
+ 新建房间时可勾选“智能调度”(可选,需本机服务,供应商为 TypeSafe Jev 或 OpenAI Decisions,按次计费)。开启后,没点名的消息先由决策模型判断(通常约 1 秒),主持人等结果出来才启动;消息明确在对某位成员说(叫了名字、追问其回答、补充其正在处理的消息,置信度 ≥ 0.7)就直接改由该成员回答;要求全体各自回应(如“都出来打个招呼”)就同时请所有成员各答一次,主持人不再汇总;只是“好的”“谢谢”就不唤醒任何成员(这两种置信度须 ≥ 0.85);拿不准或需要分工协调时仍由主持人处理。每次判断都在该消息下留一行“智能调度”记录,可点 👍 / 👎 评价这次判断是否合适。自动改派失败时退回为 60 秒内可手动采纳的建议。目前只能在建房时开启。开启智能调度只需连接本机服务,成员可以全是手动接入或网页成员:本机服务仍登记该房间并运行决策;被推荐的手动或网页成员与其他成员一样,采纳后在成员栏出现待处理请求,由你或其自行处理(网页成员需你复制通知并代发)。
37
37
 
38
38
  需要深入讨论时,在成员栏点“开启讨论”(或建房时勾选“创建后开启讨论”):在弹窗里填目标、轮次和时长等上限,由本机唤醒的主持人安排成员轮流发言。到限、结束或用户点“回到自由协作”后,房间继续自由协作,成员、历史与结论保留,可再次开启;不再另建讨论房间。
39
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.2.62",
3
+ "version": "0.2.63",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -2,10 +2,10 @@
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
5
- import { join, resolve } from 'node:path';
5
+ import { basename, join, resolve } from 'node:path';
6
6
  import { atomic_json, deliver_task, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
7
7
  import { api } from './discussion_runner.mjs';
8
- import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, route_free_message } from './scheduler.mjs';
8
+ import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, route_free_message } from './scheduler.mjs';
9
9
 
10
10
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
11
11
  const RENEW_MS = 30_000;
@@ -72,7 +72,7 @@ export function free_task_files(config, member, execution, binding) {
72
72
  // The host routes unnamed messages and receives hand-backs; it may name members in its answer.
73
73
  const discussion = config.mode !== 'free';
74
74
  const host_rule = member.role === 'host' && !discussion
75
- ? `你是本房间的主持人:purpose 为 route 的请求是用户未点名的消息,handoff 表示你派出的成员都已结束。先用 ${cli} room view ${config.room} 查看成员与状态,再决定由谁处理;需要派活时在回答中加 --to 成员名(可重复,不能点名自己),只点名一位成员时,其回答直接面向用户,你不会再被唤醒;同时点名多位时,全部结束后你会再被唤醒一次汇总。不需要他人时不加 --to,本次即结束。只派分给各成员的文件或模块,避免多人改同一文件。`
75
+ ? `你是本房间的主持人:purpose 为 route 的请求是用户未点名的消息,handoff 表示你派出的成员都已结束,all 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 --to)。先用 ${cli} room view ${config.room} 查看成员与状态,再决定由谁处理;需要派活时在回答中加 --to 成员名(可重复,不能点名自己),只点名一位成员时,其回答直接面向用户,你不会再被唤醒;同时点名多位时,全部结束后你会再被唤醒一次汇总。不需要他人时不加 --to,本次即结束。只派分给各成员的文件或模块,避免多人改同一文件。`
76
76
  : '';
77
77
  atomic_json(task_path, { site: config.site, room: config.room, mode: 'free', member: member.name,
78
78
  execution_id: execution.id, request_messages: execution.requests.map(item => item.message_id),
@@ -89,10 +89,27 @@ export function free_task_files(config, member, execution, binding) {
89
89
  const ADAPTER_LABELS = { codex: 'Codex App', codex_cli: 'Codex CLI', claude: 'Claude Code', antigravity: 'Antigravity App',
90
90
  antigravity_cli: 'Antigravity CLI', api: '模型 API,只能读写房间消息' };
91
91
 
92
- /** What a scheduler can know about a member without a role field: its software and project access. */
93
- function member_description(name, profile) {
94
- const access = profile.adapter === 'api' ? '' : profile.project_access === 'write' ? ',可修改项目文件' : ',项目文件只读';
95
- return `${name}(${ADAPTER_LABELS[profile.adapter] || profile.adapter}${access})`;
92
+ /** What a scheduler can know about a member without a role field: its software, project access and the messages it is on. */
93
+ function member_description(name, profile, work) {
94
+ const project = profile.project_name || (profile.cwd ? basename(profile.cwd) : '');
95
+ const access = profile.adapter === 'api' ? '' : !profile.cwd ? ',未指定项目目录'
96
+ : profile.project_access === 'write' ? `,可修改项目 ${project} 的文件` : `,项目 ${project} 只读`;
97
+ return `${name}(${ADAPTER_LABELS[profile.adapter] || profile.adapter}${access}${work})`;
98
+ }
99
+
100
+ /** The other messages a member is answering or has queued, so a correction can follow the message it corrects. */
101
+ function member_work(requests, name, message_id) {
102
+ const open = requests.filter(item => item.member === name && item.message_id !== message_id);
103
+ const ids = status => [...new Set(open.filter(item => item.status === status).map(item => `#${item.message_id}`))].join('、');
104
+ return [ids('delivering') && `;正在处理 ${ids('delivering')}`, ids('pending') && `;待处理 ${ids('pending')}`].filter(Boolean).join('');
105
+ }
106
+
107
+ /** Why a message stayed with the host: the scheduler chose the host, or leaned elsewhere without enough certainty. */
108
+ function route_host_label(decision, host, members) {
109
+ if (decision.choice === '__host__' || decision.confidence === null) return `交给主持人 ${host}`;
110
+ const lean = decision.choice === '__all__' ? '全体各自回应' : decision.choice === '__none__' ? '无需回应'
111
+ : members.some(member => member.name === decision.choice) ? `请 ${decision.choice} 回答` : '';
112
+ return lean ? `倾向${lean},把握不足,交给主持人 ${host}` : `交给主持人 ${host}`;
96
113
  }
97
114
 
98
115
  /** Delivers free-room requests through the same software adapters as discussions. */
@@ -105,6 +122,8 @@ export class FreeRoomRunner {
105
122
  this.fetch = fetch_impl;
106
123
  this.routed = new Set();
107
124
  this.decisions = new Set();
125
+ // Routed requests whose decision is still pending: their host waits, so one pick replaces the host's start.
126
+ this.routing = new Set();
108
127
  this.children = new Set();
109
128
  this.members = new Map();
110
129
  this.exited = new Set();
@@ -158,38 +177,59 @@ export class FreeRoomRunner {
158
177
  for (const member of inbox.members) {
159
178
  const row = this.row(member.name);
160
179
  if (row && LIVE.includes(row.status)) await this.reconcile(member, row, active, inbox.execution_minutes);
161
- else if (!inbox.archived && member.pending.length && !this.members.get(member.name)?.size) await this.start(member, row);
180
+ else if (!inbox.archived && member.pending.length && !this.members.get(member.name)?.size
181
+ && !member.pending.some(request => this.routing.has(request.id))) await this.start(member, row);
162
182
  }
163
183
  }
164
184
  /**
165
- * Smart scheduling: for each new unnamed message routed to the host, ask the provider in the background whether
166
- * another woken member suits it better, and post a recommendation the user may adopt. Nothing waits on it.
185
+ * Smart scheduling: for each new unnamed message routed to the host, ask the provider whether another woken
186
+ * member suits it better and apply a confident pick. The host waits for the answer (or its failure) before starting.
167
187
  */
168
188
  recommend_unnamed(inbox, room) {
169
189
  // Manual and online members are listed apart; they are never delivered to but may host or be recommended.
170
190
  const everyone = [...inbox.members, ...(inbox.observers || [])];
171
191
  const host = everyone.find(member => member.role === 'host');
172
192
  const waiting = host?.pending.filter(request => request.purpose === 'route' && !this.routed.has(request.id)) || [];
173
- const members = everyone.filter(member => member !== host).map(member => ({ id: member.id, name: member.name,
174
- description: this.config.participants[member.name] ? member_description(member.name, this.config.participants[member.name])
175
- : `${member.name}(${member.access === 'online' ? '网页 AI' : '手动接入'},由用户代为触发)` }));
176
193
  for (const request of waiting) {
177
194
  this.routed.add(request.id);
178
- if (!members.length) continue;
179
- const decision = this.recommend(request, room, host, members).catch(error => this.log(`智能调度 #${request.message_id} 未给出推荐:${error.message}`));
195
+ if (everyone.length < 2) continue;
196
+ this.routing.add(request.id);
197
+ const decision = this.recommend(request, room, everyone).catch(error => this.log(`智能调度 #${request.message_id} 未给出推荐:${error.message}`))
198
+ .finally(() => this.routing.delete(request.id));
180
199
  this.decisions.add(decision);
181
200
  decision.finally(() => this.decisions.delete(decision));
182
201
  }
183
202
  }
184
- /** Decide one message; a confident pick is recorded and applied, otherwise the host keeps it. */
185
- async recommend(request, room, host, members) {
203
+ /** Describe each member as the scheduler sees it: software, project access and the other messages it is on. */
204
+ route_members(everyone, room, message_id) {
205
+ const requests = (room.free?.requests || []).filter(item => ['pending', 'delivering'].includes(item.status));
206
+ return everyone.map(member => {
207
+ const work = member_work(requests, member.name, message_id);
208
+ const profile = this.config.participants[member.name];
209
+ return { id: member.id, name: member.name, role: member.role, description: profile ? member_description(member.name, profile, work)
210
+ : `${member.name}(${member.access === 'online' ? '网页 AI' : '手动接入'},由用户代为触发${work})` };
211
+ });
212
+ }
213
+ /** Decide one message and apply a confident pick: one member, everyone, or no one; otherwise the host keeps it. */
214
+ async recommend(request, room, everyone) {
186
215
  const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES - 1)}`);
187
216
  const message = page.messages.find(item => item.id === request.message_id);
188
217
  if (!message) return;
218
+ const described = this.route_members(everyone, room, message.id);
219
+ const host = described.find(member => member.role === 'host');
220
+ const members = described.filter(member => member !== host);
189
221
  const decision = await route_free_message(this.config.api_home, this.config.scheduler,
190
- { title: room.title, goal: room.goal, context: page.messages, message, host: host.name, members }, this.fetch);
191
- this.log(`智能调度 #${message.id}:${decision.member ? `推荐 ${decision.member}` : '不推荐,交主持人'}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
192
- if (!decision.member) return this.record_decision(message.id, decision, `交给主持人 ${host.name}`, false);
222
+ { title: room.title, goal: room.goal, context: page.messages, message, host, members }, this.fetch);
223
+ this.log(`智能调度 #${message.id}:${decision.action}${decision.member ? ` ${decision.member}` : ''}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
224
+ if (decision.action === 'host') return this.record_decision(message.id, decision, route_host_label(decision, host.name, members), false);
225
+ if (decision.action !== 'member') {
226
+ // Everyone or no one replaces the host's request in one step; a host that already started keeps the message.
227
+ const applied = await api(this.config, `/recommendations/${message.id}`, { action: decision.action })
228
+ .then(() => true, error => { this.log(`智能调度 #${message.id} 未能改派:${error.message}`); return false; });
229
+ const names = described.map(member => member.name).join('、');
230
+ const label = decision.action === 'all' ? `请全体各自回应(${names})` : '无需回应,未唤醒任何成员';
231
+ return this.record_decision(message.id, decision, applied ? label : `${label}(未生效,仍由主持人处理)`, applied);
232
+ }
193
233
  await api(this.config, '/recommendations', { message_id: message.id, member_id: members.find(member => member.name === decision.member).id,
194
234
  provider: this.config.scheduler.provider, confidence: decision.confidence });
195
235
  // A confident pick is applied at once, interrupting the host; if that fails the recommendation stays for the user to adopt.
@@ -201,8 +241,9 @@ export class FreeRoomRunner {
201
241
  async record_decision(message_id, decision, label, applied) {
202
242
  try {
203
243
  await api(this.config, '/scheduler-decisions', { kind: 'interjection', label, confidence: decision.confidence ?? null,
204
- applied, provider: this.config.scheduler.provider, after_message_id: message_id,
205
- context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE, prompt: ROUTE_PROMPT_VERSION, package: PACKAGE_VERSION } });
244
+ applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail,
245
+ context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE,
246
+ prompt: ROUTE_PROMPT_VERSION, package: PACKAGE_VERSION } });
206
247
  } catch (error) { this.log(`智能调度 #${message_id} 决策记录未保存:${error.message}`); }
207
248
  }
208
249
  /** Reserve with a persisted client id before invoking any software. */
@@ -9,10 +9,14 @@ export const SCHEDULER_PROVIDERS = {
9
9
  const REQUEST_TIMEOUT_MS = 30_000;
10
10
  /** Below this confidence a decision stays with the host, exactly as without scheduling. */
11
11
  export const ROUTE_CONFIDENCE = 0.7;
12
+ /** Waking everyone or no one costs more when wrong (N runs, or an ignored message), so it needs more certainty. */
13
+ export const ROUTE_WIDE_CONFIDENCE = 0.85;
12
14
  /** Bump when the routing question or its option descriptions change, so ratings can be compared across wordings. */
13
- export const ROUTE_PROMPT_VERSION = 2;
15
+ export const ROUTE_PROMPT_VERSION = 3;
14
16
  const HOST_CHOICE = '__host__';
15
17
  const NOTE_CHOICE = '__note__';
18
+ const ALL_CHOICE = '__all__';
19
+ const NONE_CHOICE = '__none__';
16
20
  const CONTEXT_MESSAGES = 10;
17
21
  /** A free-room route sees a longer stretch, enough to tell who has not spoken for a while. */
18
22
  export const ROUTE_CONTEXT_MESSAGES = 20;
@@ -99,7 +103,8 @@ function unsure_label(lean) {
99
103
  /** Shorten a message so recent context stays small; the decision never needs whole answers. */
100
104
  function excerpt(message) {
101
105
  const text = String(message.text || '').replace(/\s+/g, ' ').trim();
102
- return `#${message.id} ${message.author}:${text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text}`;
106
+ const reply = message.reply_to ? `(回复 #${message.reply_to})` : '';
107
+ return `#${message.id} ${message.author}${reply}:${text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text}`;
103
108
  }
104
109
 
105
110
  /** The room's purpose in the user's words, so a decision can tell drifting from progress. */
@@ -125,18 +130,27 @@ export function certainty(answer) {
125
130
  }
126
131
 
127
132
  /**
128
- * Pick the member for one unnamed free-room message, or null to leave it with the host.
129
- * Members are [{name, description}] without the host; only a confident member choice is returned.
133
+ * Decide who answers one unnamed free-room message: whether its addressee is already settled by the text or the thread.
134
+ * Members are [{name, description}] without the host; host is {name, description}. Returns action member (with member),
135
+ * all, none, or host for anything unsettled or not confident enough.
130
136
  */
131
137
  export async function route_free_message(home, scheduler, { title, goal, context, message, host, members }, fetch_impl) {
132
138
  const choices = [...members.map(member => ({ value: member.name, description: member.description })),
133
- { value: HOST_CHOICE, description: `交回主持人 ${host}:需要拆分任务、多人协作,或消息与任何成员都无关` }];
134
- const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, '', {}, ROUTE_CONTEXT_MESSAGES), [{ type: 'choice', name: 'handler',
135
- instructions: '这条用户消息没有用 @ 点名成员,但正文可能直接提到某位成员的名字或在向其提问(如“某某在吗”),此时应选该成员。判断它能否直接交给一位成员独立处理;需要主持人先理解、拆分或协调时选交回主持人。', choices }], fetch_impl);
136
- const answer = answers.handler || {};
137
- const confidence = certainty(answer);
138
- const member = members.some(item => item.name === answer.choice) && confidence >= ROUTE_CONFIDENCE ? answer.choice : null;
139
- return { member, choice: answer.choice ?? null, confidence };
139
+ { value: HOST_CHOICE, description: `主持人 ${host.description}:消息在延续主持人自己的回答,或对象不明确,或需要分工、排先后、互相回应` },
140
+ { value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要配合,如打招呼、自我介绍、各说一句看法' },
141
+ { value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答' }];
142
+ const questions = [{ type: 'choice', name: 'handler', choices,
143
+ instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象是否已经确定:正文叫了某位成员的名字或在向其提问;在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息);在补充或纠正一条正由某位成员处理的消息。以上情况选该成员,是主持人就选主持人。要求所有成员各自回应一次选全体;只是确认、致谢或旁注选无需回应;对象不明确,或需要分工、排先后、互相回应时选主持人。' }];
144
+ const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, '', {}, ROUTE_CONTEXT_MESSAGES), questions, fetch_impl);
145
+ const detail = detail_of(questions, answers);
146
+ const choice = answers.handler?.choice ?? null;
147
+ const confidence = certainty(answers.handler);
148
+ const wide = choice === ALL_CHOICE || choice === NONE_CHOICE;
149
+ const sure = confidence !== null && confidence >= (wide ? ROUTE_WIDE_CONFIDENCE : ROUTE_CONFIDENCE);
150
+ if (sure && choice === ALL_CHOICE) return { action: 'all', choice, confidence, detail };
151
+ if (sure && choice === NONE_CHOICE) return { action: 'none', choice, confidence, detail };
152
+ if (sure && members.some(item => item.name === choice)) return { action: 'member', member: choice, choice, confidence, detail };
153
+ return { action: 'host', choice, confidence, detail };
140
154
  }
141
155
 
142
156
  /**