ai_co_discuss 0.2.46 → 0.2.48

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/backend/server.py CHANGED
@@ -214,6 +214,7 @@ def require_room(connection, room_id):
214
214
  room["round_budget"] = dict(budget) if budget else None
215
215
  if budget:
216
216
  room["opinion_count"] = opinion_count(connection, room_id, room["experts"])
217
+ room["round_groups"] = budget_round_groups(connection, room_id, room["experts"], budget["base"]) if budget else room["rounds"]
217
218
  room["floor"] = dict(connection.execute("SELECT holder,next_speaker,revision FROM room_floor WHERE room_id=?", (room_id,)).fetchone())
218
219
  room["runtime"] = runtime_view(connection, room_id)
219
220
  room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
@@ -233,6 +234,7 @@ def require_room(connection, room_id):
233
234
  for decision in room["scheduler_decisions"]:
234
235
  decision["detail"] = details.get(decision["id"])
235
236
  room["host_arrangements"] = host_arrangements(connection, room_id, room["scheduler_decisions"])
237
+ room["cancelled_turns"] = cancelled_turns(connection, room_id)
236
238
  parent = connection.execute("""SELECT rooms.id,rooms.title FROM room_links JOIN rooms ON rooms.id=room_links.parent_id
237
239
  WHERE room_links.room_id=?""", (room_id,)).fetchone()
238
240
  room["parent_room"] = dict(parent) if parent else None
@@ -296,6 +298,21 @@ def host_arrangements(connection, room_id, decisions):
296
298
  return [plan for plan in plans if plan["after_message_id"] not in applied][-RECENT_DECISIONS:]
297
299
 
298
300
 
301
+ def cancelled_turns(connection, room_id):
302
+ """Assignments that ended without an answer, anchored where their wake card first appeared.
303
+
304
+ The anchor is the later of the previous turn's answer and the latest message at creation time, so the card keeps its place in the transcript.
305
+ """
306
+ rows, latest_answer = [], 0
307
+ for row in connection.execute("""SELECT id,expert,question,status,answer_id,
308
+ (SELECT COALESCE(MAX(messages.id),0) FROM messages WHERE messages.room_id=discussion_turns.room_id AND messages.created_at<=discussion_turns.created_at) AS created_after
309
+ FROM discussion_turns WHERE room_id=? ORDER BY id""", (room_id,)):
310
+ if row["status"] == "cancelled":
311
+ rows.append({"id": row["id"], "expert": row["expert"], "question": row["question"], "after_message_id": max(latest_answer, row["created_after"])})
312
+ latest_answer = max(latest_answer, row["answer_id"] or 0)
313
+ return rows[-RECENT_DECISIONS:]
314
+
315
+
299
316
  def discussion_rounds(connection, room_id, experts):
