ai_co_discuss 0.2.98 → 0.2.99
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-participant/SKILL.md +1 -1
- package/.agents/skills/discuss-participant/references/free.md +1 -1
- package/backend/free_room.py +185 -4
- package/backend/room_import.py +2 -0
- package/backend/schema.sql +31 -0
- package/backend/server.py +37 -9
- package/docs/en/workflow.md +98 -96
- package/docs/workflow.md +98 -96
- package/package.json +1 -1
- package/runner/codex_app_client.mjs +13 -0
- package/runner/discussion_adapters.mjs +205 -26
- package/runner/discussion_launch.mjs +136 -15
- package/runner/discussion_models.mjs +89 -1
- package/runner/discussion_runner.mjs +16 -13
- package/runner/discussion_service.mjs +98 -19
- package/runner/expert_prompts.mjs +85 -1
- package/runner/free_runner.mjs +38 -20
- package/runner/scheduler.mjs +4 -4
- package/runner/session_cut.mjs +167 -0
- package/web/app.js +234 -32
- package/web/markdown.css +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +3506 -3097
|
@@ -35,7 +35,7 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
|
|
|
35
35
|
|
|
36
36
|
从实际任务或用户指定值核实站点、房间和身份,不从当前网页或最近聊天猜测:站点用 `--site`、进程 `DISCUSS_URL` 或凭据绑定的站点,房间始终显式指定,写入显式 `--as 本人`。任务标明 `access_env: "DISCUSS_ACCESS_FILE"` 时凭据已由进程绑定,`--access-file FILE` 可省略;否则用任务交付的 `access_file`。项目与 cwd 有值时用于本次获准资料;CLI 路径只定位工具,不把 CLI 仓库当作讨论项目或扩大文件权限。
|
|
37
37
|
|
|
38
|
-
每次 shell 调用都是一次模型往返,手动会话里还可能要用户逐条确认,所以互不依赖的只读命令放进同一条 shell
|
|
38
|
+
每次 shell 调用都是一次模型往返,手动会话里还可能要用户逐条确认,所以互不依赖的只读命令放进同一条 shell 命令依次执行。正文想好后,生成提交编号、写草稿、`message post` 放进同一条命令,草稿写成功才提交(失败重试仍用这份草稿和编号);不同的网站写入各自单独执行并核对结果。
|
|
39
39
|
|
|
40
40
|
## 阅读
|
|
41
41
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
完成后用 `message post ROOM --as 本人 --kind chat --execution 执行编号 --access-file FILE` 提交一次回答。手动接入的成员没有执行编号:从 `room view` 的 `free.requests` 找到自己的待处理请求,回答时用 `--request 请求编号` 关闭。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
任务需要多步操作时,在关键转折处(读完材料、改定方案、验证结果)用一两句中文写出进展,不另调命令:本机接入程序会把这些中途说明显示在网页的处理中卡片,让用户知道你在做什么。只写进展与判断,不复述工具输出,不写读技能、执行编号、提交命令之类的操作步骤(用户看不懂也用不上),不代替最终回答;几步就能答完的简单请求不写。
|
|
12
12
|
|
|
13
13
|
## 共享工作目录
|
|
14
14
|
|
package/backend/free_room.py
CHANGED
|
@@ -72,8 +72,13 @@ MAX_NOTE_LENGTH = 500
|
|
|
72
72
|
MAX_NOTES_PER_RENEW = 10
|
|
73
73
|
# A member description is a short note for the scheduler and the host, keyed by the member's name.
|
|
74
74
|
MAX_MEMBER_DESCRIPTION = 500
|
|
75
|
+
# A display label replaces the member's name on screen only; the name stays the member's identity everywhere else.
|
|
76
|
+
MAX_MEMBER_LABEL = 32
|
|
75
77
|
MAX_MEMBER_NAME = 80
|
|
76
78
|
MAX_PATH_LENGTH = 500
|
|
79
|
+
# Refused operations one execution may report, and the length of each field.
|
|
80
|
+
MAX_DENIALS = 20
|
|
81
|
+
MAX_DENIAL_FIELD = 300
|
|
77
82
|
ACTIVE = ("dispatched", "started")
|
|
78
83
|
ACCESS_MODES = ("runner", "manual", "online")
|
|
79
84
|
SCHEDULER_PROVIDERS = ("jev", "decisions")
|
|
@@ -267,6 +272,31 @@ def describe_members(connection, room_id, token, payload):
|
|
|
267
272
|
return {"descriptions": member_descriptions(connection, room_id)}
|
|
268
273
|
|
|
269
274
|
|
|
275
|
+
def member_labels(connection, room_id):
|
|
276
|
+
"""The owner's display labels of members by name; only screens use them, never addressing or credentials."""
|
|
277
|
+
return {row["name"]: row["label"] for row in connection.execute(
|
|
278
|
+
"SELECT name,label FROM member_labels WHERE room_id=? ORDER BY name", (room_id,))}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def label_members(connection, room_id, token, payload):
|
|
282
|
+
"""Owner sets members' display labels by name; an empty text removes that member's label."""
|
|
283
|
+
require_owner(connection, room_id, token)
|
|
284
|
+
labels = payload.get("labels")
|
|
285
|
+
if (not isinstance(labels, dict) or not labels or len(labels) > MAX_ROOM_AGENTS
|
|
286
|
+
or any(not isinstance(name, str) or not 0 < len(name) <= MAX_MEMBER_NAME or not isinstance(text, str)
|
|
287
|
+
or len(text.strip()) > MAX_MEMBER_LABEL for name, text in labels.items())):
|
|
288
|
+
raise ValueError(f"labels 须为 {{成员名: 显示名}},最多 {MAX_ROOM_AGENTS} 位,每个最多 {MAX_MEMBER_LABEL} 字")
|
|
289
|
+
begin_write(connection)
|
|
290
|
+
now = int(time.time())
|
|
291
|
+
for name, text in labels.items():
|
|
292
|
+
if text.strip():
|
|
293
|
+
connection.execute("""INSERT INTO member_labels (room_id,name,label,updated_at) VALUES (?,?,?,?)
|
|
294
|
+
ON CONFLICT(room_id,name) DO UPDATE SET label=excluded.label,updated_at=excluded.updated_at""",
|
|
295
|
+
(room_id, name, text.strip(), now))
|
|
296
|
+
else:
|
|
297
|
+
connection.execute("DELETE FROM member_labels WHERE room_id=? AND name=?", (room_id, name))
|
|
298
|
+
return {"labels": member_labels(connection, room_id)}
|
|
299
|
+
|
|
270
300
|
def note_count(connection, execution_id):
|
|
271
301
|
"""How many progress notes an execution has; the texts are read separately so room views stay small."""
|
|
272
302
|
return connection.execute("SELECT COUNT(*) FROM execution_notes WHERE execution_id=?", (execution_id,)).fetchone()[0]
|
|
@@ -312,11 +342,13 @@ def recent_executions(connection, room_id):
|
|
|
312
342
|
(SELECT json_group_array(id) FROM member_requests WHERE execution_id=e.id) AS request_ids,
|
|
313
343
|
(SELECT MAX(answer_id) FROM member_requests WHERE execution_id=e.id) AS answer_id,
|
|
314
344
|
(SELECT MIN(message_id) FROM member_requests WHERE execution_id=e.id) AS request_message_id,
|
|
315
|
-
(SELECT COUNT(*) FROM execution_notes WHERE execution_id=e.id) AS note_count
|
|
345
|
+
(SELECT COUNT(*) FROM execution_notes WHERE execution_id=e.id) AS note_count,
|
|
346
|
+
(SELECT denials FROM execution_denials WHERE execution_id=e.id) AS denials
|
|
316
347
|
FROM member_executions e WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_EXECUTIONS))
|
|
317
348
|
return [{**public_execution(row), "cwd": row["cwd"], "changes": json.loads(row["changes"] or "[]"),
|
|
318
349
|
"changes_recorded": bool(row["changes"]), "request_ids": json.loads(row["request_ids"]),
|
|
319
|
-
"answer_id": row["answer_id"], "request_message_id": row["request_message_id"], "notes": row["note_count"]
|
|
350
|
+
"answer_id": row["answer_id"], "request_message_id": row["request_message_id"], "notes": row["note_count"],
|
|
351
|
+
"denials": json.loads(row["denials"] or "[]")} for row in rows]
|
|
320
352
|
|
|
321
353
|
|
|
322
354
|
def possible_conflicts(connection, room_id, names):
|
|
@@ -1190,7 +1222,8 @@ def execution_detail(connection, row):
|
|
|
1190
1222
|
member = connection.execute("SELECT * FROM room_members WHERE id=?", (row["member_id"],)).fetchone()
|
|
1191
1223
|
requests = [dict(item) for item in connection.execute(
|
|
1192
1224
|
"SELECT id,message_id,status,purpose FROM member_requests WHERE execution_id=? ORDER BY id", (row["id"],))]
|
|
1193
|
-
return {**public_execution(row), "member": member["name"], "read_until": member["read_until"], "requests": requests
|
|
1225
|
+
return {**public_execution(row), "member": member["name"], "read_until": member["read_until"], "requests": requests,
|
|
1226
|
+
"rewound_executions": member_rewinds(connection, member["id"])}
|
|
1194
1227
|
|
|
1195
1228
|
|
|
1196
1229
|
def view_execution(connection, room_id, execution_id, token):
|
|
@@ -1216,6 +1249,17 @@ def changed_files(payload):
|
|
|
1216
1249
|
return sorted(set(files))
|
|
1217
1250
|
|
|
1218
1251
|
|
|
1252
|
+
def denied_actions(payload):
|
|
1253
|
+
"""Validate the operations a member's software refused: the tool, what it tried, and the rule that would allow it."""
|
|
1254
|
+
denials = payload.get("denials")
|
|
1255
|
+
def valid(item):
|
|
1256
|
+
return (isinstance(item, dict) and isinstance(item.get("tool"), str) and 0 < len(item["tool"]) <= 40
|
|
1257
|
+
and all(isinstance(item.get(key, ""), str) and len(item.get(key, "")) <= MAX_DENIAL_FIELD for key in ("detail", "rule")))
|
|
1258
|
+
if not isinstance(denials, list) or not 0 < len(denials) <= MAX_DENIALS or not all(map(valid, denials)):
|
|
1259
|
+
raise ValueError(f"denials 须为 1–{MAX_DENIALS} 条 {{tool, detail, rule}}")
|
|
1260
|
+
return [{key: item.get(key, "") for key in ("tool", "detail", "rule")} for item in denials]
|
|
1261
|
+
|
|
1262
|
+
|
|
1219
1263
|
def renewed_activity(payload, row, now):
|
|
1220
1264
|
"""Validate the runner's reported phase; its output time is kept within the execution, since the runner's clock may differ."""
|
|
1221
1265
|
activity, activity_at = payload.get("activity"), payload.get("activity_at")
|
|
@@ -1250,6 +1294,12 @@ def execution_action(connection, room_id, execution_id, token, payload):
|
|
|
1250
1294
|
if row["changes"]:
|
|
1251
1295
|
raise FileExistsError("本次执行的改动已记录")
|
|
1252
1296
|
connection.execute("UPDATE member_executions SET changes=? WHERE id=?", (json.dumps(changed_files(payload), ensure_ascii=False), execution_id))
|
|
1297
|
+
elif action == "denials":
|
|
1298
|
+
# Like file changes, read from the software's output once its process ends; recorded once.
|
|
1299
|
+
saved = connection.execute("INSERT OR IGNORE INTO execution_denials (execution_id,room_id,denials) VALUES (?,?,?)",
|
|
1300
|
+
(execution_id, room_id, json.dumps(denied_actions(payload), ensure_ascii=False)))
|
|
1301
|
+
if not saved.rowcount:
|
|
1302
|
+
raise FileExistsError("本次执行的拒绝记录已记录")
|
|
1253
1303
|
elif action == "segments":
|
|
1254
1304
|
# The last segments and notes arrive after the member's answer ended the execution; such notes are dated at its end.
|
|
1255
1305
|
finished = 0 if row["status"] in ACTIVE else (row["finished_at"] or 1)
|
|
@@ -1299,6 +1349,137 @@ def interrupt_member(connection, room_id, member_id, token):
|
|
|
1299
1349
|
return free_view(connection, room_id)
|
|
1300
1350
|
|
|
1301
1351
|
|
|
1352
|
+
def withdrawals(connection, room_id):
|
|
1353
|
+
"""The room's taken-back messages by id, so the page keeps them in place while AI readers skip them."""
|
|
1354
|
+
return [dict(row) for row in connection.execute("""SELECT w.message_id,w.kind FROM message_withdrawals w
|
|
1355
|
+
JOIN messages m ON m.id=w.message_id WHERE m.room_id=? ORDER BY w.message_id""", (room_id,))]
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def owner_message(connection, room_id, message_id):
|
|
1359
|
+
"""One visible message of the user in a free room without an open discussion, which a retract or rewind may take back."""
|
|
1360
|
+
row = connection.execute("SELECT * FROM messages WHERE room_id=? AND id=?", (room_id, message_id)).fetchone()
|
|
1361
|
+
if not row:
|
|
1362
|
+
raise LookupError("消息不存在")
|
|
1363
|
+
if row["author"] != "用户":
|
|
1364
|
+
raise ValueError("只能撤回或编辑用户自己的消息")
|
|
1365
|
+
if room_mode(connection, room_id) != "free":
|
|
1366
|
+
raise ValueError("只有协作房间的消息可以撤回或编辑")
|
|
1367
|
+
if connection.execute("SELECT 1 FROM room_directory WHERE room_id=? AND archived=1", (room_id,)).fetchone():
|
|
1368
|
+
raise ValueError("房间已存档;恢复后才能撤回或编辑")
|
|
1369
|
+
# A discussion's host reads the user's messages itself, with no request that shows whether it began.
|
|
1370
|
+
if connection.execute("SELECT 1 FROM discussion_sessions WHERE room_id=? AND (status IN ('active','paused') OR started_message_id>=?)",
|
|
1371
|
+
(room_id, message_id)).fetchone():
|
|
1372
|
+
raise FileExistsError("讨论进行中,或这条消息之后开启过讨论,不能撤回或编辑")
|
|
1373
|
+
return row
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def retract_message(connection, room_id, message_id, token):
|
|
1377
|
+
"""Owner takes back a message no member began to handle: its queued requests end and AI readers no longer see it.
|
|
1378
|
+
|
|
1379
|
+
Bound to one known message, so it takes no revision; a message any member started, answered or failed is refused.
|
|
1380
|
+
A member working on an earlier message may still have read it, which the room cannot know.
|
|
1381
|
+
|
|
1382
|
+
Returns:
|
|
1383
|
+
`{"message_id", "text", "duplicate"}`; the text lets the page put it back in the input for editing.
|
|
1384
|
+
"""
|
|
1385
|
+
require_owner(connection, room_id, token)
|
|
1386
|
+
begin_write(connection)
|
|
1387
|
+
message = owner_message(connection, room_id, message_id)
|
|
1388
|
+
if connection.execute("SELECT 1 FROM message_withdrawals WHERE message_id=?", (message_id,)).fetchone():
|
|
1389
|
+
return {"message_id": message_id, "text": message["text"], "duplicate": True}
|
|
1390
|
+
if connection.execute("SELECT 1 FROM member_requests WHERE message_id=? AND status IN ('delivering','completed','failed')", (message_id,)).fetchone():
|
|
1391
|
+
raise FileExistsError("已有成员开始处理这条消息,不能撤回;可以编辑这条消息回退到它之前")
|
|
1392
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE message_id=? AND status='pending'", (message_id,))
|
|
1393
|
+
connection.execute("DELETE FROM free_queue WHERE message_id=?", (message_id,))
|
|
1394
|
+
connection.execute("INSERT INTO message_withdrawals (message_id,kind,created_at) VALUES (?,'retracted',?)", (message_id, int(time.time())))
|
|
1395
|
+
settle_batches(connection, room_id)
|
|
1396
|
+
return {"message_id": message_id, "text": message["text"], "duplicate": False}
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
# Software whose session the runner can cut before a taken-back execution; an API member rebuilds its context each time.
|
|
1400
|
+
CUTTABLE_ADAPTERS = ("claude", "codex", "codex_cli", "antigravity_cli")
|
|
1401
|
+
# Executions a rewind takes back: any touching a message from the rewind point on, and any still running.
|
|
1402
|
+
REWOUND_EXECUTIONS = """SELECT e.id,e.member_id FROM member_executions e WHERE e.room_id=? AND (e.status IN ('dispatched','started')
|
|
1403
|
+
OR EXISTS(SELECT 1 FROM member_requests r WHERE r.execution_id=e.id AND (r.message_id>=? OR r.answer_id>=?))) ORDER BY e.id"""
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def rewind_plan(connection, room_id, message_id):
|
|
1407
|
+
"""What rewinding to just before a message takes back, and a revision of the state it was read from.
|
|
1408
|
+
|
|
1409
|
+
Each member reports what happens to its session: `cut` (the runner cuts it before the first taken-back execution),
|
|
1410
|
+
`fresh` (an API member, which rebuilds its context), `unchanged` (no taken-back execution) or `kept` (manual, online or
|
|
1411
|
+
App software whose own window keeps what it saw).
|
|
1412
|
+
"""
|
|
1413
|
+
owner_message(connection, room_id, message_id)
|
|
1414
|
+
if connection.execute("SELECT 1 FROM message_withdrawals WHERE message_id=?", (message_id,)).fetchone():
|
|
1415
|
+
raise FileExistsError("这条消息已被撤回或回退")
|
|
1416
|
+
executions = {}
|
|
1417
|
+
for row in connection.execute(REWOUND_EXECUTIONS, (room_id, message_id, message_id)):
|
|
1418
|
+
executions.setdefault(row["member_id"], []).append(row["id"])
|
|
1419
|
+
members = []
|
|
1420
|
+
for row in connection.execute("SELECT id,name,access,adapter FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
|
|
1421
|
+
taken = executions.get(row["id"], [])
|
|
1422
|
+
session = ("kept" if row["access"] != "runner" or row["adapter"] not in (*CUTTABLE_ADAPTERS, "api")
|
|
1423
|
+
else "unchanged" if not taken else "fresh" if row["adapter"] == "api" else "cut")
|
|
1424
|
+
members.append({"id": row["id"], "name": row["name"], "executions": taken, "session": session})
|
|
1425
|
+
count = connection.execute("""SELECT COUNT(*) FROM messages m WHERE m.room_id=? AND m.id>=?
|
|
1426
|
+
AND NOT EXISTS(SELECT 1 FROM message_withdrawals w WHERE w.message_id=m.id)""", (room_id, message_id)).fetchone()[0]
|
|
1427
|
+
latest = connection.execute("SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=?", (room_id,)).fetchone()[0]
|
|
1428
|
+
state = json.dumps([message_id, latest, count, members])
|
|
1429
|
+
return {"message_id": message_id, "messages": count, "members": members,
|
|
1430
|
+
"revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def rewind_room(connection, room_id, token, payload):
|
|
1434
|
+
"""Owner returns the room to just before one of its messages, as editing that message does.
|
|
1435
|
+
|
|
1436
|
+
Read the plan first (`GET rewind?message_id=`) and send its revision: a missing revision is refused and a changed
|
|
1437
|
+
one conflicts. The message and everything after it stay on the page but leave AI reads; running work stops, open
|
|
1438
|
+
requests on them end, members read again from before the message, and each runner member's session is cut before
|
|
1439
|
+
its first taken-back execution when it next works. Files the members changed are not restored.
|
|
1440
|
+
"""
|
|
1441
|
+
require_owner(connection, room_id, token)
|
|
1442
|
+
begin_write(connection)
|
|
1443
|
+
message_id = payload.get("message_id")
|
|
1444
|
+
if type(message_id) is not int:
|
|
1445
|
+
raise ValueError("须提供要回退到其之前的消息编号 message_id")
|
|
1446
|
+
if payload.get("revision") is None:
|
|
1447
|
+
raise ValueError("回退前须先读取回退计划(GET rewind?message_id=),并带上其 revision")
|
|
1448
|
+
plan = rewind_plan(connection, room_id, message_id)
|
|
1449
|
+
if payload["revision"] != plan["revision"]:
|
|
1450
|
+
raise FileExistsError("房间已有新的消息或执行,请重新读取回退计划后再确认")
|
|
1451
|
+
now = int(time.time())
|
|
1452
|
+
for row in connection.execute("SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started')", (room_id,)).fetchall():
|
|
1453
|
+
finish_execution(connection, row["id"], "cancelled", "用户回退了对话", "cancelled", "user_stop")
|
|
1454
|
+
record_cancel_reasons(connection, "room_id=? AND message_id>=? AND status='pending'", (room_id, message_id), "user_stop")
|
|
1455
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE room_id=? AND message_id>=? AND status='pending'", (room_id, message_id))
|
|
1456
|
+
connection.execute("DELETE FROM free_queue WHERE room_id=? AND message_id>=?", (room_id, message_id))
|
|
1457
|
+
# Batches of taken-back dispatches never hand back, and a dispatch the round limit held from them is never sent.
|
|
1458
|
+
connection.execute("UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id>=?", (room_id, message_id))
|
|
1459
|
+
connection.execute("UPDATE free_chain_holds SET message_id=0,host_id=0,names='[]',handoff=0 WHERE room_id=? AND message_id>=?", (room_id, message_id))
|
|
1460
|
+
connection.execute("""INSERT INTO message_withdrawals (message_id,kind,created_at) SELECT id,'rewound',? FROM messages m
|
|
1461
|
+
WHERE m.room_id=? AND m.id>=? AND NOT EXISTS(SELECT 1 FROM message_withdrawals w WHERE w.message_id=m.id)""", (now, room_id, message_id))
|
|
1462
|
+
connection.execute("UPDATE room_members SET read_until=MIN(read_until,?) WHERE room_id=?", (message_id - 1, room_id))
|
|
1463
|
+
cuts = {str(member["id"]): member["executions"] for member in plan["members"] if member["session"] == "cut"}
|
|
1464
|
+
cursor = connection.execute("INSERT INTO room_rewinds (room_id,message_id,cuts,created_at) VALUES (?,?,?,?)",
|
|
1465
|
+
(room_id, message_id, json.dumps(cuts), now))
|
|
1466
|
+
settle_batches(connection, room_id)
|
|
1467
|
+
return {"rewind_id": cursor.lastrowid, **plan}
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
# The runner checks this many of a member's latest rewinds before each execution.
|
|
1471
|
+
RECENT_REWINDS = 50
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def member_rewinds(connection, member_id):
|
|
1475
|
+
"""Executions taken back from one member by recent rewinds, oldest first; the runner cuts before the earliest it finds."""
|
|
1476
|
+
taken = set()
|
|
1477
|
+
for row in connection.execute("SELECT cuts FROM room_rewinds WHERE room_id=(SELECT room_id FROM room_members WHERE id=?) ORDER BY id DESC LIMIT ?",
|
|
1478
|
+
(member_id, RECENT_REWINDS)):
|
|
1479
|
+
taken.update(json.loads(row["cuts"]).get(str(member_id), []))
|
|
1480
|
+
return sorted(taken)
|
|
1481
|
+
|
|
1482
|
+
|
|
1302
1483
|
def add_member(connection, room_id, token, payload, now):
|
|
1303
1484
|
"""Owner adds a member while the room keeps running; other members are not interrupted."""
|
|
1304
1485
|
require_owner(connection, room_id, token)
|
|
@@ -1410,7 +1591,7 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
|
|
|
1410
1591
|
save_discussion_experts(connection, room_id, experts)
|
|
1411
1592
|
|
|
1412
1593
|
|
|
1413
|
-
ADOPTABLE_ADAPTERS = ("claude", "codex", "codex_cli", "antigravity_cli")
|
|
1594
|
+
ADOPTABLE_ADAPTERS = ("claude", "codex", "codex_cli", "antigravity", "antigravity_cli")
|
|
1414
1595
|
|
|
1415
1596
|
|
|
1416
1597
|
def adopt_manual_member(connection, room_id, member, payload):
|
package/backend/room_import.py
CHANGED
|
@@ -166,6 +166,8 @@ def record_statements(tables, room_id, maps):
|
|
|
166
166
|
for row in tables.get("member_requests", [])]
|
|
167
167
|
statements += [("INSERT INTO member_suggestions (message_id,member_id) VALUES (?,?)", (messages[row["message_id"]], members[row["member_id"]]))
|
|
168
168
|
for row in tables.get("member_suggestions", [])]
|
|
169
|
+
statements += [("INSERT INTO message_withdrawals (message_id,kind,created_at) VALUES (?,?,?)", (messages[row["message_id"]], row["kind"], row["created_at"]))
|
|
170
|
+
for row in tables.get("message_withdrawals", [])]
|
|
169
171
|
# Imported recommendations are history only: an open or waiting one no longer acts.
|
|
170
172
|
statements += [("""INSERT INTO member_recommendations (message_id,room_id,member_id,provider,confidence,created_at,expires_at,status,mode)
|
|
171
173
|
VALUES (?,?,?,?,?,?,?,?,?)""", (messages[row["message_id"]], room_id, members[row["member_id"]], row["provider"], row["confidence"],
|
package/backend/schema.sql
CHANGED
|
@@ -550,6 +550,14 @@ CREATE TABLE IF NOT EXISTS member_descriptions (
|
|
|
550
550
|
updated_at INTEGER NOT NULL,
|
|
551
551
|
PRIMARY KEY(room_id,name)
|
|
552
552
|
);
|
|
553
|
+
-- Display labels the owner gave members (e.g. from a named preset); screens show them, the name stays the identity.
|
|
554
|
+
CREATE TABLE IF NOT EXISTS member_labels (
|
|
555
|
+
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
|
556
|
+
name TEXT NOT NULL,
|
|
557
|
+
label TEXT NOT NULL,
|
|
558
|
+
updated_at INTEGER NOT NULL,
|
|
559
|
+
PRIMARY KEY(room_id,name)
|
|
560
|
+
);
|
|
553
561
|
-- Progress notes the runner saw a member's software write between tool calls; numbered per execution so a retried renewal adds nothing.
|
|
554
562
|
CREATE TABLE IF NOT EXISTS execution_notes (
|
|
555
563
|
execution_id INTEGER NOT NULL REFERENCES member_executions(id) ON DELETE CASCADE,
|
|
@@ -559,6 +567,13 @@ CREATE TABLE IF NOT EXISTS execution_notes (
|
|
|
559
567
|
created_at INTEGER NOT NULL,
|
|
560
568
|
PRIMARY KEY(execution_id,seq)
|
|
561
569
|
);
|
|
570
|
+
-- Operations a member's software refused without asking, since a headless run cannot ask for approval; reported once by the
|
|
571
|
+
-- runner after the process ends, as a JSON list of {tool, detail, rule}, so the owner can see and allow them.
|
|
572
|
+
CREATE TABLE IF NOT EXISTS execution_denials (
|
|
573
|
+
execution_id INTEGER PRIMARY KEY REFERENCES member_executions(id) ON DELETE CASCADE,
|
|
574
|
+
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
|
575
|
+
denials TEXT NOT NULL
|
|
576
|
+
);
|
|
562
577
|
-- Phases the runner saw a member's software go through in one execution (a free execution or a discussion event), as
|
|
563
578
|
-- millisecond offsets from its delivery; numbered per execution, and a resent segment keeps the later end. Not part of backups.
|
|
564
579
|
CREATE TABLE IF NOT EXISTS execution_segments (
|
|
@@ -646,6 +661,22 @@ CREATE TABLE IF NOT EXISTS request_cancel_reasons (
|
|
|
646
661
|
request_id INTEGER PRIMARY KEY REFERENCES member_requests(id) ON DELETE CASCADE,
|
|
647
662
|
reason TEXT NOT NULL CHECK(reason IN ('user_stop','member_away','member_removed','rerouted','no_reply','archived'))
|
|
648
663
|
);
|
|
664
|
+
-- Messages the owner took back: a retracted one no member began to handle, or the history a rewind returned to before.
|
|
665
|
+
-- AI readers no longer see them; the page keeps each in place. No room column, so it never clashes with messages.room_id in joins.
|
|
666
|
+
CREATE TABLE IF NOT EXISTS message_withdrawals (
|
|
667
|
+
message_id INTEGER PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
|
|
668
|
+
kind TEXT NOT NULL CHECK(kind IN ('retracted','rewound')),
|
|
669
|
+
created_at INTEGER NOT NULL
|
|
670
|
+
);
|
|
671
|
+
-- Each rewind and, per member id, the executions it took back (JSON {member id: [execution ids]}): before the member's next
|
|
672
|
+
-- execution the runner cuts its session before the earliest of them it finds. Not part of backups, since executions are not.
|
|
673
|
+
CREATE TABLE IF NOT EXISTS room_rewinds (
|
|
674
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
675
|
+
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
|
676
|
+
message_id INTEGER NOT NULL,
|
|
677
|
+
cuts TEXT NOT NULL,
|
|
678
|
+
created_at INTEGER NOT NULL
|
|
679
|
+
);
|
|
649
680
|
-- What produced a decision (model, threshold, prompt and package versions), so ratings stay comparable across versions.
|
|
650
681
|
CREATE TABLE IF NOT EXISTS scheduler_decision_context (
|
|
651
682
|
decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
|
package/backend/server.py
CHANGED
|
@@ -28,8 +28,8 @@ from .free_room import (add_member, after_free_message, authorize_free_write, ch
|
|
|
28
28
|
discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
|
|
29
29
|
from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions, sync_participants
|
|
30
30
|
from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member, route_held_messages
|
|
31
|
-
from .free_room import describe_members, execution_notes, member_descriptions, reorder_speakers, set_chain_rounds, speaking_order, stop_room_work
|
|
32
|
-
from .free_room import continue_chain, execution_timeline
|
|
31
|
+
from .free_room import describe_members, execution_notes, label_members, member_descriptions, member_labels, reorder_speakers, set_chain_rounds, speaking_order, stop_room_work
|
|
32
|
+
from .free_room import continue_chain, execution_timeline, retract_message, rewind_plan, rewind_room, withdrawals
|
|
33
33
|
from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
|
|
34
34
|
from .room_import import import_room, imported_attachment
|
|
35
35
|
from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
|
|
@@ -107,6 +107,7 @@ ROOM_BACKUP_QUERIES = {
|
|
|
107
107
|
"scheduler_decisions": f"{DECISION_SELECT} WHERE d.room_id=? ORDER BY d.id",
|
|
108
108
|
"topic_folders": "SELECT * FROM topic_folders WHERE id IN (SELECT folder_id FROM room_folder_assignments WHERE room_id=?)",
|
|
109
109
|
"messages": "SELECT * FROM messages WHERE room_id=? ORDER BY id",
|
|
110
|
+
"message_withdrawals": "SELECT w.* FROM message_withdrawals w JOIN messages m ON m.id=w.message_id WHERE m.room_id=? ORDER BY w.message_id",
|
|
110
111
|
"attachments": "SELECT * FROM attachments WHERE room_id=? AND message_id IS NOT NULL ORDER BY message_id,position",
|
|
111
112
|
"discussion_turns": "SELECT * FROM discussion_turns WHERE room_id=? ORDER BY id",
|
|
112
113
|
**{table: f"SELECT * FROM {table} WHERE message_id IN (SELECT id FROM messages WHERE room_id=?) ORDER BY message_id"
|
|
@@ -125,13 +126,16 @@ MESSAGE_QUERY = """SELECT messages.*, COALESCE(details.kind,'opinion') AS kind,
|
|
|
125
126
|
COALESCE(details.submitted_by, CASE WHEN messages.source IN ('cli','api','url','user')
|
|
126
127
|
THEN messages.author ELSE '用户' END) AS submitted_by, details.turn_id,
|
|
127
128
|
COALESCE(summaries.text,'') AS summary, COALESCE(summaries.author,'') AS summary_by, timing.elapsed_seconds,
|
|
128
|
-
answered.execution_id, answered.request_message_id
|
|
129
|
+
answered.execution_id, answered.request_message_id, COALESCE(withdrawn.kind,'') AS withdrawn
|
|
129
130
|
FROM messages LEFT JOIN message_details AS details ON details.message_id=messages.id
|
|
131
|
+
LEFT JOIN message_withdrawals AS withdrawn ON withdrawn.message_id=messages.id
|
|
130
132
|
LEFT JOIN message_summaries AS summaries ON summaries.message_id=messages.id
|
|
131
133
|
LEFT JOIN message_timings AS timing ON timing.message_id=messages.id
|
|
132
134
|
LEFT JOIN (SELECT answer_id, MIN(execution_id) AS execution_id, MIN(message_id) AS request_message_id
|
|
133
135
|
FROM member_requests WHERE answer_id IS NOT NULL AND execution_id IS NOT NULL GROUP BY answer_id) AS answered
|
|
134
136
|
ON answered.answer_id=messages.id"""
|
|
137
|
+
# Messages the owner took back leave AI reads; the page asks for them with withdrawn=include to keep them in place.
|
|
138
|
+
INCLUDE_WITHDRAWN = "include"
|
|
135
139
|
|
|
136
140
|
|
|
137
141
|
def room_backup(connection, room_id):
|
|
@@ -274,6 +278,8 @@ def require_room(connection, room_id):
|
|
|
274
278
|
room["held_since"] = 0
|
|
275
279
|
room["free"] = free_view(connection, room_id)
|
|
276
280
|
room["descriptions"] = member_descriptions(connection, room_id)
|
|
281
|
+
room["labels"] = member_labels(connection, room_id)
|
|
282
|
+
room["withdrawals"] = withdrawals(connection, room_id)
|
|
277
283
|
room["speaking_order"] = speaking_order(connection, room)
|
|
278
284
|
return room
|
|
279
285
|
|
|
@@ -672,13 +678,15 @@ def manage_room(connection, room_id, action, payload):
|
|
|
672
678
|
|
|
673
679
|
def delete_room_records(connection, room_id):
|
|
674
680
|
"""Remove a room's dependent records in the caller's transaction."""
|
|
675
|
-
for table in ("member_recommendations", "free_queue", "speaking_orders", "member_requests", "member_descriptions", "execution_notes", "execution_segments", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "free_handoffs", "free_settings", "free_round_limits", "free_chain_holds", "room_links",
|
|
681
|
+
for table in ("member_recommendations", "free_queue", "speaking_orders", "member_requests", "member_descriptions", "member_labels", "execution_notes", "execution_segments", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "free_handoffs", "free_settings", "free_round_limits", "free_chain_holds", "room_links",
|
|
676
682
|
"discussion_options", "round_budgets", "scheduler_decisions", "scheduler_routing", "discussion_conclusions", "discussion_sessions"):
|
|
677
683
|
if table == "scheduler_decisions":
|
|
678
684
|
for child in ("scheduler_feedback", "scheduler_decision_context", "scheduler_decision_inputs"):
|
|
679
685
|
connection.execute(f"DELETE FROM {child} WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
|
|
680
686
|
connection.execute("DELETE FROM scheduler_decision_details WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
|
|
681
687
|
connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
|
|
688
|
+
connection.execute("DELETE FROM message_withdrawals WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
|
|
689
|
+
connection.execute("DELETE FROM room_rewinds WHERE room_id=?", (room_id,))
|
|
682
690
|
connection.execute("DELETE FROM message_timings WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
|
|
683
691
|
connection.execute("DELETE FROM message_summaries WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
|
|
684
692
|
connection.execute("DELETE FROM message_details WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
|
|
@@ -891,6 +899,9 @@ def attachment_view(row):
|
|
|
891
899
|
def message_with_attachments(connection, row):
|
|
892
900
|
"""Attach the published file list to an original message."""
|
|
893
901
|
message = dict(row)
|
|
902
|
+
if message["withdrawn"] == "retracted":
|
|
903
|
+
# The owner retracted it before anyone handled it: only the page that retracted it keeps the text.
|
|
904
|
+
return {**message, "text": "", "summary": "", "suggestions": [], "attachments": []}
|
|
894
905
|
message["suggestions"] = message_suggestions(connection, message["id"])
|
|
895
906
|
message["attachments"] = [attachment_view(item) for item in connection.execute(
|
|
896
907
|
"SELECT * FROM attachments WHERE message_id=? ORDER BY position,id", (message["id"],))]
|
|
@@ -1467,6 +1478,8 @@ def message_filters(room_id, params):
|
|
|
1467
1478
|
raise ValueError("after 需为非负消息编号,不能与翻页同时使用")
|
|
1468
1479
|
clauses.append("messages.id>?")
|
|
1469
1480
|
values.append(after)
|
|
1481
|
+
if params.get("withdrawn") != INCLUDE_WITHDRAWN:
|
|
1482
|
+
clauses.append("withdrawn.message_id IS NULL")
|
|
1470
1483
|
return " AND ".join(clauses), values
|
|
1471
1484
|
|
|
1472
1485
|
|
|
@@ -1694,7 +1707,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1694
1707
|
def respond(self, body, status=200, content_type="application/json"):
|
|
1695
1708
|
"""Send a UTF-8 response that browsers and proxies must not cache."""
|
|
1696
1709
|
parts = urlsplit(self.path).path.strip("/").split("/")
|
|
1697
|
-
action = (self
|
|
1710
|
+
action = (getattr(self, "payload_cache", None) or {}).get("action")
|
|
1698
1711
|
# Timeline segments are read by the timeline page on its own schedule, so they wake no other page.
|
|
1699
1712
|
quiet_renewal = (parts[3:4] == ["events"] and action == "renew") or (parts[3:4] in (["events"], ["executions"]) and action == "segments")
|
|
1700
1713
|
if (self.command == "POST" and 200 <= status < 300 and parts[:2] in (["api", "rooms"], ["api", "folders"])
|
|
@@ -1854,7 +1867,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1854
1867
|
"""Forward authenticated browser requests to the shared Node entrypoint and pin their website."""
|
|
1855
1868
|
site = self.local_request_site(method)
|
|
1856
1869
|
route = parts[2:]
|
|
1857
|
-
if method == "OPTIONS" and route in (["settings"], ["launch"], ["room-models"], ["room-member"], ["room-member-access"], ["room-member-adapter"], ["room-profiles"], ["room-restore"], ["models"], ["api-key"], ["api-check"],
|
|
1870
|
+
if method == "OPTIONS" and route in (["settings"], ["launch"], ["room-models"], ["room-member"], ["room-member-access"], ["room-member-allow"], ["room-member-adapter"], ["room-profiles"], ["room-restore"], ["room-fork"], ["models"], ["api-key"], ["api-check"],
|
|
1858
1871
|
["scheduler-check"], ["pick-directory"], ["projects"], ["presets"], ["machine-link"], ["admin-account"]):
|
|
1859
1872
|
return self.respond({"ok": True})
|
|
1860
1873
|
if route == ["settings"] and method in ("GET", "POST"):
|
|
@@ -1873,9 +1886,9 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1873
1886
|
command = "api-check"
|
|
1874
1887
|
elif route == ["scheduler-check"] and method == "POST":
|
|
1875
1888
|
command = "scheduler-check"
|
|
1876
|
-
elif route in (["room-member"], ["room-member-access"], ["room-member-adapter"]) and method == "POST":
|
|
1889
|
+
elif route in (["room-member"], ["room-member-access"], ["room-member-allow"], ["room-member-adapter"]) and method == "POST":
|
|
1877
1890
|
command = route[0]
|
|
1878
|
-
elif route in (["room-profiles"], ["room-restore"]) and method == "POST":
|
|
1891
|
+
elif route in (["room-profiles"], ["room-restore"], ["room-fork"]) and method == "POST":
|
|
1879
1892
|
command = route[0]
|
|
1880
1893
|
elif route == ["pick-directory"] and method == "POST":
|
|
1881
1894
|
command = "pick-directory"
|
|
@@ -1894,7 +1907,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1894
1907
|
if self.headers.get_content_type() != "application/json":
|
|
1895
1908
|
raise ValueError("本机操作须使用 application/json")
|
|
1896
1909
|
payload = self.read_payload()
|
|
1897
|
-
if command in ("start", "room-config", "room-models", "room-member", "room-member-access", "room-member-adapter", "room-profiles", "room-restore"):
|
|
1910
|
+
if command in ("start", "room-config", "room-models", "room-member", "room-member-access", "room-member-allow", "room-member-adapter", "room-profiles", "room-restore", "room-fork"):
|
|
1898
1911
|
payload.pop("access_file", None)
|
|
1899
1912
|
payload.update(site=site, entry="web")
|
|
1900
1913
|
if command == "machine-link":
|
|
@@ -2070,6 +2083,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
2070
2083
|
result = describe_members(connection, room_id, token, self.read_payload())
|
|
2071
2084
|
connection.commit()
|
|
2072
2085
|
return self.respond(result)
|
|
2086
|
+
if parts[3:] == ["labels"] and method == "POST":
|
|
2087
|
+
result = label_members(connection, room_id, token, self.read_payload())
|
|
2088
|
+
connection.commit()
|
|
2089
|
+
return self.respond(result)
|
|
2073
2090
|
if parts[3:] == ["budget"] and method == "POST":
|
|
2074
2091
|
result = set_chain_rounds(connection, room_id, token, self.read_payload())
|
|
2075
2092
|
connection.commit()
|
|
@@ -2093,6 +2110,17 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
2093
2110
|
result = require_room(connection, room_id)
|
|
2094
2111
|
connection.commit()
|
|
2095
2112
|
return self.respond(result)
|
|
2113
|
+
if len(parts) == 6 and parts[3] == "messages" and parts[5] == "retract" and method == "POST":
|
|
2114
|
+
result = retract_message(connection, room_id, int(parts[4]), token)
|
|
2115
|
+
connection.commit()
|
|
2116
|
+
return self.respond(result)
|
|
2117
|
+
if parts[3:] == ["rewind"]:
|
|
2118
|
+
if method == "GET":
|
|
2119
|
+
require_owner(connection, room_id, token)
|
|
2120
|
+
return self.respond(rewind_plan(connection, room_id, int(params.get("message_id", 0))))
|
|
2121
|
+
result = rewind_room(connection, room_id, token, self.read_payload())
|
|
2122
|
+
connection.commit()
|
|
2123
|
+
return self.respond(result)
|
|
2096
2124
|
free = room_mode(connection, room_id) == "free"
|
|
2097
2125
|
# A discussion keeps the local service's inbox and per-member executions for the user's direct questions.
|
|
2098
2126
|
if parts[3:4] in (["inbox"], ["executions"]) or (free and parts[3:4] in (["members"], ["suggestions"], ["recommendations"])):
|