ai_co_discuss 0.2.67 → 0.2.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/discuss-host/SKILL.md +126 -126
- package/.agents/skills/discuss-host/references/manual.md +1 -1
- package/README.md +185 -185
- package/backend/free_room.py +41 -1
- package/backend/runtime.py +1 -1
- package/backend/schema.sql +14 -0
- package/backend/server.py +62 -35
- package/docs/architecture.md +277 -276
- package/docs/cli.md +196 -196
- package/docs/deployment.md +225 -225
- package/docs/runner.md +454 -451
- package/docs/workflow.md +6 -6
- package/package.json +1 -1
- package/runner/api_participant.mjs +230 -230
- package/runner/discuss.py +991 -991
- package/runner/discussion_service.mjs +3 -0
- package/runner/free_runner.mjs +9 -2
- package/web/app.js +3370 -3376
- package/web/modern.css +1 -1
- package/web/modern.js +985 -1010
package/backend/server.py
CHANGED
|
@@ -25,7 +25,7 @@ from .free_room import (add_member, after_free_message, authorize_free_write, ch
|
|
|
25
25
|
inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
|
|
26
26
|
discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
|
|
27
27
|
from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions, sync_participants
|
|
28
|
-
from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member
|
|
28
|
+
from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member, route_held_messages
|
|
29
29
|
from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
|
|
30
30
|
from .room_import import import_room, imported_attachment
|
|
31
31
|
from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
|
|
@@ -82,6 +82,7 @@ MAX_DECISION_CONTEXT = 500
|
|
|
82
82
|
DECISION_SELECT = """SELECT d.*,f.rating AS feedback,COALESCE(f.should_be,'') AS should_be,COALESCE(f.note,'') AS note,c.context
|
|
83
83
|
FROM scheduler_decisions d LEFT JOIN scheduler_feedback f ON f.decision_id=d.id LEFT JOIN scheduler_decision_context c ON c.decision_id=d.id"""
|
|
84
84
|
MAX_DECISION_LABEL = 200
|
|
85
|
+
ROUTING_VISIBLE_SECONDS = 120
|
|
85
86
|
MAX_DECISION_DETAIL = 4000
|
|
86
87
|
SCHEDULER_PROVIDERS = ("jev", "decisions")
|
|
87
88
|
ROOM_BACKUP_QUERIES = {
|
|
@@ -235,6 +236,8 @@ def require_room(connection, room_id):
|
|
|
235
236
|
"SELECT decision_id,detail FROM scheduler_decision_details WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))}
|
|
236
237
|
for decision in room["scheduler_decisions"]:
|
|
237
238
|
decision["detail"] = details.get(decision["id"])
|
|
239
|
+
room["scheduler_routing"] = [row[0] for row in connection.execute(
|
|
240
|
+
"SELECT message_id FROM scheduler_routing WHERE room_id=? AND created_at>? ORDER BY message_id", (room_id, int(time.time()) - ROUTING_VISIBLE_SECONDS))]
|
|
238
241
|
room["host_arrangements"] = host_arrangements(connection, room_id, room["scheduler_decisions"])
|
|
239
242
|
room["cancelled_turns"] = cancelled_turns(connection, room_id)
|
|
240
243
|
parent = connection.execute("""SELECT rooms.id,rooms.title FROM room_links JOIN rooms ON rooms.id=room_links.parent_id
|
|
@@ -488,6 +491,8 @@ def update_topic(connection, room_id, payload):
|
|
|
488
491
|
connection.execute("UPDATE rooms SET title=?,goal=? WHERE id=?", (settings["title"], settings["goal"], room_id))
|
|
489
492
|
sync_discussion_members(connection, room_id, payload, settings)
|
|
490
493
|
save_topic_settings(connection, room_id, settings)
|
|
494
|
+
if settings["phase"] == "concluded" and room["phase"] != "concluded" and room["mode"] != "free" and not room["archived"]:
|
|
495
|
+
return_to_free(connection, {**room, "host": settings["host"]})
|
|
491
496
|
return require_room(connection, room_id)
|
|
492
497
|
|
|
493
498
|
|
|
@@ -629,8 +634,8 @@ def manage_room(connection, room_id, action, payload):
|
|
|
629
634
|
|
|
630
635
|
def delete_room_records(connection, room_id):
|
|
631
636
|
"""Remove a room's dependent records in the caller's transaction."""
|
|
632
|
-
for table in ("member_recommendations", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
|
|
633
|
-
"discussion_options", "round_budgets", "scheduler_decisions", "discussion_sessions"):
|
|
637
|
+
for table in ("member_recommendations", "free_queue", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
|
|
638
|
+
"discussion_options", "round_budgets", "scheduler_decisions", "scheduler_routing", "discussion_sessions"):
|
|
634
639
|
if table == "scheduler_decisions":
|
|
635
640
|
for child in ("scheduler_feedback", "scheduler_decision_context"):
|
|
636
641
|
connection.execute(f"DELETE FROM {child} WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
|
|
@@ -773,13 +778,20 @@ def end_discussion(connection, room_id, token, payload):
|
|
|
773
778
|
room = require_room(connection, room_id)
|
|
774
779
|
if room["mode"] == "free":
|
|
775
780
|
raise FileExistsError("房间没有进行中的讨论")
|
|
776
|
-
|
|
777
|
-
|
|
781
|
+
return_to_free(connection, room)
|
|
782
|
+
return require_room(connection, room_id)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def return_to_free(connection, room):
|
|
786
|
+
"""End the discussion in place: stop its run and turns, switch back to free collaboration and route held user messages."""
|
|
787
|
+
room_id = room["id"]
|
|
788
|
+
connection.execute("""UPDATE discussion_runtime SET status='stopped',generation=generation+1,revision=revision+1
|
|
789
|
+
WHERE room_id=? AND status IN ('running','ready','paused')""", (room_id,))
|
|
778
790
|
connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND status IN ('pending','notified')", (room_id,))
|
|
779
791
|
connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND status='queued'", (room_id,))
|
|
780
792
|
connection.execute("UPDATE topic_settings SET phase='concluded' WHERE room_id=?", (room_id,))
|
|
781
793
|
connection.execute("UPDATE room_modes SET mode='free' WHERE room_id=?", (room_id,))
|
|
782
|
-
|
|
794
|
+
route_held_messages(connection, room_id, room["host"])
|
|
783
795
|
|
|
784
796
|
|
|
785
797
|
def message_values(payload):
|
|
@@ -1040,6 +1052,10 @@ def append_message(connection, room_id, payload, token="", free_actor=None):
|
|
|
1040
1052
|
begin_write(connection)
|
|
1041
1053
|
room = require_room(connection, room_id)
|
|
1042
1054
|
if room["mode"] == "free" or free_actor is not None:
|
|
1055
|
+
previous = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND submission_id=?", (room_id, submission_id)).fetchone()
|
|
1056
|
+
# An exact retry of a discussion message saved before the discussion ended keeps its original result.
|
|
1057
|
+
if previous and previous["kind"] != "chat" and (previous["author"], previous["text"]) == (author, text):
|
|
1058
|
+
return {"message": message_with_attachments(connection, previous), "duplicate": True}
|
|
1043
1059
|
return append_free_message(connection, room, payload, free_actor)
|
|
1044
1060
|
details = message_details(room, {**payload, "author": author})
|
|
1045
1061
|
# An expert's opinion answers the arrangement open for that expert; naming it with turn_id is optional.
|
|
@@ -1214,6 +1230,20 @@ def rate_scheduler_decision(connection, room_id, decision_id, token, payload):
|
|
|
1214
1230
|
return {"decision_id": decision_id, "feedback": rating, "should_be": should_be.strip(), "note": note.strip()}
|
|
1215
1231
|
|
|
1216
1232
|
|
|
1233
|
+
def mark_scheduler_routing(connection, room_id, token, payload):
|
|
1234
|
+
"""The local service marks a message while the decision model is analysing it, and clears the mark when done."""
|
|
1235
|
+
begin_write(connection)
|
|
1236
|
+
require_owner(connection, room_id, token)
|
|
1237
|
+
message_id, active = payload.get("message_id"), payload.get("active")
|
|
1238
|
+
if type(message_id) is not int or message_id <= 0 or type(active) is not bool:
|
|
1239
|
+
raise ValueError("须提供正整数 message_id 与布尔 active")
|
|
1240
|
+
if active:
|
|
1241
|
+
connection.execute("INSERT OR REPLACE INTO scheduler_routing (room_id,message_id,created_at) VALUES (?,?,?)", (room_id, message_id, int(time.time())))
|
|
1242
|
+
else:
|
|
1243
|
+
connection.execute("DELETE FROM scheduler_routing WHERE room_id=? AND message_id=?", (room_id, message_id))
|
|
1244
|
+
return {"message_id": message_id, "active": active}
|
|
1245
|
+
|
|
1246
|
+
|
|
1217
1247
|
def record_scheduler_decision(connection, room_id, token, payload, owner=False):
|
|
1218
1248
|
"""The dispatcher records one smart-scheduling decision so the transcript shows what it did and how sure it was.
|
|
1219
1249
|
|
|
@@ -1263,27 +1293,24 @@ def continue_turns(connection, room_id, payload):
|
|
|
1263
1293
|
connection.execute("UPDATE discussion_turns SET status='pending' WHERE id=? AND status='queued'", (turn_id,))
|
|
1264
1294
|
return {"turn": get_turn(connection, room_id, turn_id), "queued": room["queued_turns"][1:]}
|
|
1265
1295
|
|
|
1266
|
-
def
|
|
1267
|
-
"""User-only
|
|
1296
|
+
def reopen_limits(connection, room_id, payload):
|
|
1297
|
+
"""User-only: reopen a concluded discussion that has not returned to free collaboration."""
|
|
1268
1298
|
begin_write(connection)
|
|
1269
1299
|
room = require_room(connection, room_id)
|
|
1270
1300
|
if payload.get("actor") != "用户":
|
|
1271
|
-
raise ValueError("
|
|
1301
|
+
raise ValueError("仅用户可以重启已收束的讨论")
|
|
1272
1302
|
if room["archived"]:
|
|
1273
1303
|
raise ValueError("请先恢复已存档房间")
|
|
1304
|
+
if room["mode"] == "free":
|
|
1305
|
+
raise ValueError("房间已回到自由协作;需要讨论时请重新开启讨论")
|
|
1274
1306
|
if not room["round_budget"]:
|
|
1275
|
-
raise ValueError("
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
raise ValueError("轮次须为整数(追加 1–100,重启 0–100,0 表示不限)")
|
|
1307
|
+
raise ValueError("该房间沿用旧轮次计数,不支持重启")
|
|
1308
|
+
if room["phase"] != "concluded":
|
|
1309
|
+
raise ValueError("仅已收束的讨论可以重启")
|
|
1310
|
+
rounds = payload.get("max_rounds", room["limits"]["max_rounds"])
|
|
1311
|
+
if type(rounds) is not int or not 0 <= rounds <= 100:
|
|
1312
|
+
raise ValueError("轮次须为 0–100 的整数,0 表示不限")
|
|
1282
1313
|
count = opinion_count(connection, room_id, room["experts"])
|
|
1283
|
-
if action == "extend":
|
|
1284
|
-
connection.execute("UPDATE round_budgets SET base=?,cap=?,extra=extra+? WHERE room_id=?", (count, rounds, rounds, room_id))
|
|
1285
|
-
connection.execute("UPDATE discussion_limits SET stop_reason='',revision=revision+1 WHERE room_id=?", (room_id,))
|
|
1286
|
-
return require_room(connection, room_id)
|
|
1287
1314
|
limits = limit_values({"max_rounds": rounds, "max_minutes": payload.get("max_minutes", room["limits"]["max_minutes"])}, room["limits"])
|
|
1288
1315
|
connection.execute("UPDATE round_budgets SET base=?,cap=0,extra=0 WHERE room_id=?", (count, room_id))
|
|
1289
1316
|
connection.execute("""UPDATE discussion_limits SET max_rounds=?,max_minutes=?,started_at=0,stop_reason='',revision=revision+1
|
|
@@ -1294,29 +1321,27 @@ def adjust_limits(connection, room_id, payload, action):
|
|
|
1294
1321
|
|
|
1295
1322
|
|
|
1296
1323
|
def check_limits(connection, room_id, payload):
|
|
1297
|
-
"""
|
|
1324
|
+
"""End the discussion at a reached boundary (or once the host declares the goal done); the room returns to free collaboration."""
|
|
1298
1325
|
action = payload.get("action", "check")
|
|
1299
|
-
if action not in ("check", "done", "
|
|
1300
|
-
raise ValueError("action 须为 check/done/
|
|
1301
|
-
if action
|
|
1302
|
-
return
|
|
1326
|
+
if action not in ("check", "done", "reopen"):
|
|
1327
|
+
raise ValueError("action 须为 check/done/reopen")
|
|
1328
|
+
if action == "reopen":
|
|
1329
|
+
return reopen_limits(connection, room_id, payload)
|
|
1303
1330
|
begin_write(connection)
|
|
1304
1331
|
room = require_room(connection, room_id)
|
|
1305
1332
|
require_host(room, payload)
|
|
1306
1333
|
limits = room["limits"]
|
|
1307
1334
|
done = action == "done"
|
|
1308
|
-
inactive = room["phase"] in ("paused", "concluded") or room["archived"]
|
|
1335
|
+
inactive = (room["mode"] == "free" or room["phase"] in ("paused", "concluded") or room["archived"]
|
|
1336
|
+
or (done and room["phase"] == "preparing"))
|
|
1309
1337
|
if (not limits["reached"] and not done) or inactive:
|
|
1310
1338
|
return room
|
|
1311
1339
|
if done:
|
|
1312
1340
|
require_conclusion_ready(connection, room, payload, allow_queued=True)
|
|
1313
1341
|
reason = "goal_done" if done else limits["reason"] or "user_stop"
|
|
1314
1342
|
connection.execute("UPDATE discussion_limits SET stop_reason=?,revision=revision+1 WHERE room_id=?", (reason, room_id))
|
|
1315
|
-
|
|
1316
|
-
connection
|
|
1317
|
-
connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=?", (room_id,))
|
|
1318
|
-
# Pausing at the limit also pauses the run, so the board offers restart and the old execution is superseded.
|
|
1319
|
-
connection.execute("UPDATE discussion_runtime SET status='paused',generation=generation+1,revision=revision+1 WHERE room_id=? AND status='running'", (room_id,))
|
|
1343
|
+
# The run's generation moves on, so the old execution is superseded; unnamed messages now go to the host as usual.
|
|
1344
|
+
return_to_free(connection, room)
|
|
1320
1345
|
return require_room(connection, room_id)
|
|
1321
1346
|
|
|
1322
1347
|
|
|
@@ -1443,7 +1468,7 @@ def room_text(connection, room_id, params):
|
|
|
1443
1468
|
f"主持人:{room['host']};阶段:{PHASES[room['phase']]};当前焦点:{room['focus'] or '尚未填写'}",
|
|
1444
1469
|
f"当前记录截止 #{room['latest_id']};共 {room['message_count']} 条;已完成 {room['completed_rounds']} 轮讨论(所有参与专家各发言一次)。"]
|
|
1445
1470
|
limits = room["limits"]
|
|
1446
|
-
content.append(f"讨论上限:{limits['max_rounds'] or '不限'} 轮;总时长 {limits['max_minutes'] or '不限'} 分钟;单次自动执行 {str(limits['execution_minutes']) + ' 分钟' if limits['execution_minutes'] else '不限时长'}
|
|
1471
|
+
content.append(f"讨论上限:{limits['max_rounds'] or '不限'} 轮;总时长 {limits['max_minutes'] or '不限'} 分钟;单次自动执行 {str(limits['execution_minutes']) + ' 分钟' if limits['execution_minutes'] else '不限时长'};到限后讨论结束,房间回到自由协作。到限:{limits['reached']}。")
|
|
1447
1472
|
if room["mode"] == "free":
|
|
1448
1473
|
members = ";".join(f"{item['name']}(已读至 #{item['read_until']},待投递 {item['pending']})" for item in room["free"]["members"])
|
|
1449
1474
|
content.append(f"自由协作房间:无发言权、安排与轮次。成员:{members or '无'}。用户的消息点名成员后生成请求;成员回答须带本次执行编号,执行结束、被打断或过期后的回答会被拒绝,不能改作普通发言。")
|
|
@@ -1526,7 +1551,7 @@ def protocol_text(room):
|
|
|
1526
1551
|
"API 参与者由本机 runner 使用相同房间授权和执行约束提交,消息 source=api;CLI 仍使用 source=cli。兼容字段 experts 中的 cli 表示可自行提交,实际接入方式见成员 adapter。",
|
|
1527
1552
|
"写入使用 POST,通常为 Content-Type: application/json;附件上传使用 application/octet-stream 原始字节。主持操作填写 actor 为当前主持人或用户;这些是声明身份,不是认证凭据。",
|
|
1528
1553
|
f"附件:POST {base}/attachments?actor=身份&file_name=文件名&submission_id=新编号,原文件最多 20 MiB;GET 同路径默认列已发送附件,加 submission_id 精确核对上传。GET {base}/attachments/ID 读元数据,/ID/content 下载原文件。消息可带 attachment_ids(最多 10 个有序编号);只关联同房间、同提交者的未使用附件,可只有附件无正文。自动任务须在回答完成前上传;消息查询包含附件,按需下载后由当前软件读取,不自动解析。",
|
|
1529
|
-
"讨论上限:room.limits 返回轮次、总时长、单次执行和实际进度。准备/暂停时通过 topic 的 limits 对象修改;0 表示不预设轮次/总时长/单次执行时限。POST /api/rooms/ROOM/limits(actor)或 room limits ROOM --check
|
|
1554
|
+
"讨论上限:room.limits 返回轮次、总时长、单次执行和实际进度。准备/暂停时通过 topic 的 limits 对象修改;0 表示不预设轮次/总时长/单次执行时限。POST /api/rooms/ROOM/limits(actor)或 room limits ROOM --check 检查到限处理;到限后讨论结束并停止新专家安排与观点,房间回到自由协作;暂停期间不计时也不消耗总时长。",
|
|
1530
1555
|
"运行方式:room.runtime 显示 manual/automatic、状态及真实执行回执。所有房间写入须带 X-Discussion-Token,CLI 用 --access-file 读取私有文件。用户及运行控制使用房主凭据,手动本地成员使用自己的成员凭证,不能相互冒充;在线成员只读,由房主或讨论主持人代发。自动讨论先 execution enter,创建安排或保存关联回答即完成本步,不创建原生定时任务;本机唤醒成员的持久凭证不能替代自动执行,手动接入的专家用自己的成员凭证回答安排,暂停、停止、换代、租约到期后的旧执行失效。run view/start/pause/stop/configure 字段查 CLI schema。",
|
|
1531
1556
|
"没有发言权:同一话题同时只有一个未完成的发言安排,被安排的专家直接回答它;专家观点自动回答该专家当前有效的安排,turn_id 可省略(填写则须与之相符),没有轮到你时被拒绝。主持发言(moderation)只能由当前主持人署名,任何时候都可发出;创建、取消安排由主持人或用户操作。用户的 @ 直接提问不占安排也不计轮次。失败的写入不会改变安排状态,同编号重试沿用原内容。",
|
|
1532
1557
|
'创建话题:POST /api/rooms,携带房主控制凭据;字段 title、goal、user_request、background、boundaries、host、experts、members、submission_id(新 UUID)。members 与 experts 名单一致,各项含 name、独立 token、access(手动本地 manual、自动 runner、在线 online)。AI 主持人必须为本地可写成员。CLI room create --access-file 私有房主文件 在请求前保存凭据,确认后返回各本地成员文件路径;同编号重试沿用原资料和全部凭据。名单、主持职责变更仅房主可做。',
|
|
@@ -1933,8 +1958,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1933
1958
|
return self.respond(result)
|
|
1934
1959
|
if parts[3:4] == ["attachments"]:
|
|
1935
1960
|
return self.handle_attachments(connection, parts, params, method)
|
|
1936
|
-
if parts[3:4] in (["runtime"], ["events"], ["scheduler-decisions"]):
|
|
1937
|
-
if free and method == "POST" and parts[3:4]
|
|
1961
|
+
if parts[3:4] in (["runtime"], ["events"], ["scheduler-decisions"], ["scheduler-routing"]):
|
|
1962
|
+
if free and method == "POST" and parts[3:4] not in (["scheduler-decisions"], ["scheduler-routing"]):
|
|
1938
1963
|
raise ValueError("自由房间不使用讨论运行控制")
|
|
1939
1964
|
return self.handle_runtime_api(connection, parts, params, method)
|
|
1940
1965
|
free_actor = None
|
|
@@ -2065,6 +2090,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
2065
2090
|
result = event_action(connection, room_id, int(parts[4]), token, self.read_payload())
|
|
2066
2091
|
elif parts[3:] == ["scheduler-decisions"] and method == "POST":
|
|
2067
2092
|
result = record_scheduler_decision(connection, room_id, token, self.read_payload(), owner=room_mode(connection, room_id) == "free")
|
|
2093
|
+
elif parts[3:] == ["scheduler-routing"] and method == "POST":
|
|
2094
|
+
result = mark_scheduler_routing(connection, room_id, token, self.read_payload())
|
|
2068
2095
|
elif len(parts) == 5 and parts[3] == "scheduler-decisions" and method == "POST":
|
|
2069
2096
|
result = rate_scheduler_decision(connection, room_id, int(parts[4]), token, self.read_payload())
|
|
2070
2097
|
else:
|