300
317
  """Group complete history metadata until every configured expert has spoken.
301
318
 
@@ -327,6 +344,34 @@ def discussion_rounds(connection, room_id, experts):
327
344
  return rounds
328
345
 
329
346
 
347
+ def budget_round_groups(connection, room_id, experts, base):
348
+ """Group history by the round budget's rule: every len(experts) opinions form a round, and a new segment (base) starts a new one.
349
+
350
+ `rounds` stays speaker-based because scheduling needs who still owes a turn; this view only labels history.
351
+ """
352
+ if not experts:
353
+ return []
354
+ rows = connection.execute("""SELECT messages.id,messages.author,
355
+ COALESCE(details.kind,'opinion') AS kind FROM messages
356
+ LEFT JOIN message_details AS details ON details.message_id=messages.id
357
+ WHERE room_id=? ORDER BY messages.id""", (room_id,))
358
+ groups, next_start, seen = [], None, 0
359
+ for message in rows:
360
+ if next_start is None:
361
+ next_start = message["id"]
362
+ if message["kind"] != "opinion" or message["author"] not in experts:
363
+ continue
364
+ if not groups or groups[-1]["completed"]:
365
+ groups.append({"number": len(groups) + 1, "start_id": next_start, "opinions": 0, "completed": False})
366
+ current = groups[-1]
367
+ seen += 1
368
+ current["opinions"] += 1
369
+ current["completed"] = current["opinions"] >= len(experts) or seen == base
370
+ if current["completed"]:
371
+ next_start = None
372
+ return groups
373
+
374
+
330
375
  def opinion_count(connection, room_id, experts):
331
376
  """Count formal expert opinions; the program-controlled round budget is measured in these."""
332
377
  if not experts:
@@ -969,7 +1014,7 @@ def append_free_message(connection, room, payload, actor):
969
1014
  def record_reported_session(connection, room_id, token, payload):
970
1015
  """Keep the software session a manual member posted from, so the local service can take the member over."""
971
1016
  session_id, client = payload.get("session_id"), payload.get("session_client")
972
- if client not in ("claude", "codex") or not isinstance(session_id, str) or not SESSION_ID_PATTERN.fullmatch(session_id) or not token:
1017
+ if client not in ("claude", "codex", "antigravity") or not isinstance(session_id, str) or not SESSION_ID_PATTERN.fullmatch(session_id) or not token:
973
1018
  return
974
1019
  member = connection.execute("SELECT id FROM room_members WHERE room_id=? AND token_hash=? AND status='active' AND access='manual' AND adapter='manual'",
975
1020
  (room_id, token_hash(token))).fetchone()
@@ -998,7 +1043,7 @@ def append_message(connection, room_id, payload, token="", free_actor=None):
998
1043
  return {"message": previous, "duplicate": True}
999
1044
  if author != "用户" and details["kind"] == "opinion":
1000
1045
  require_within_limits(room)
1001
- if room["phase"] in ("paused", "concluded") and details["kind"] == "opinion":
1046
+ if room["phase"] in ("paused", "concluded") and details["kind"] == "opinion" and (author != "用户" or room["archived"]):
1002
1047
  raise ValueError("话题已暂停或收束;恢复阶段后才能提交新观点,运行回执请用 receipt")
1003
1048
  check_message_references(connection, room_id, expected)
1004
1049
  check_message_attachments(connection, room_id, details["submitted_by"], identifiers)
@@ -1065,12 +1110,28 @@ def roster_view(room):
1065
1110
  """Who is present and who still owes a turn in the open round; its revision changes whenever either changes."""
1066
1111
  last = room["rounds"][-1] if room["rounds"] else None
1067
1112
  spoken = set(last["speakers"]) if last and not last["completed"] else set()
1068
- experts = [{"name": name, "mode": mode, "host": name == room["host"], "spoken": name in spoken} for name, mode in room["experts"].items()]
1069
- state = json.dumps([sorted(room["experts"]), sorted(spoken)], ensure_ascii=False)
1070
- return {"experts": experts, "waiting": [item["name"] for item in experts if not item["spoken"] and not item["host"]],
1113
+ pending_registration = unregistered_experts(room)
1114
+ experts = [{"name": name, "mode": mode, "host": name == room["host"], "spoken": name in spoken,
1115
+ "registered": name not in pending_registration} for name, mode in room["experts"].items()]
1116
+ state = json.dumps([sorted(room["experts"]), sorted(spoken), sorted(pending_registration)], ensure_ascii=False)
1117
+ return {"experts": experts, "unregistered": pending_registration,
1118
+ "waiting": [item["name"] for item in experts if not item["spoken"] and not item["host"] and item["registered"]],
1071
1119
  "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
1072
1120
 
1073
1121
 
1122
+ def unregistered_experts(room):
1123
+ """Manual experts of a woken discussion (adapter 'manual') that have not registered from their own session; they cannot be scheduled until they do."""
1124
+ return [member["name"] for member in room["members"]
1125
+ if member["adapter"] == "manual" and member["status"] == "active" and not member["registered_at"] and member["name"] in room["experts"]]
1126
+
1127
+
1128
+ def require_registered(room, names):
1129
+ """Reject scheduling an expert that has not registered yet; it joins the round queue by itself once it registers."""
1130
+ missing = [name for name in dict.fromkeys(names) if name in unregistered_experts(room)]
1131
+ if missing:
1132
+ raise ValueError(f"以下手动专家尚未注册,不能安排发言:{'、'.join(missing)};它在自己的会话中注册后会自动加入本轮队列,也可先让其暂离")
1133
+
1134
+
1074
1135
  def require_round_coverage(room, plan, payload):
1075
1136
  """A multi-turn plan is only accepted against the latest roster and must schedule everyone still waiting in the round."""
1076
1137
  if len(plan) < 2:
@@ -1100,6 +1161,7 @@ def create_turn(connection, room_id, payload):
1100
1161
  require_within_limits(room)
1101
1162
  if any(expert not in room["experts"] or len(question) > MAX_QUESTION_LENGTH for expert, question, _ in plan):
1102
1163
  raise ValueError("请选择参与专家,待回应问题最多 1000 字")
1164
+ require_registered(room, [expert for expert, _, _ in plan])
1103
1165
  require_round_coverage(room, plan, payload)
1104
1166
  if room["phase"] in ("paused", "concluded"):
1105
1167
  raise ValueError("话题已暂停或收束,请先调整阶段")
@@ -1151,6 +1213,7 @@ def continue_turns(connection, room_id, payload):
1151
1213
  if not room["queued_turns"]:
1152
1214
  raise FileExistsError("没有排队中的安排;请创建新的安排")
1153
1215
  require_within_limits(room)
1216
+ require_registered(room, [room["queued_turns"][0]["expert"]])
1154
1217
  require_floor(room, room["host"])
1155
1218
  turn_id = room["queued_turns"][0]["id"]
1156
1219
  connection.execute("UPDATE discussion_turns SET status='pending' WHERE id=? AND status='queued'", (turn_id,))
@@ -1240,7 +1303,7 @@ def update_turn(connection, room_id, turn_id, payload):
1240
1303
  status = payload.get("status")
1241
1304
  if status not in ("notified", "cancelled"):
1242
1305
  raise ValueError("安排状态仅可显式改为 notified 或 cancelled")
1243
- if turn["status"] == status:
1306
+ if turn["status"] == status or (status == "cancelled" and turn["status"] == "skipped"):
1244
1307
  return turn
1245
1308
  if status == "notified" and room["phase"] in ("paused", "concluded"):
1246
1309
  raise ValueError("话题已暂停或收束,不能继续通知")
@@ -257,7 +257,7 @@ running 状态即使只有 pending 事件,也必须先暂停才能重新启动
257
257
  - **请求**:用户消息按结构化接收者 `to`(成员名)为每位成员生成一条请求;省略且仅有一位在场成员时默认点名该成员,多位成员时交给主持人(`route`),只有在线成员而没有主持人时只作留言,不广播。网页只从正文中代码与引用之外的 `@成员` 生成 `to`,没有 @ 时省略 `to`。请求状态为 `pending`(待投递)→ `delivering`(处理中)→ `completed` / `cancelled` / `failed`。
258
258
  - **执行**:`member_executions` 每位成员最多一个 `dispatched/started`(部分唯一索引),不同成员可并行。接入程序用控制凭据 `GET inbox`、`POST executions {member_id, client_id}`(同一 `client_id` 返回同一执行)把成员的全部待投递请求打包;`POST executions/ID {action}` 支持 `renew`(租约 120 秒,不超过单次执行时限,默认 20 分钟)、`fail`(请求标记失败)和 `release`(仅确认未送达时退回队列)。租约过期的执行在下次读取或领取时标记失败,不自动重发。
259
259
  - **主持人与任务链**:用户是需求方,不担任主持人。只要有在场的非在线成员,房间就保持恰好一位主持人:成员变动时,离开或移出的主持人失去角色,由第一位在场的 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 完成回交,回交请求按(派活消息,主持人)唯一,重复执行不会重复唤醒。
260
- - **智能调度推荐**:接入程序用控制凭据 `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` 供接入程序识别。
260
+ - **智能调度推荐**:接入程序用控制凭据 `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
261
  - **回应建议**:普通成员新发言的结构化 `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 发布前显式执行。已有历史消息不补写建议。
262
262
  - **在线成员**:`access=online` 的成员只读,不进入 `inbox`,不能用自己的凭证发言;房主以控制凭据代发其回答(`author` 为该成员、`submitted_by` 为用户、`source=proxy`),可带 `request_ids` 关闭它的待处理请求。
263
263
  - **关联房间**:从自由房间发起的讨论房间在建房时带 `parent_room_id`,记录在 `room_links`;讨论房间视图有 `parent_room`,自由房间视图列出 `linked_rooms`。头脑风暴的轮次、时长与名单因此天然独立于自由房间。
package/docs/cli.md CHANGED
@@ -134,7 +134,7 @@ codiscuss floor view $room
134
134
  | 主持发言 | 领取后 `message post ROOM --as Codex --kind moderation --body-file FILE --submission-id UUID` |
135
135
  | 在线专家代发 | 被正式安排后,由主持人 `floor take ROOM --as Codex --speaker Claude --revision N`,再 `message post ROOM --as Codex --author Claude --turn TURN --summary-file SUMMARY --summary-by Claude --body-file FILE --submission-id UUID` |
136
136
  | 放弃发言 | `floor return ROOM --as NAME --revision N`;未完成安排仍需处理 |
137
- | 整轮安排 | 主持人领取后 `turn roster ROOM` 后 `turn create ROOM --as Codex --plan-file PLAN.json --roster REV --submission-id UUID`;PLAN 为 `[{"expert":"Claude","question":"…"}]`,按顺序接力;多项计划须先 `turn roster ROOM` 取得最新专家状态,把其 `revision` 用 `--roster` 带上,并包含 `waiting` 中的每位专家(主持人除外):未带 revision 返回 400,名单已变化返回 409,缺人返回 400 并列出名字 |
137
+ | 整轮安排 | 主持人领取后 `turn roster ROOM` 后 `turn create ROOM --as Codex --plan-file PLAN.json --roster REV --submission-id UUID`;PLAN 为 `[{"expert":"Claude","question":"…"}]`,按顺序接力;多项计划须先 `turn roster ROOM` 取得最新专家状态,把其 `revision` 用 `--roster` 带上,并包含 `waiting` 中的每位专家(主持人除外;尚未注册的手动专家列在 `unregistered`,不在 `waiting` 中,也不能被安排,否则返回 400 并列出名字):未带 revision 返回 400,名单已变化返回 409,缺人返回 400 并列出名字 |
138
138
  | 继续原计划 | 用户介入或打断后,主持人领取并 `turn continue ROOM --as Codex`;改用新安排则排队自动作废 |
139
139
  | 取消安排 | 主持人领取后 `turn cancel ROOM TURN --as Codex` |
140
140
  | 切换阶段 | `room update ROOM --as Codex --phase discussing` |
package/docs/runner.md CHANGED
@@ -283,7 +283,7 @@ Windows 的 `executable` 填原生 `.exe`,不填 npm 的 `.cmd` / `.ps1` 包
283
283
 
284
284
  agy 只用一套方法控制权限:
285
285
 
286
- 1. **只在本工具新建的会话中执行**:每位参与者对应一个独立原生讨论项目,权限只写在该项目里。外部已有会话(包括从当前 agy 对话发起主持、在启动配置中填写 `session_id`)会恢复其原项目,无法套用讨论项目权限,因此启动时即拒绝,交付时也再次拒绝。
286
+ 1. **只在本工具新建的会话中执行**:每位参与者对应一个独立原生讨论项目,权限只写在该项目里。外部已有会话(包括从当前 agy 对话发起主持、在启动配置中填写 `session_id`)会恢复其原项目,无法套用讨论项目权限,因此启动时即拒绝,交付时也再次拒绝。唯一例外是手动专家的托管:agy 在自己的会话里执行 `codiscuss` 时由 `ANTIGRAVITY_CONVERSATION_ID` 上报会话,用户注册后服务把它转为 `antigravity_cli` 并以 `--conversation ID` 续接,不加 `--project`,沿用该会话原项目和用户自己的 agy 权限(无讨论项目规则,也不检查全局放行);无头模式下 shell 命令须已由用户的规则放行,例如 `command(regex:^codiscuss(\.cmd)? .*$)`,否则被拒绝。
287
287
  2. **项目内规则**:allow 为受限的讨论命令规则与草稿目录写入(`write` 时加项目目录写入);deny 为项目目录写入(`read` 时)、运行目录、技能目录、`~/.gemini` 写入与 `mcp(*)`。规则按目录前缀递归匹配,不使用 `/**`;Deny > Ask > Allow,因此草稿不得位于受保护目录中,冲突在交付前报告。
288
288
  3. **交付前检查全局设置**:CLI 与共享全局 allow 中若有 `*`、`command(*)`、`unsandboxed(*)` 或 `unsandboxed_command(*)` 这类放行全部命令的规则,则拒绝交付,因为它会让 shell 绕过项目的文件规则。具体命令的放行和 `toolPermission` 不检查,属于用户自己的授权。未授权 shell 在非交互模式下被拒绝。
289
289
  4. **不改用户全局设置**:本工具不向 agy 全局设置写入任何规则;`setup` 只移除早期版本记录在 `agy-permission.json` 中的全局规则(先备份到服务目录 `agy-settings.backup.json`),其他设置与用户规则不变。
package/docs/skills.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  主持开始新讨论时确认全自动或手动复制冷启动。自动任务明确引用仓库内的当前技能版本,并提供任务文件与 CLI 凭据文件路径;参与者先 `execution enter`,再按阶段读取材料。自动执行不建原生定时。手动方式沿用本文的原有接入;受保护房间另由用户提供私有接入文件。讨论项目和 cwd 可选,不能把 CLI 仓库路径当作讨论资料目录或擅自增加目录权限。
8
8
 
9
- 两份主技能把讨论质量与判断放在前面,仅保留当前任务必需的执行边界。主持人的 `references/manual.md` 只在手动接入、在线转交或原生任务场景读取;参与者的 `references/manual.md` 只管手动持续参与,`references/writing.md` 在首次使用某类写入、代发或失败恢复时按需查阅。自动任务不加载手动流程,也不逐轮复读帮助、全量历史和故障清单。
9
+ 两份主技能把讨论质量与判断放在前面,仅保留当前任务必需的执行边界。主持人的 `references/manual.md` 只在手动接入、在线转交或原生任务场景读取;参与者的 `references/manual.md` 管手动成员的接入文件、注册与原生定时持续参与,`references/free.md` 只在自由协作房间读取,`references/writing.md` 在首次使用某类写入、代发或失败恢复时按需查阅。自动任务不加载手动流程,也不逐轮复读帮助、全量历史和故障清单。
10
10
 
11
11
  启动提示可能已要求 `execution enter`;当前调用成功认领一次后,加载技能不再次认领。主持人先用一次 `state set` 保存判断、简报和下一步意图,再创建安排;安排落库即结束该次资格,不追加状态写入。专家提交原文即结束,由程序续租和交付后续任务。返回记录已含完整保存证据时直接核对,缺少证据或结果不明才补查询;这减少重复读写,但不削弱署名、发言权、提交去重或到限约束。
12
12