ai_co_discuss 0.3.94 → 0.3.96
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/references/free.md +3 -1
- package/.agents/skills/workflow-creator/SKILL.md +2 -2
- package/backend/free_room.py +155 -59
- package/backend/identity.py +2 -2
- package/backend/server.py +2 -2
- package/docs/en/runner.md +3 -1
- package/docs/en/workflow.md +1 -1
- package/docs/runner.md +3 -1
- package/docs/workflow.md +1 -1
- package/package.json +1 -1
- package/runner/adapters/api.mjs +5 -4
- package/runner/adapters/profiles.mjs +11 -0
- package/runner/discuss.py +39 -3
- package/runner/discussion_api.mjs +1 -8
- package/runner/discussion_launch.mjs +2 -2
- package/runner/scheduler.mjs +14 -7
- package/web/app.js +6 -4
- package/web/modern.css +1 -1
- package/web/modern.js +11 -11
- package/workflows/story-serial/SKILL.md +13 -12
- package/workflows/story-serial/agents/critic.md +2 -5
- package/workflows/story-serial/agents/critic_people.md +1 -1
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
- 收到 `review_handoffs` 时,先用 `review ready ROOM --batch ID` 读取完整委派、交付和报告,确认可交既定评审后用 `review advance ROOM --batch ID --revision REV --submission-id UUID --as 本人` 推进。只处理该批交接,其他请求继续完成;状态变化须重读,不能替换评审者或扩大权限。能力失效、额度不足、交付不完整等原因保留在读取结果和任务中。实际投递前本机复核只读权限与能力版本,失败撤销该次评审并交回主持人。
|
|
27
27
|
- 收到 `handoff` 后交给其他成员或汇总给用户;成员的回答已完整时只用一两句说明完成,不复述。
|
|
28
28
|
- 几位成员要按固定先后接力、后一位依赖前一位的结果(写完再审、实现后评审)时,派活加 `--sequential`,`--to` 按接力顺序点名:程序在每位答完后直接交给下一位,最后一位答完交回你,中间不唤醒你。走完一圈算一轮(中间各棒不另计)。派活正文要把每一棒做什么、按什么标准交给下一棒写清楚,后面的成员靠它和前一棒的回答工作。
|
|
29
|
-
-
|
|
29
|
+
- 某一棒有几位互不依赖、可以同时做的成员(如几位评审各审一类问题)时,把他们写成同一棒:第一位用 `--to`,其余紧跟着用 `--with`(如 `--to 写手 --to 评审甲 --with 评审乙 --sequential --loop`)。这一棒的成员同时开工,全部结束才交下一棒,下一棒能读到他们每一份回答;组里有人失败,接力停下交回你,用户重试失败的那位后接着走。并行的成员看不到彼此的意见,派活正文要分清各自管什么。
|
|
30
|
+
- 派活时想让某位成员同时开工、但不必等它(如评审期间让记录员先整理别的材料),加 `--detach 成员`(可重复,不能与 `--to` 重名):它和这次派活一起开工,交回你或交下一棒都不等它;它做完不交回你,结果在房间里可读;失败时程序通知你。用在依次接力上时,它只随第一棒开工一次。撤回这次派活会一并撤回它。
|
|
31
|
+
- 要“改完再审、审到通过为止”时再加 `--loop`:每走完一圈,交回你的 `handoff` 带 `sequence.phase: checkpoint`。本圈最后一位(最后一棒是同时做的一组时,其中任一位)明确要求返工时,用 `sequence continue ROOM --request 该交接请求编号 --as 本人` 开始下一圈(程序直接接着走,你本次即结束);认可、只剩可不采纳的建议、需要你或用户裁定时,不继续,直接验收或说明。开启智能调度时,它会先替你判断检查点,只有明确返工才自动开始下一圈,其余仍交给你;任务的 `scheduler_suggestions` 记着它的判断。`sequence view ROOM ID` 可读本圈各棒原文。每开始新的一圈算一轮,轮数用尽时循环停下,由用户决定是否继续。
|
|
30
32
|
- 用户只点名你(旁问,如问近况、是否卡住)时:读 `sequence view`、`room events` 与进行中执行的详情后直接回答,接力和循环照常进行。旁问里的新任务涉及正在接力的成员或同一批文件时,等接力交回后再派,或先撤回原派活;互不相干的可以马上派。用户发言会清除你之前的 `--continue`,仍需要就在这次回答里重新带上。
|
|
31
33
|
- 同一条用户消息最多推进若干轮(`room view` 的 `free.max_rounds`,0 为不限;本链已用与上限见 `free.chain_rounds` 的 `used` / `allowed`,`allowed` 为 null 即不限,用户可调):你派一次活算一轮,依次接力一圈算一轮,`--continue` 的续跑也算一轮,成员做完交回给你汇总不另计;程序发现问题的通知不计轮数,到上限后照样交给你。到上限时你的下一次派活会被暂存、任务链停下,由用户决定继续或发新消息;你不能自己加轮。派活前先想清楚需要几位、几轮,最后一轮留给汇总。(上限防止 AI 之间无人值守地来回转交。)
|
|
32
34
|
- 已知整项任务所需轮数超过剩余轮数时(连写几集、多步实现加评审),在开始派活时带 `--need-rounds N --rounds-reason "…"`(N 为从这次派活起还需要的轮数,含最后汇总):剩余不够时任务链立即停下,用户看到理由并一键批准差额后才发出这次派活,之后不再中途停下;够用时照常派出。不要在回复里让用户改上限或回“继续”,也不要建议超出 `need-rounds` 上限(100)的数值。
|
|
@@ -38,7 +38,7 @@ description: AI CoDiscuss 工作流的创建与修改技能。用户要把房间
|
|
|
38
38
|
- **只写验证过的做法**:用户直接给了分工、还没跑过的,按用户的分工写,跑过一轮后再用同一 ID 修订。
|
|
39
39
|
- **权限按职责最小**:只有需要改文件的角色给 `write`;同一份文件同一时刻只让一个角色改。
|
|
40
40
|
- 主持人不设卡片;主持人的职责写在 SKILL.md 里。
|
|
41
|
-
- **固定先后的接力写成依次派活**:几个角色按固定顺序接力、后一位依赖前一位的结果时(写完再审、实现后评审),在 SKILL.md
|
|
41
|
+
- **固定先后的接力写成依次派活**:几个角色按固定顺序接力、后一位依赖前一位的结果时(写完再审、实现后评审),在 SKILL.md 写明主持人用一次依次派活点名这几位(按接力顺序),需要“改到通过为止”时写成循环,由主持人在每圈结束时决定是否再来一圈;不要写成主持人每一棒都亲自转交。原因:程序会在每棒答完后直接交给下一位,主持人只在开头和验收时出场。几位评审排在同一圈时,按问题类型分工、各自的“必改”只限本类。互不依赖的评审写成同一棒并行(主持人用 `--with` 点名),这一棒全部审完才交下一位,循环检查点读每位的结论,任一位要求返工就再来一圈;他们看不到彼此的意见,SKILL.md 要分清各自管什么。后一位要参考前一位时排成串行,并让最后一位读完前面的结论后给出合并结论:循环只读最后一棒,不汇总会漏掉前面的返工。
|
|
42
42
|
|
|
43
43
|
## 格式
|
|
44
44
|
|
|
@@ -96,7 +96,7 @@ project_access: write
|
|
|
96
96
|
|
|
97
97
|
- **工作流 ID**(文件夹名 = SKILL.md 的 `name`):小写英文和数字,可用单个连字符连接(不在首尾),1—40 位。`label` 最多 32 字。
|
|
98
98
|
- **`description`** 最多 500 字:写清做什么、什么时候用。主持人只凭它判断一条请求该不该按这个工作流做,写含糊了,该用时会不用、不该用时会套用。
|
|
99
|
-
- **卡片**:最多 30 张。`name` 是卡片 ID,等于文件名,只用小写英文、数字和下划线,最多 16 位。`platform` 是平台 ID:`Claude`、`Codex`、`Antigravity`、`DeepSeek`、`Qwen`、`Grok`(网页显示的 Gemini 写 `Antigravity
|
|
99
|
+
- **卡片**:最多 30 张。`name` 是卡片 ID,等于文件名,只用小写英文、数字和下划线,最多 16 位。`platform` 是平台 ID:`Claude`、`Codex`、`Antigravity`、`DeepSeek`、`Qwen`、`Grok`、`OpenAI`、`Custom`(网页显示的 Gemini 写 `Antigravity`,自定义 API 写 `Custom`),须是本机已配置的软件,或已配置的模型 API(官方平台可按已保存的 API Key 加入;Custom 须先保存完整的本机 API 配置)。成员名由程序拼成 `平台-卡片ID`。
|
|
100
100
|
- **卡片可选字段**:`label`、`description`、`project_access`(read / write / auto / full)、`model`、`effort`。不用的字段直接省略;模型和强度省略即用本机默认值。接入方式、可执行文件、密钥不写进卡片,由各自电脑的配置决定。
|
|
101
101
|
- 头信息只支持单行 `key: value`;不支持多行值、列表和嵌套。
|
|
102
102
|
- 卡片正文留空时使用该软件的默认提示。
|
package/backend/free_room.py
CHANGED
|
@@ -604,6 +604,10 @@ def resolve_member_fields(connection, room_id, payload):
|
|
|
604
604
|
resolve = lambda value: handles.get(value.strip(), value) if isinstance(value, str) else value
|
|
605
605
|
if isinstance(payload.get("to"), list):
|
|
606
606
|
payload["to"] = [resolve(item) for item in payload["to"]]
|
|
607
|
+
if isinstance(payload.get("detach"), list):
|
|
608
|
+
payload["detach"] = [resolve(item) for item in payload["detach"]]
|
|
609
|
+
if isinstance(payload.get("parallel"), list):
|
|
610
|
+
payload["parallel"] = [[resolve(item) for item in group] if isinstance(group, list) else group for group in payload["parallel"]]
|
|
607
611
|
for key in ("next_review", "expert"):
|
|
608
612
|
if key in payload:
|
|
609
613
|
payload[key] = resolve(payload[key])
|
|
@@ -985,7 +989,7 @@ def recipients(connection, room_id, payload):
|
|
|
985
989
|
|
|
986
990
|
# What a proactive post may not carry: it answers no request. Only the host's may also dispatch, as an answer does.
|
|
987
991
|
PROACTIVE_EXCLUDED = ("execution_id", "request_ids", "next_review", "turn_id")
|
|
988
|
-
HOST_ONLY_PROACTIVE = ("to", "handoff", "no_handoff", "continue", "sequential", "loop", "need_rounds", "rounds_reason")
|
|
992
|
+
HOST_ONLY_PROACTIVE = ("to", "handoff", "no_handoff", "continue", "sequential", "loop", "parallel", "detach", "need_rounds", "rounds_reason")
|
|
989
993
|
|
|
990
994
|
|
|
991
995
|
def check_need_rounds(member, payload):
|
|
@@ -1058,10 +1062,10 @@ def after_proactive(connection, room_id, message_id, member, payload):
|
|
|
1058
1062
|
return
|
|
1059
1063
|
if payload.get("to") and payload.get("sequential") is True:
|
|
1060
1064
|
start_sequence(connection, room_id, member, message_id, payload["to"], message_id, payload.get("loop") is True,
|
|
1061
|
-
need_plan(payload))
|
|
1065
|
+
need_plan(payload), payload.get("parallel"), payload.get("detach"))
|
|
1062
1066
|
elif payload.get("to"):
|
|
1063
1067
|
dispatch(connection, room_id, member, message_id, payload["to"], message_id, payload.get("handoff") is True,
|
|
1064
|
-
payload.get("no_handoff") is True, {"need": need_plan(payload)})
|
|
1068
|
+
payload.get("no_handoff") is True, {"need": need_plan(payload)}, payload.get("detach"))
|
|
1065
1069
|
remember_continue(connection, room_id, member, message_id, message_id, payload)
|
|
1066
1070
|
settle_batches(connection, room_id)
|
|
1067
1071
|
|
|
@@ -1280,14 +1284,17 @@ def continue_chain(connection, room_id, token, chain_id, payload):
|
|
|
1280
1284
|
connection.execute("""UPDATE free_chain_holds SET message_id=0,host_id=0,names='[]',handoff=0,asked_rounds=0,asked_reason=''
|
|
1281
1285
|
WHERE room_id=? AND chain_id=?""", (room_id, chain_id))
|
|
1282
1286
|
if held["message_id"]:
|
|
1283
|
-
|
|
1287
|
+
names = json.loads(held["names"])
|
|
1288
|
+
names, detach = (names["to"], names["detach"]) if isinstance(names, dict) else (names, None)
|
|
1289
|
+
dispatch(connection, room_id, host, held["message_id"], names, chain_id, bool(held["handoff"]), detach=detach)
|
|
1284
1290
|
return free_view(connection, room_id)
|
|
1285
1291
|
|
|
1286
1292
|
|
|
1287
|
-
def hold_dispatch(connection, room_id, host, message_id, names, chain_id, handoff, ask=None):
|
|
1293
|
+
def hold_dispatch(connection, room_id, host, message_id, names, chain_id, handoff, ask=None, detach=None):
|
|
1288
1294
|
"""Stop a chain at its round limit, keeping the dispatch it held back so the owner can send it by continuing.
|
|
1289
1295
|
|
|
1290
1296
|
`ask` is the host's request for more rounds ({"rounds": the rounds to add, "reason"}), shown to the owner to approve.
|
|
1297
|
+
Members sent along without being waited for (`detach`) are held with it: `names` then keeps {"to", "detach"}.
|
|
1291
1298
|
"""
|
|
1292
1299
|
halt_chain(connection, room_id, chain_id, "budget")
|
|
1293
1300
|
asked, reason = (ask["rounds"], ask["reason"]) if ask else (0, "")
|
|
@@ -1295,7 +1302,8 @@ def hold_dispatch(connection, room_id, host, message_id, names, chain_id, handof
|
|
|
1295
1302
|
VALUES (?,?,?,?,?,?,?,?)
|
|
1296
1303
|
ON CONFLICT(room_id,chain_id) DO UPDATE SET message_id=excluded.message_id,host_id=excluded.host_id,names=excluded.names,
|
|
1297
1304
|
handoff=excluded.handoff,asked_rounds=excluded.asked_rounds,asked_reason=excluded.asked_reason""",
|
|
1298
|
-
(room_id, chain_id, message_id, host["id"], json.dumps(list(names),
|
|
1305
|
+
(room_id, chain_id, message_id, host["id"], json.dumps({"to": list(names), "detach": list(detach)} if detach else list(names),
|
|
1306
|
+
ensure_ascii=False), int(handoff), asked, reason))
|
|
1299
1307
|
|
|
1300
1308
|
|
|
1301
1309
|
def wake_host(connection, room_id, chain_id, text, key):
|
|
@@ -1368,13 +1376,14 @@ def resume_host(connection, room_id):
|
|
|
1368
1376
|
wake_host(connection, room_id, row["chain_id"], f"主持人续跑(接 #{row['message_id']}):{row['reason']}", f"continue:{row['message_id']}")
|
|
1369
1377
|
|
|
1370
1378
|
|
|
1371
|
-
def dispatch(connection, room_id, host, message_id, names, chain_id, handoff=False, direct=False, plan=None):
|
|
1379
|
+
def dispatch(connection, room_id, host, message_id, names, chain_id, handoff=False, direct=False, plan=None, detach=None):
|
|
1372
1380
|
"""Queue the host's named members as one batch, one round; it hands back to the host when it ends.
|
|
1373
1381
|
|
|
1374
1382
|
`direct` (the host's `no_handoff`) lets a one-member batch that completed reach the user without coming back; a
|
|
1375
1383
|
failure still comes back. `plan` carries either `relay` (a relay's later step in the same pass: free, never held)
|
|
1376
1384
|
or `need` (the host's `need_rounds`: {"rounds", "reason"}); a need above the rounds left stops the chain right away
|
|
1377
|
-
and asks the owner for the difference.
|
|
1385
|
+
and asks the owner for the difference. Members in `detach` start with the batch but stay outside it: nothing waits for
|
|
1386
|
+
them, and a failure of theirs reaches the host like any request outside a batch.
|
|
1378
1387
|
"""
|
|
1379
1388
|
plan = plan or {}
|
|
1380
1389
|
member_ids = named_members(active_members(connection, room_id), names, exclude=host["name"])
|
|
@@ -1384,10 +1393,12 @@ def dispatch(connection, room_id, host, message_id, names, chain_id, handoff=Fal
|
|
|
1384
1393
|
remaining = None if plan.get("relay") else rounds_left(connection, room_id, chain_id)
|
|
1385
1394
|
if remaining is not None and (remaining < 1 or need and remaining < need["rounds"]):
|
|
1386
1395
|
ask = {"rounds": need["rounds"] - max(remaining, 0), "reason": need["reason"]} if need else None
|
|
1387
|
-
hold_dispatch(connection, room_id, host, message_id, names, chain_id, handoff, ask)
|
|
1396
|
+
hold_dispatch(connection, room_id, host, message_id, names, chain_id, handoff, ask, detach)
|
|
1388
1397
|
return
|
|
1389
1398
|
for member_id in member_ids:
|
|
1390
1399
|
insert_request(connection, room_id, member_id, message_id, chain_id, "ask", "host", batch_id=message_id)
|
|
1400
|
+
for member_id in named_members(active_members(connection, room_id), detach or [], exclude=host["name"]):
|
|
1401
|
+
insert_request(connection, room_id, member_id, message_id, chain_id, "ask", "host")
|
|
1391
1402
|
connection.execute("INSERT INTO free_batches (room_id,batch_id,chain_id,host_id) VALUES (?,?,?,?)",
|
|
1392
1403
|
(room_id, message_id, chain_id, host["id"]))
|
|
1393
1404
|
if plan.get("relay"):
|
|
@@ -1467,7 +1478,10 @@ def advance_queues(connection, room_id):
|
|
|
1467
1478
|
|
|
1468
1479
|
def checked_sequence(actor, payload):
|
|
1469
1480
|
"""Validate a host's one-after-another dispatch (optionally a loop) before its message is saved."""
|
|
1481
|
+
checked_detach(actor, payload)
|
|
1470
1482
|
sequential, loop = payload.get("sequential", False), payload.get("loop", False)
|
|
1483
|
+
if payload.get("parallel") is not None and sequential is not True:
|
|
1484
|
+
raise ValueError("parallel 只与 sequential 一起使用")
|
|
1471
1485
|
if not isinstance(actor, dict) or (sequential is False and loop is False):
|
|
1472
1486
|
return
|
|
1473
1487
|
if sequential is not True or type(loop) is not bool:
|
|
@@ -1479,46 +1493,108 @@ def checked_sequence(actor, payload):
|
|
|
1479
1493
|
raise ValueError("依次接力须用 to 按顺序点名至少两位不同成员,每位只出现一次")
|
|
1480
1494
|
if payload.get("next_review"):
|
|
1481
1495
|
raise ValueError("依次接力不能同时声明预授权评审;评审者直接排进顺序即可")
|
|
1496
|
+
relay_order(names, payload.get("parallel"))
|
|
1482
1497
|
|
|
1483
1498
|
|
|
1484
|
-
def
|
|
1485
|
-
"""
|
|
1486
|
-
|
|
1499
|
+
def checked_detach(actor, payload):
|
|
1500
|
+
"""`detach`: members the host sends along with a dispatch without waiting for them; they are not among those in `to`."""
|
|
1501
|
+
detach = payload.get("detach")
|
|
1502
|
+
if detach is None:
|
|
1503
|
+
return
|
|
1504
|
+
if not isinstance(actor, dict) or actor["role"] != "host":
|
|
1505
|
+
raise PermissionError("只有主持人派活时可以带上不等待的成员")
|
|
1506
|
+
names = payload.get("to") or []
|
|
1507
|
+
if (not isinstance(detach, list) or not detach or any(not isinstance(name, str) for name in detach)
|
|
1508
|
+
or len(set(detach)) != len(detach)):
|
|
1509
|
+
raise ValueError("detach 须为不重复的成员名列表")
|
|
1510
|
+
if not names or set(detach) & set(names):
|
|
1511
|
+
raise ValueError("detach 须与 to 同用,且不包含 to 中的成员")
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def relay_order(names, parallel):
|
|
1515
|
+
"""The relay's steps from `to` and `parallel`: each step a list of names; a group named in `parallel` is one step.
|
|
1516
|
+
|
|
1517
|
+
Each group lists at least two members who stand next to each other in `to`; a member belongs to at most one group.
|
|
1518
|
+
"""
|
|
1519
|
+
if parallel is None:
|
|
1520
|
+
return [[name] for name in names]
|
|
1521
|
+
groups = parallel if isinstance(parallel, list) else None
|
|
1522
|
+
if not groups or any(not isinstance(group, list) or len(group) < 2 or len(set(group)) != len(group)
|
|
1523
|
+
or any(name not in names for name in group) for group in groups):
|
|
1524
|
+
raise ValueError("parallel 须为若干组成员名,每组至少两位不同成员,且都在 to 中")
|
|
1525
|
+
grouped = [name for group in groups for name in group]
|
|
1526
|
+
if len(set(grouped)) != len(grouped):
|
|
1527
|
+
raise ValueError("parallel 中每位成员只能属于一组")
|
|
1528
|
+
steps, index = [], 0
|
|
1529
|
+
while index < len(names):
|
|
1530
|
+
group = next((group for group in groups if names[index] in group), None)
|
|
1531
|
+
if group is None:
|
|
1532
|
+
steps.append([names[index]])
|
|
1533
|
+
index += 1
|
|
1534
|
+
continue
|
|
1535
|
+
if set(names[index:index + len(group)]) != set(group):
|
|
1536
|
+
raise ValueError("parallel 的同一组成员须在 to 中相邻")
|
|
1537
|
+
steps.append(names[index:index + len(group)])
|
|
1538
|
+
index += len(group)
|
|
1539
|
+
return steps
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def relay_steps(sequence):
|
|
1543
|
+
"""The relay's steps as lists of member ids; a step stored as one id (single member) reads as a one-member list."""
|
|
1544
|
+
return [step if isinstance(step, list) else [step] for step in json.loads(sequence["member_ids"])]
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def stored_step(member_ids):
|
|
1548
|
+
"""How a step is kept in `member_ids` and `steps`: one member as its id, as before groups existed; a group as a list."""
|
|
1549
|
+
return member_ids[0] if len(member_ids) == 1 else member_ids
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def start_sequence(connection, room_id, host, message_id, names, chain_id, loop, need=None, parallel=None, detach=None):
|
|
1553
|
+
"""Start a host's one-after-another dispatch with its first step; the rest follow as each step ends.
|
|
1554
|
+
|
|
1555
|
+
A step named in `parallel` is a group: its members work at once and the relay goes on only after all of them end.
|
|
1556
|
+
"""
|
|
1557
|
+
active = active_members(connection, room_id)
|
|
1558
|
+
named_members(active, names, exclude=host["name"])
|
|
1487
1559
|
if not chain_id:
|
|
1488
1560
|
return
|
|
1561
|
+
order = relay_order(names, parallel)
|
|
1562
|
+
steps = [stored_step([active[name]["id"] for name in step]) for step in order]
|
|
1489
1563
|
connection.execute("""INSERT INTO free_sequences (room_id,message_id,chain_id,host_id,member_ids,loop,batch_id,steps)
|
|
1490
|
-
VALUES (?,?,?,?,?,?,?,?)""", (room_id, message_id, chain_id, host["id"], json.dumps(
|
|
1491
|
-
json.dumps([[1,
|
|
1492
|
-
dispatch(connection, room_id, host, message_id,
|
|
1564
|
+
VALUES (?,?,?,?,?,?,?,?)""", (room_id, message_id, chain_id, host["id"], json.dumps(steps), int(loop), message_id,
|
|
1565
|
+
json.dumps([[1, steps[0], message_id]])))
|
|
1566
|
+
dispatch(connection, room_id, host, message_id, order[0], chain_id, plan={"need": need}, detach=detach)
|
|
1493
1567
|
|
|
1494
1568
|
|
|
1495
1569
|
def sequence_step(connection, room_id, sequence, host, position, pass_number, message_id):
|
|
1496
|
-
"""Ask the
|
|
1570
|
+
"""Ask the members still here of the step at `position`, hung on `message_id`; one step is one batch.
|
|
1497
1571
|
|
|
1498
1572
|
A pass's first step costs one round (or waits at the limit); later steps of the same pass ride on it.
|
|
1499
1573
|
"""
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
steps = json.loads(sequence["steps"]) + [[pass_number,
|
|
1574
|
+
present = {row["id"]: name for name, row in active_members(connection, room_id).items()}
|
|
1575
|
+
member_ids = [member_id for member_id in relay_steps(sequence)[position] if member_id in present]
|
|
1576
|
+
steps = json.loads(sequence["steps"]) + [[pass_number, stored_step(member_ids), message_id]]
|
|
1503
1577
|
connection.execute("UPDATE free_sequences SET status='running',pass=?,position=?,batch_id=?,steps=? WHERE id=?",
|
|
1504
1578
|
(pass_number, position, message_id, json.dumps(steps), sequence["id"]))
|
|
1505
|
-
dispatch(connection, room_id, host, message_id, [
|
|
1579
|
+
dispatch(connection, room_id, host, message_id, [present[member_id] for member_id in member_ids], sequence["chain_id"],
|
|
1580
|
+
plan={"relay": pass_number == sequence["pass"]})
|
|
1506
1581
|
|
|
1507
1582
|
|
|
1508
1583
|
def next_present(connection, room_id, sequence, start):
|
|
1509
|
-
"""The first position from `start`
|
|
1584
|
+
"""The first position from `start` with a member still here who can be asked; steps whose members all left are skipped."""
|
|
1510
1585
|
present = {row["id"] for row in active_members(connection, room_id).values()}
|
|
1511
|
-
return next((index for index,
|
|
1586
|
+
return next((index for index, step in enumerate(relay_steps(sequence)) if index >= start and present.intersection(step)), None)
|
|
1512
1587
|
|
|
1513
1588
|
|
|
1514
1589
|
def advance_sequence(connection, room_id, sequence, host, completed, failed=False):
|
|
1515
|
-
"""Pass a finished step on to the next
|
|
1590
|
+
"""Pass a finished step on to the next one; after the last one hand back to the host, as a checkpoint for a loop.
|
|
1516
1591
|
|
|
1517
|
-
A step nobody completed ends the sequence. A failed step
|
|
1518
|
-
|
|
1592
|
+
A step nobody completed ends the sequence. A failed step, a group step with one member failed included, is told to
|
|
1593
|
+
the present host, which decides what follows (retrying the failure carries the relay on); a stopped one (or one whose
|
|
1594
|
+
members left) halts the chain like any stopped batch.
|
|
1519
1595
|
"""
|
|
1520
|
-
if not completed or not host or host["id"] != sequence["host_id"]:
|
|
1521
|
-
if
|
|
1596
|
+
if not completed or failed or not host or host["id"] != sequence["host_id"]:
|
|
1597
|
+
if failed and host:
|
|
1522
1598
|
wake_host(connection, room_id, sequence["chain_id"], f"依次派活 #{sequence['message_id']} 在第 {sequence['position'] + 1} 棒失败,"
|
|
1523
1599
|
"接力已停下。请主持人决定重试、改派还是告知用户。", f"sequence:{sequence['id']}:{sequence['batch_id']}")
|
|
1524
1600
|
elif not completed:
|
|
@@ -1550,21 +1626,26 @@ def sequence_view(connection, room_id, sequence_id, token):
|
|
|
1550
1626
|
names = {item["id"]: item["name"] for item in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
|
|
1551
1627
|
origin = connection.execute("SELECT id,author,text FROM messages WHERE id=?", (row["message_id"],)).fetchone()
|
|
1552
1628
|
answers = []
|
|
1553
|
-
for pass_number,
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1629
|
+
steps_of_pass = [(member_ids, batch_id) for pass_number, member_ids, batch_id in json.loads(row["steps"]) if pass_number == row["pass"]]
|
|
1630
|
+
for step_number, (member_ids, batch_id) in enumerate(steps_of_pass, 1):
|
|
1631
|
+
for member_id in member_ids if isinstance(member_ids, list) else [member_ids]:
|
|
1632
|
+
answer = connection.execute("""SELECT r.status,m.id,m.text FROM member_requests r LEFT JOIN messages m ON m.id=r.answer_id
|
|
1633
|
+
WHERE r.room_id=? AND r.batch_id=? AND r.member_id=? AND r.purpose='ask'""", (room_id, batch_id, member_id)).fetchone()
|
|
1634
|
+
answers.append({"member": names.get(member_id), "step": step_number, "status": answer["status"] if answer else "waiting",
|
|
1635
|
+
"message_id": answer["id"] if answer else None, "text": answer["text"] if answer else None})
|
|
1560
1636
|
handoff = connection.execute("SELECT status FROM member_requests WHERE id=?", (row["handoff_request"],)).fetchone()
|
|
1561
|
-
return {"id": row["id"], "message_id": row["message_id"], "order":
|
|
1637
|
+
return {"id": row["id"], "message_id": row["message_id"], "order": order_names(row, names),
|
|
1562
1638
|
"loop": bool(row["loop"]), "pass": row["pass"], "position": row["position"], "status": row["status"],
|
|
1563
1639
|
"chain_id": row["chain_id"], "remaining_rounds": rounds_left(connection, room_id, row["chain_id"]),
|
|
1564
1640
|
"handoff_request": row["handoff_request"] or None, "handoff_status": handoff[0] if handoff else None,
|
|
1565
1641
|
"origin": dict(origin) if origin else None, "answers": answers}
|
|
1566
1642
|
|
|
1567
1643
|
|
|
1644
|
+
def order_names(sequence, names):
|
|
1645
|
+
"""The relay's order by name: a one-member step as the name, a group step as the list of its members' names."""
|
|
1646
|
+
return [names.get(step[0]) if len(step) == 1 else [names.get(item) for item in step] for step in relay_steps(sequence)]
|
|
1647
|
+
|
|
1648
|
+
|
|
1568
1649
|
def continue_sequence(connection, room_id, token, payload):
|
|
1569
1650
|
"""Run a loop's next pass from its checkpoint, closing only that hand-back; bound to the one checkpoint request.
|
|
1570
1651
|
|
|
@@ -1611,16 +1692,17 @@ def sequence_request_context(connection, request):
|
|
|
1611
1692
|
if not sequence:
|
|
1612
1693
|
return {}
|
|
1613
1694
|
names = {item["id"]: item["name"] for item in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (row["room_id"],))}
|
|
1614
|
-
|
|
1615
|
-
order =
|
|
1695
|
+
steps = relay_steps(sequence)
|
|
1696
|
+
order = order_names(sequence, names)
|
|
1616
1697
|
phase = "step" if row["purpose"] == "ask" else "checkpoint" if sequence["loop"] else "end"
|
|
1617
|
-
|
|
1698
|
+
position = next((index for index, step in enumerate(steps) if row["member_id"] in step), None) if phase == "step" else len(order) - 1
|
|
1699
|
+
if position is None:
|
|
1618
1700
|
return {}
|
|
1619
|
-
position = member_ids.index(row["member_id"]) if phase == "step" else len(order) - 1
|
|
1620
1701
|
following = order[position + 1] if phase == "step" and position + 1 < len(order) else None
|
|
1702
|
+
together = [names.get(item) for item in steps[position] if item != row["member_id"]] if phase == "step" else []
|
|
1621
1703
|
return {"sequence": {"id": sequence["id"], "message_id": sequence["message_id"], "order": order, "loop": bool(sequence["loop"]),
|
|
1622
1704
|
"pass": sequence["pass"], "position": position, "phase": phase, "next": following,
|
|
1623
|
-
"answer_id": sequence["answer_id"] or None}}
|
|
1705
|
+
**({"together": together} if together else {}), "answer_id": sequence["answer_id"] or None}}
|
|
1624
1706
|
|
|
1625
1707
|
|
|
1626
1708
|
def default_order(connection, room_id, members):
|
|
@@ -1652,10 +1734,12 @@ def speaking_order(connection, room):
|
|
|
1652
1734
|
if sequence:
|
|
1653
1735
|
# A host's one-after-another dispatch shows like a queue: this pass's members, done up to the one asked now.
|
|
1654
1736
|
kind, target = "queue", sequence["message_id"]
|
|
1655
|
-
for index,
|
|
1656
|
-
if
|
|
1657
|
-
|
|
1658
|
-
|
|
1737
|
+
for index, step in enumerate(relay_steps(sequence)):
|
|
1738
|
+
state = "done" if sequence["status"] == "checkpoint" or index < sequence["position"] else "speaking" if index == sequence["position"] else "queued"
|
|
1739
|
+
for member_id in step:
|
|
1740
|
+
if member_id in by_id:
|
|
1741
|
+
# Members of a group step share its number: they work at once.
|
|
1742
|
+
items.append({"name": by_id[member_id]["name"], "state": state, **({"step": index + 1} if len(step) > 1 else {})})
|
|
1659
1743
|
elif not session:
|
|
1660
1744
|
target = connection.execute("""SELECT MAX(message_id) FROM (
|
|
1661
1745
|
SELECT MAX(message_id) AS message_id FROM free_queue_waiting WHERE room_id=?
|
|
@@ -1673,7 +1757,8 @@ def speaking_order(connection, room):
|
|
|
1673
1757
|
items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
|
|
1674
1758
|
movable = "queued" if kind != "default" else "idle"
|
|
1675
1759
|
relay = {"id": sequence["id"], "loop": bool(sequence["loop"]), "pass": sequence["pass"]} if sequence else None
|
|
1676
|
-
state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items], later, session_plan_state, relay],
|
|
1760
|
+
state = json.dumps([kind, target, [[item["name"], item["state"], item.get("step")] for item in items], later, session_plan_state, relay],
|
|
1761
|
+
ensure_ascii=False)
|
|
1677
1762
|
return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
|
|
1678
1763
|
"default": [member["name"] for member in default_order(connection, room["id"], active)],
|
|
1679
1764
|
**({"sequence": relay} if relay else {}),
|
|
@@ -1708,13 +1793,18 @@ def reorder_speakers(connection, room, token, payload):
|
|
|
1708
1793
|
raise ValueError("新顺序须恰好包含尚未发言的成员" + (f";缺少:{'、'.join(missing)}" if missing else "") + (f";多出:{'、'.join(extra)}" if extra else ""))
|
|
1709
1794
|
names = {member["name"]: member["id"] for member in room["members"] if member["status"] == "active"}
|
|
1710
1795
|
if current.get("sequence"):
|
|
1711
|
-
# Later passes of a loop keep the new order too;
|
|
1796
|
+
# Later passes of a loop keep the new order too; a group moves as one step, placed where its first member is
|
|
1797
|
+
# named; steps whose members all left stay in place at the end.
|
|
1712
1798
|
sequence = connection.execute("SELECT * FROM free_sequences WHERE id=?", (current["sequence"]["id"],)).fetchone()
|
|
1713
|
-
|
|
1714
|
-
kept =
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1799
|
+
steps = relay_steps(sequence)
|
|
1800
|
+
kept, rest, moved = steps[:sequence["position"] + 1], steps[sequence["position"] + 1:], []
|
|
1801
|
+
for name in order:
|
|
1802
|
+
step = next(step for step in rest if names[name] in step)
|
|
1803
|
+
if step not in moved:
|
|
1804
|
+
moved.append(step)
|
|
1805
|
+
steps = kept + moved + [step for step in rest if step not in moved]
|
|
1806
|
+
connection.execute("UPDATE free_sequences SET member_ids=? WHERE id=?",
|
|
1807
|
+
(json.dumps([stored_step(step) for step in steps]), sequence["id"]))
|
|
1718
1808
|
elif current["kind"] == "queue":
|
|
1719
1809
|
connection.execute(f"DELETE FROM free_queue WHERE message_id=? AND NOT {QUEUE_ASKED.format(row='free_queue')}", (current["message_id"],))
|
|
1720
1810
|
for name in order:
|
|
@@ -1773,10 +1863,11 @@ def after_free_message(connection, room_id, message_id, actor, payload):
|
|
|
1773
1863
|
(message_id, execution_id))
|
|
1774
1864
|
connection.execute("UPDATE room_members SET read_until=MAX(read_until,?) WHERE id=?", (message_id, actor["id"]))
|
|
1775
1865
|
if actor["role"] == "host" and payload.get("to") and payload.get("sequential") is True:
|
|
1776
|
-
start_sequence(connection, room_id, actor, message_id, payload["to"], chain_id, payload.get("loop") is True, need_plan(payload)
|
|
1866
|
+
start_sequence(connection, room_id, actor, message_id, payload["to"], chain_id, payload.get("loop") is True, need_plan(payload),
|
|
1867
|
+
payload.get("parallel"), payload.get("detach"))
|
|
1777
1868
|
elif actor["role"] == "host" and payload.get("to"):
|
|
1778
1869
|
dispatch(connection, room_id, actor, message_id, payload["to"], chain_id, payload.get("handoff") is True or bool(payload.get("next_review")),
|
|
1779
|
-
payload.get("no_handoff") is True, {"need": need_plan(payload)})
|
|
1870
|
+
payload.get("no_handoff") is True, {"need": need_plan(payload)}, payload.get("detach"))
|
|
1780
1871
|
if actor["role"] == "host":
|
|
1781
1872
|
remember_continue(connection, room_id, actor, message_id, chain_id, payload)
|
|
1782
1873
|
settle_batches(connection, room_id)
|
|
@@ -1913,13 +2004,15 @@ def withdraw_dispatch(connection, room_id, token, message_id):
|
|
|
1913
2004
|
batches = [message_id] + [row[0] for row in connection.execute(f"SELECT batch_id FROM free_sequences WHERE {sequences}", (room_id, message_id))]
|
|
1914
2005
|
connection.execute(f"UPDATE free_sequences SET status='stopped' WHERE {sequences}", (room_id, message_id))
|
|
1915
2006
|
marks = ",".join("?" * len(batches))
|
|
2007
|
+
# The dispatch's members sent along without being waited for (no batch) are taken back too.
|
|
2008
|
+
taken = f"(r.batch_id IN ({marks}) OR r.message_id=? AND r.batch_id=0 AND r.created_by='host' AND r.purpose='ask')"
|
|
1916
2009
|
live = connection.execute(f"""SELECT DISTINCT e.member_id FROM member_requests r JOIN member_executions e ON e.id=r.execution_id
|
|
1917
|
-
WHERE r.room_id=? AND
|
|
2010
|
+
WHERE r.room_id=? AND {taken} AND e.status IN ('dispatched','started')""", (room_id, *batches, message_id)).fetchall()
|
|
1918
2011
|
for row in live:
|
|
1919
2012
|
stop_member_work(connection, row[0], "主持人撤回派活", "user_stop", keep_pending=True)
|
|
1920
|
-
waiting = f"room_id=? AND
|
|
1921
|
-
record_cancel_reasons(connection, waiting, (room_id, *batches), "user_stop")
|
|
1922
|
-
connection.execute(f"UPDATE member_requests SET status='cancelled' WHERE {waiting}", (room_id, *batches))
|
|
2013
|
+
waiting = f"id IN (SELECT r.id FROM member_requests r WHERE r.room_id=? AND {taken} AND r.status='pending')"
|
|
2014
|
+
record_cancel_reasons(connection, waiting, (room_id, *batches, message_id), "user_stop")
|
|
2015
|
+
connection.execute(f"UPDATE member_requests SET status='cancelled' WHERE {waiting}", (room_id, *batches, message_id))
|
|
1923
2016
|
connection.execute(f"UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id IN ({marks})", (room_id, *batches))
|
|
1924
2017
|
return free_view(connection, room_id)
|
|
1925
2018
|
|
|
@@ -2545,8 +2638,11 @@ def retry_failed(connection, room_id, execution):
|
|
|
2545
2638
|
[request["id"] for request in requests])
|
|
2546
2639
|
for request in requests:
|
|
2547
2640
|
batch_id, chain_id = request["batch_id"], request["chain_id"]
|
|
2548
|
-
|
|
2549
|
-
|
|
2641
|
+
# A relay step stopped by one failed member of its group resumes too: the others' answers wait for this one.
|
|
2642
|
+
if batch_id and (not connection.execute(
|
|
2643
|
+
"SELECT 1 FROM member_requests WHERE room_id=? AND batch_id=? AND status='completed'", (room_id, batch_id)).fetchone()
|
|
2644
|
+
or connection.execute("SELECT 1 FROM free_sequences WHERE room_id=? AND batch_id=? AND status='stopped'",
|
|
2645
|
+
(room_id, batch_id)).fetchone()):
|
|
2550
2646
|
connection.execute("UPDATE free_batches SET settled=0 WHERE room_id=? AND batch_id=?", (room_id, batch_id))
|
|
2551
2647
|
connection.execute("DELETE FROM free_chains WHERE room_id=? AND chain_id=? AND reason='all_failed'", (room_id, chain_id))
|
|
2552
2648
|
connection.execute("UPDATE free_sequences SET status='running' WHERE room_id=? AND batch_id=? AND status='stopped'",
|
package/backend/identity.py
CHANGED
|
@@ -7,14 +7,14 @@ own session, model and thinking effort. Platform names alone keep their historic
|
|
|
7
7
|
import re
|
|
8
8
|
|
|
9
9
|
USER = "用户"
|
|
10
|
-
AI_PLATFORMS = ("Codex", "Claude", "Antigravity", "DeepSeek", "Qwen", "Grok")
|
|
10
|
+
AI_PLATFORMS = ("Codex", "Claude", "Antigravity", "DeepSeek", "Qwen", "Grok", "OpenAI", "Custom")
|
|
11
11
|
PARTICIPANTS = [USER, *AI_PLATFORMS]
|
|
12
12
|
MAX_INSTANCE_SUFFIX = 16
|
|
13
13
|
# AI instances one room may hold, across platforms.
|
|
14
14
|
MAX_ROOM_AGENTS = 12
|
|
15
15
|
INSTANCE_PATTERN = re.compile(rf"({'|'.join(AI_PLATFORMS)})(?:-[0-9A-Za-z_]{{1,{MAX_INSTANCE_SUFFIX}}})?")
|
|
16
16
|
# The name people and AIs call a platform when it differs from its ID; web/app.js PLATFORM_IDENTITIES shows the same.
|
|
17
|
-
PLATFORM_NICKNAMES = {"Antigravity": "Gemini"}
|
|
17
|
+
PLATFORM_NICKNAMES = {"Antigravity": "Gemini", "Custom": "自定义 API"}
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def is_ai(name):
|
package/backend/server.py
CHANGED
|
@@ -1132,7 +1132,7 @@ def check_message_references(connection, room_id, message):
|
|
|
1132
1132
|
def append_free_message(connection, room, payload, actor):
|
|
1133
1133
|
"""Append a free-room message: the owner's creates requests, a member's completes its execution."""
|
|
1134
1134
|
if payload.get("kind") == "moderation":
|
|
1135
|
-
if any(payload.get(field) for field in ("next_review", "review_scope", "review_roster", "sequential", "loop", "need_rounds")):
|
|
1135
|
+
if any(payload.get(field) for field in ("next_review", "review_scope", "review_roster", "sequential", "loop", "parallel", "detach", "need_rounds")):
|
|
1136
1136
|
raise ValueError("主持说明不能声明评审授权、依次接力或申请轮数;请在派活时提交")
|
|
1137
1137
|
return append_free_moderation(connection, room, payload, actor)
|
|
1138
1138
|
if payload.get("kind") not in (None, "chat"):
|
|
@@ -1573,7 +1573,7 @@ def protocol_text(room):
|
|
|
1573
1573
|
"用户管理和讨论开关使用房主凭据;本地成员使用本人的 --access-file,不借用其他身份。凭据私下交付,不公开 token。API 参与者遵循相同请求与执行约束,source=api;本地 CLI 使用 source=cli。",
|
|
1574
1574
|
"runner 成员等待本机接入程序交付任务,用 execution view 核对本次执行和请求,再以 message post --as 本人 --kind chat --execution 本次编号 --access-file FILE 回答。执行失效后保留草稿,不能去掉编号改作普通发言;认领、续租与后续投递由接入程序处理。",
|
|
1575
1575
|
"manual 本地成员首次用 member register 注册,会话 ID 由 CLI 自动取得;回答用 --request 请求编号,不编造执行编号。讨论不使用原生定时;原会话不能托管时由用户触发。",
|
|
1576
|
-
"自由协作中的 host 可用 --to 点名成员,route 表示未点名的用户请求,handoff 表示派出任务已结束;链上 AI 派活受房间预算限制。--to 多位并加 --sequential
|
|
1576
|
+
"自由协作中的 host 可用 --to 点名成员,route 表示未点名的用户请求,handoff 表示派出任务已结束;链上 AI 派活受房间预算限制。--to 多位并加 --sequential 时按点名顺序依次接力,每棒一轮,最后交回主持人;--with 成员 与紧挨在前的 --to 同一棒同时做,这一组全部结束才交下一棒;--detach 成员 与这次派活同时开工但不等它(失败时通知主持人);再加 --loop 则交回为检查点,用 sequence continue --request 交接请求编号 开始下一圈,不继续即直接验收。普通成员的 --to 或正文 @ 只保存回应建议,不直接唤醒;不点名自己、不扩大授权。",
|
|
1577
1577
|
"讨论中的用户 @ 直接提问独立排队,不计讨论轮次、不与讨论发言合并。initial 阶段先独立判断;服务端按讨论请求的 read_until 限制读取。直接提问和主持任务不继承该阅读限制。",
|
|
1578
1578
|
"主持安排前先 turn roster 读取在场专家、waiting、unregistered 和 revision,再 turn create --roster revision;单项和多项都要带凭据,多项计划须覆盖本轮 waiting。继续排队同样先读 roster,再 turn continue --roster revision。状态或新消息变化后旧凭据被拒绝,重新读取再判断;每个回答保存后由程序推进下一项。",
|
|
1579
1579
|
"局部追问先 turn roster 读取 host_requests 的请求与原消息编号,再 turn ask --request ID --expert NAME --roster revision --submission-id UUID;普通提问不计轮次、不改讨论队列,可问已发言者和主持人,只结束指定请求。",
|
package/docs/en/runner.md
CHANGED
|
@@ -68,7 +68,9 @@ Once paired, the "协作房间" directory lists the rooms on the computer, the s
|
|
|
68
68
|
|
|
69
69
|
## API Access
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
Choose **OpenAI** in the member picker, flip its card, enter your official OpenAI API key and choose a model. Its default endpoint is `https://api.openai.com/v1` with the Responses protocol. Existing Codex cards still support API access.
|
|
72
|
+
|
|
73
|
+
Choose **自定义 API** (Custom API) for another service, then enter its base URL, API key and full model ID. It defaults to Chat Completions and also supports Responses, Anthropic Messages and Gemini generateContent. Use the name and description button on the card's back to set a display name; add the slot again to configure multiple independent members. Official presets for Anthropic, Google Gemini, DeepSeek, Alibaba Cloud Model Studio (Qwen) and xAI (Grok) remain available.
|
|
72
74
|
|
|
73
75
|
API members can act as experts or hosts. They have room tools only and no access to local files. Keys stay on your machine, and calls are billed to your account.
|
|
74
76
|
|
package/docs/en/workflow.md
CHANGED
|
@@ -20,7 +20,7 @@ How collaboration works inside a room. For installation, see [Setup](runner.md).
|
|
|
20
20
|
- **Mention** — `@member` and it works in its own original session; several members can run in parallel.
|
|
21
21
|
- **Unaddressed messages** go to the host. With a single member, they go straight to it.
|
|
22
22
|
- **Expert presets** — a second row under the platforms lists presets; one click adds a member with its description, prompt, project access (and optionally model and effort) set. `Codex-review` (read-only reviewer) and `Gemini-docs` (writes docs only, via agy) are built in; the bookmark on a card's back saves it as your own preset on the computer running the local service.
|
|
23
|
-
- **Host delegation** — when the host mentions one member, that member replies to you directly, unless the host asks for the work back to hand it on (for example to a reviewer); when it mentions several, the host summarizes after they finish. One message of yours lets the host run up to 10 rounds by default (a round: the host dispatches, the members answer, the work comes back for a summary); change it (1–100 rounds, or 不限 for unlimited) when creating the room, or later under 协作设置 (Collaboration settings) on the room page. At the limit it stops and waits for you; 继续 (Continue) in the side panel lets it run on. When the host knows up front how many rounds a task needs (say, several episodes in a row), it asks for them at the start: if too few are left it stops at once, the side panel shows its reason, and 批准 (Approve) lets it run through without stopping again. A one-after-another relay counts one round per full pass.
|
|
23
|
+
- **Host delegation** — when the host mentions one member, that member replies to you directly, unless the host asks for the work back to hand it on (for example to a reviewer); when it mentions several, the host summarizes after they finish. One message of yours lets the host run up to 10 rounds by default (a round: the host dispatches, the members answer, the work comes back for a summary); change it (1–100 rounds, or 不限 for unlimited) when creating the room, or later under 协作设置 (Collaboration settings) on the room page. At the limit it stops and waits for you; 继续 (Continue) in the side panel lets it run on. When the host knows up front how many rounds a task needs (say, several episodes in a row), it asks for them at the start: if too few are left it stops at once, the side panel shows its reason, and 批准 (Approve) lets it run through without stopping again. A one-after-another relay counts one round per full pass; members who do not depend on each other (say, reviewers checking different things) can share one step, working at once, and the relay moves on once all of them finish. The host can also send a member along with a dispatch without waiting for it: it starts at the same time, the dispatch comes back without it, and the host is told if it fails.
|
|
24
24
|
- **Everyone** — `@全体成员` asks all members to answer; `@全体依次` asks them one after another in speaking order.
|
|
25
25
|
- **Stop and retry** — stop a member's current run from its card or menu, or stop all running and queued work from the side panel. “Stopped” requires a confirmed process exit; otherwise the card says “Stop unconfirmed” or “Voided, may still be generating.” Late replies stay out of the transcript; only the owner can expand their text. New tasks wait for termination confirmation, a late reply or the original 10-minute deadline. Resuming a discussion or resending does not bypass or restart that wait. Retry puts the original message back in the composer for you to send.
|
|
26
26
|
- **Retract and edit** — a message no member has started can be retracted from its “…” menu, and its text returns to the input. To change a message that was already handled, click “Edit” and resend: it and everything after it fold into a “rewound” line that AI members no longer read, and the sessions of Claude Code, Codex and agy members this machine wakes go back to before it. Manual and app members keep their own windows, so their sessions cannot go back. Files members changed are not restored. Neither works while a discussion is open.
|
package/docs/runner.md
CHANGED
|
@@ -99,7 +99,9 @@ codiscuss local connect https://你的站点
|
|
|
99
99
|
|
|
100
100
|
## API 接入
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
在“添加”行选择 **OpenAI**,翻开卡片填写 OpenAI 官方 API Key 并选择模型;已预填官方地址 `https://api.openai.com/v1` 和 Responses 协议。原有 Codex 卡片仍可选择 API 接入。
|
|
103
|
+
|
|
104
|
+
选择 **自定义 API** 可接入其他服务:翻开卡片填写 Base URL、API Key 和完整模型 ID,默认使用 Chat Completions,也可选择 Responses、Anthropic Messages 或 Gemini generateContent。卡片背面的“名称与描述”按钮可修改显示名称;重复点击添加可配置多个不同的 API 成员。Anthropic、Google Gemini、DeepSeek、阿里云百炼(Qwen)、xAI(Grok)也保留官方地址预设。
|
|
103
105
|
|
|
104
106
|
API 成员可以担任专家或主持人。房间指定了项目目录时,它可以用接入程序提供的文件工具读取、搜索项目文件;项目权限设为可写时还能新建和编辑文件,但没有命令行,不能运行测试或构建。`.env`、私钥等密钥文件、`.git` 目录和本机服务目录始终不开放,读到的内容会发给你配置的模型服务商。密钥只保存在本机,调用费用按你的账号计费。
|
|
105
107
|
|
package/docs/workflow.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
- **点名**:`@成员` 后,它在自己的原会话中处理并回复,多位可并行。用界面上显示的昵称点名即可(如 `@Gemini`),输入 `@` 后从列表选择最方便。
|
|
19
19
|
- **未点名的消息**:交给主持人分派;只有一位成员时直接交给它。
|
|
20
20
|
- **主持人派活**:主持人派出的成员做完或失败后都会交回主持人,由它接着安排或汇总;简单问答主持人可以让成员直接回复你,失败时仍交回主持人。程序发现的问题(如成员执行失败)会以“程序通知主持人”卡片交给主持人处理;主持人也可以在回答后要求房间空闲时再被唤醒,自己接着推进。你的一条消息默认最多让主持人推进 10 轮(主持人派活、成员回答、交回汇总为一轮;主持人要求续跑一次也算一轮,程序通知不算),建房时在“推进方式”里或建房后在房间页的“协作设置”里调整(1–100 轮,或勾选“不限”);到上限会停下等你,右栏点“继续”即接着推进。主持人事先知道任务要多少轮(如连写几集)时,会在开始时申请轮数:剩余不够就先停下,右栏显示申请理由,点“批准”后一次跑完,不再中途停下。
|
|
21
|
-
-
|
|
21
|
+
- **依次接力与循环**:几位成员要按固定先后做(写完再审、实现后评审)时,主持人会安排依次接力:一位做完直接交给下一位,最后一位做完才交回主持人,走完一圈算一轮。需要“改完再审”时安排成循环:每走完一圈交回主持人一次,评审要求返工就再走一圈,认可或需要取舍时由主持人验收。互不依赖的几位(如分别审不同问题的评审)可以排在同一棒同时做,这一组全部做完才交给下一位。右栏发言顺序显示进行到哪一位、第几圈(同时做的成员共用一个序号)。主持人派活时还可以带上一位不必等待的成员,它同时开工,派出的工作结束时不等它,它失败时主持人会收到通知。右栏可拖动调整还没轮到的成员。循环圈数受上面的轮数限制,需要多改几轮时把轮数调大。
|
|
22
22
|
- **全体回答**:`@全体成员` 请所有成员各自回答,`@全体依次` 按发言顺序逐个回答。
|
|
23
23
|
- **停止与重发**:处理中卡片或成员菜单可停止该成员当前这一次;右栏“全部停止”停止所有成员并取消还没送达的请求。本机确认进程退出后才显示“已停止”,无法确认时显示“停止未确认”;自己窗口或 App 里的会话停不了时显示“已作废,可能仍在生成”。后续回答不进入记录,只有房主展开“未采纳的回答”时能读取正文。停止未确认或停不了时,新消息等待结束确认、迟到回答或原有 10 分钟期限;继续讨论、主动重发不跳过等待,也不重新计时。被停止的成员下次工作时会被告知。失败卡的“重发”把原用户消息填回输入框,确认后再发送。
|
|
24
24
|
- **撤回与编辑**:还没有成员开始处理的消息可撤回,原文回到输入框;最新一条消息即使已开始处理也可撤回,相当于停止对它的处理并回退到它之前。输入框为空时连按两下 Esc 也可撤回最近一条。已被处理的消息点“编辑”改写后重发:这条消息及之后的记录折叠为“已回退”,AI 不再读到它们,由本机唤醒的 Claude Code、Codex、agy 会话会退回到这条消息之前;手动接入和 App 的会话在各自窗口中,无法回退。成员已改动的文件不会撤销。讨论进行中不能撤回或编辑。
|
package/package.json
CHANGED
package/runner/adapters/api.mjs
CHANGED
|
@@ -54,8 +54,8 @@ export function participant_tools(context) {
|
|
|
54
54
|
tool('read_message', '读取指定原文;服务端按本次请求限制讨论中的可见范围', { id: NUMBER }, ['id']),
|
|
55
55
|
...file_tools(context.project), ...command_tools(context.commands, context.project)];
|
|
56
56
|
const answer = { text: { ...TEXT, maxLength: 32000 }, summary: { ...TEXT, maxLength: 1000 }, reply_to: NUMBER };
|
|
57
|
-
const submit = tool('submit_chat', '保存本次执行的完整回答并结束;summary 仅在回答较长时填写;只有主持人可用 to 点名成员,派出的工作做完或失败都会交回你(只点名一位、回答已足以直接给用户时可加 no_handoff: true,失败仍交回);to 点名多位并加 sequential: true 时按点名顺序依次接力、最后交回你,依次接力走完一圈算一轮,再加 loop: true
|
|
58
|
-
{ ...answer, ...(context.role === 'host' ? { to: { type: 'array', items: TEXT }, sequential: { type: 'boolean' }, loop: { type: 'boolean' }, no_handoff: { type: 'boolean' }, continue: { ...TEXT, maxLength: 300 },
|
|
57
|
+
const submit = tool('submit_chat', '保存本次执行的完整回答并结束;summary 仅在回答较长时填写;只有主持人可用 to 点名成员,派出的工作做完或失败都会交回你(只点名一位、回答已足以直接给用户时可加 no_handoff: true,失败仍交回);to 点名多位并加 sequential: true 时按点名顺序依次接力、最后交回你,依次接力走完一圈算一轮,再加 loop: true 则交回为检查点;parallel 列出要同一棒同时做的几组成员(每组至少两位、在 to 中相邻),这一组全部结束才交下一棒,组里有人失败则接力停下交回你;detach 列出与这次派活同时开工、但不等待的成员(不计入交回或下一棒),它失败时程序通知你;主持人要在房间空闲后接着推进时填 continue(写明继续做什么),算本链一轮;派活时可用 need_rounds(本任务从这次派活起还需要的轮数)加 rounds_reason 向用户申请轮数,超过本链剩余时任务链立即停下等用户批准',
|
|
58
|
+
{ ...answer, ...(context.role === 'host' ? { to: { type: 'array', items: TEXT }, sequential: { type: 'boolean' }, loop: { type: 'boolean' }, parallel: { type: 'array', items: { type: 'array', items: TEXT } }, detach: { type: 'array', items: TEXT }, no_handoff: { type: 'boolean' }, continue: { ...TEXT, maxLength: 300 },
|
|
59
59
|
need_rounds: { type: 'integer', minimum: 1, maximum: 100 }, rounds_reason: { ...TEXT, maxLength: 300 },
|
|
60
60
|
next_review: TEXT, review_scope: { ...TEXT, maxLength: 2000 } } : {}) }, ['text']);
|
|
61
61
|
const discussion_host = context.role === 'host' && context.discussion_request?.purpose === 'discuss_host';
|
|
@@ -144,8 +144,9 @@ function write_action(context, name, args) {
|
|
|
144
144
|
to: context.role === 'host' ? args.to || [] : [],
|
|
145
145
|
...(context.role === 'host' && typeof args.continue === 'string' && args.continue.trim() ? { continue: args.continue } : {}),
|
|
146
146
|
...(context.role === 'host' && args.no_handoff === true ? { no_handoff: true } : {}),
|
|
147
|
+
...(context.role === 'host' && Array.isArray(args.detach) && args.detach.length ? { detach: args.detach } : {}),
|
|
147
148
|
...(context.role === 'host' && Number.isInteger(args.need_rounds) ? { need_rounds: args.need_rounds, rounds_reason: args.rounds_reason } : {}),
|
|
148
|
-
...(context.role === 'host' && args.sequential === true ? { sequential: true, ...(args.loop === true ? { loop: true } : {}) } : {}),
|
|
149
|
+
...(context.role === 'host' && args.sequential === true ? { sequential: true, ...(args.loop === true ? { loop: true } : {}), ...(Array.isArray(args.parallel) && args.parallel.length ? { parallel: args.parallel } : {}) } : {}),
|
|
149
150
|
...(context.role === 'host' && args.next_review ? { next_review: args.next_review, review_scope: args.review_scope, review_roster: args.review_roster } : {}),
|
|
150
151
|
}, terminal: true };
|
|
151
152
|
const actions = {
|
|
@@ -258,7 +259,7 @@ export function system_prompt(context) {
|
|
|
258
259
|
下文是本项目的参与技能${host ? '与主持技能' : ''},与本地软件参与者使用同一份。你经本机接入程序以 API 参与,环境差异如下,与技能冲突时以本段为准:
|
|
259
260
|
- 只能使用提供的${context.project ? '房间工具和项目文件工具' : '房间工具'},没有${command_tools(context.commands, context.project).length ? '通用' : ''}命令行、浏览器、附件下载或其他软件。接入程序负责执行认领、续租和凭据管理;你的房间写入工具都绑定本次执行,不要重试结果不明的写入。
|
|
260
261
|
- 命令与工具对应:room view → read_room;message list / view → read_messages / read_message;message post --kind chat → submit_chat。${host ? 'state decision → read_decision(按任务的决策编号读取完整判断)。' : ''}${discussion && host
|
|
261
|
-
? '主持讨论工具还包括 state set → save_state;room update → update_topic;turn create --plan-file → arrange_turns;turn continue → continue_plan;turn roster → read_roster,读后 turn ask → ask_interjection;取消安排 → cancel_turn;room limits --done → finish_discussion / declare_done。' : ''}${host ? ' 派活用 to,派出的工作结束后都会交回你;房间空闲后要接着推进时填 continue;message post --sequential / --loop → submit_chat 的 sequential / loop;sequence view → read_sequence;sequence continue → continue_sequence。' : ''}${goal_host(context) ? ' goal view → read_goal;goal start → start_goal;goal done / block → finish_goal。' : ''}
|
|
262
|
+
? '主持讨论工具还包括 state set → save_state;room update → update_topic;turn create --plan-file → arrange_turns;turn continue → continue_plan;turn roster → read_roster,读后 turn ask → ask_interjection;取消安排 → cancel_turn;room limits --done → finish_discussion / declare_done。' : ''}${host ? ' 派活用 to,派出的工作结束后都会交回你;房间空闲后要接着推进时填 continue;message post --sequential / --loop / --with / --detach → submit_chat 的 sequential / loop / parallel / detach;sequence view → read_sequence;sequence continue → continue_sequence。' : ''}${goal_host(context) ? ' goal view → read_goal;goal start → start_goal;goal done / block → finish_goal。' : ''}
|
|
262
263
|
${project_text(context.project, context.commands)}
|
|
263
264
|
- ${max_requests(context) ? `本次执行最多 ${max_requests(context)} 次模型请求,每轮都计入;` : ''}互不依赖的读取或修改(如同一文件的多处 edit)在同一轮一次调用多个工具,减少模型请求次数。
|
|
264
265
|
- 必须通过写入工具保存真实结果,普通文本输出不会保存。确实无法继续时不调用写入工具直接结束,本次执行会暂停等待用户。
|