ai_co_discuss 0.2.63 → 0.2.65

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.
@@ -223,6 +223,11 @@ def possible_conflicts(connection, room_id, names):
223
223
  return conflicts
224
224
 
225
225
 
226
+ # An invited manual member that has not registered counts as absent: it receives no request and is never a target.
227
+ REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM member_registrations r
228
+ WHERE r.member_id=room_members.id AND r.token_hash=room_members.token_hash))"""
229
+
230
+
226
231
  def free_view(connection, room_id):
227
232
  """Summarize members, their live execution, recent requests and possible file conflicts."""
228
233
  now = int(time.time())
@@ -230,6 +235,8 @@ def free_view(connection, room_id):
230
235
  for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
231
236
  member = {key: row[key] for key in MEMBER_FIELDS}
232
237
  member["registered_at"] = registered_at(connection, row)
238
+ reported = connection.execute("SELECT session_id,client FROM member_sessions WHERE member_id=?", (row["id"],)).fetchone()
239
+ member["reported_session"], member["reported_client"] = (reported["session_id"], reported["client"]) if reported else ("", "")
233
240
  latest = connection.execute("SELECT * FROM member_executions WHERE member_id=? ORDER BY id DESC LIMIT 1",
234
241
  (row["id"],)).fetchone()
235
242
  member["execution"] = public_execution(latest)
@@ -240,7 +247,7 @@ def free_view(connection, room_id):
240
247
  members.append(member)
241
248
  names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
242
249
  requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
243
- """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by FROM member_requests
250
+ """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at FROM member_requests
244
251
  WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
245
252
  chains = [dict(row) for row in connection.execute(
246
253
  "SELECT chain_id,reason FROM free_chains WHERE room_id=? ORDER BY chain_id DESC LIMIT 10", (room_id,))]
@@ -307,9 +314,10 @@ def authorize_free_write(connection, room_id, token, operation, payload):
307
314
  return require_member(connection, room_id, token, author)
308
315
 
309
316
 
310
- def active_members(connection, room_id):
311
- """Map active member names to their rows."""
312
- return {row["name"]: row for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status='active'", (room_id,))}
317
+ def active_members(connection, room_id, registered_only=True):
318
+ """Map active member names to their rows; unregistered manual members are left out unless a discussion start needs them pending."""
319
+ return {row["name"]: row for row in connection.execute(
320
+ f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND ({REGISTERED_MEMBER} OR ?=0)", (room_id, int(registered_only)))}
313
321
 
314
322
 
315
323
  def named_members(active, names, exclude=None):
@@ -593,7 +601,7 @@ def asks_view(connection, room_id):
593
601
  """Direct questions of a discussion room: recent requests with their member, for status badges beside messages."""
594
602
  names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
595
603
  requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
596
- """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by FROM member_requests
604
+ """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at FROM member_requests
597
605
  WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
598
606
  return {"requests": requests[::-1], "executions": recent_executions(connection, room_id), "execution_minutes": execution_minutes(connection, room_id)}
599
607
 
@@ -636,7 +644,7 @@ def inbox(connection, room_id, token):
636
644
  archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
637
645
  members, observers = [], []
638
646
  # Manual and online members are never delivered to; the runner only reads them so smart scheduling can route to them too.
639
- for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status='active' ORDER BY id", (room_id,)):
647
+ for row in connection.execute(f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id", (room_id,)):
640
648
  pending = [dict(item) for item in connection.execute(
641
649
  "SELECT id,message_id,purpose FROM member_requests WHERE member_id=? AND status='pending' ORDER BY id", (row["id"],))]
642
650
  (members if row["access"] == "runner" else observers).append({**{key: row[key] for key in MEMBER_FIELDS}, "pending": pending})
@@ -663,8 +671,8 @@ def recommend_member(connection, room_id, token, payload):
663
671
  route = host_route_request(connection, message_id) if type(message_id) is int else None
664
672
  if not route or route["room_id"] != room_id:
665
673
  raise LookupError("只能为交给主持人的未点名消息给出推荐")
666
- target = connection.execute("""SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active'
667
- AND id<>?""", (room_id, payload.get("member_id"), route["member_id"])).fetchone()
674
+ target = connection.execute(f"""SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active'
675
+ AND {REGISTERED_MEMBER} AND id<>?""", (room_id, payload.get("member_id"), route["member_id"])).fetchone()
668
676
  if not target:
669
677
  raise LookupError("推荐对象须为在场的其他成员")
670
678
  now = int(time.time())
@@ -698,8 +706,8 @@ def route_wide(connection, room_id, message_id, action):
698
706
  else:
699
707
  # The host keeps its row (one request per message and member) and now answers its own share, without dispatching.
700
708
  connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (route["id"],))
701
- connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
702
- SELECT room_id,id,?,?,'all','user',? FROM room_members WHERE room_id=? AND status='active' ORDER BY id""",
709
+ connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
710
+ SELECT room_id,id,?,?,'all','user',? FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id""",
703
711
  (message_id, message_id, int(time.time()), room_id))
704
712
  requests = connection.execute("SELECT * FROM member_requests WHERE message_id=? ORDER BY id", (message_id,)).fetchall()
705
713
  return {"action": action, "requests": [dict(row) for row in requests]}
@@ -982,8 +990,8 @@ def adopt_manual_member(connection, room_id, member, payload):
982
990
  raise ValueError("成员尚未注册,不能转为托管")
983
991
  if not reported or reported["session_id"] != payload.get("session_id") or payload.get("adapter") not in ADOPTABLE_ADAPTERS:
984
992
  raise ValueError("成员尚未上报与之相符的会话,不能转为托管")
985
- if member["access"] != "manual" or member["status"] != "active" or not run or run["mode"] != "automatic":
986
- raise ValueError("只有全自动讨论中在场的手动专家可以转为托管")
993
+ if member["access"] != "manual" or member["status"] != "active" or not (room_mode(connection, room_id) == "free" or (run and run["mode"] == "automatic")):
994
+ raise ValueError("只有自由协作或全自动讨论中在场的手动专家可以转为托管")
987
995
  connection.execute("UPDATE room_members SET access='runner',adapter=?,session_mode='borrowed' WHERE id=?", (payload["adapter"], member["id"]))
988
996
  connection.execute("DELETE FROM member_sessions WHERE member_id=?", (member["id"],))
989
997
 
@@ -1016,6 +1024,8 @@ def member_action(connection, room_id, member_id, token, payload):
1016
1024
  raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
1017
1025
  require_fresh_member_token(connection, room_id, payload.get("token"))
1018
1026
  connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
1027
+ elif action == "adopt":
1028
+ adopt_manual_member(connection, room_id, member, payload)
1019
1029
  elif action == "role":
1020
1030
  role = payload.get("role")
1021
1031
  if role not in ("host", "member") or (role == "host" and member["access"] == "online"):
@@ -1026,7 +1036,7 @@ def member_action(connection, room_id, member_id, token, payload):
1026
1036
  connection.execute("UPDATE room_members SET role='member' WHERE room_id=? AND role='host'", (room_id,))
1027
1037
  connection.execute("UPDATE room_members SET role=? WHERE id=?", (role, member_id))
1028
1038
  else:
1029
- raise ValueError("action 须为 away、active、remove 或 role")
1039
+ raise ValueError("action 须为 away、active、remove、role 或 adopt")
1030
1040
  settle_batches(connection, room_id)
1031
1041
  sync_participants(connection, room_id)
1032
1042
  return free_view(connection, room_id)
package/backend/server.py CHANGED
@@ -738,7 +738,7 @@ def start_discussion(connection, room_id, token, payload):
738
738
  if room["archived"]:
739
739
  raise ValueError("房间已存档;恢复后才能开启讨论")
740
740
  sync_participants(connection, room_id)
741
- active = active_members(connection, room_id)
741
+ active = active_members(connection, room_id, registered_only=False)
742
742
  host = next((row for row in active.values() if row["role"] == "host"), None)
743
743
  if not host or host["access"] != "runner":
744
744
  raise ValueError("开启讨论需要一位由本机托管的 AI 主持人;先在成员栏指定或转为自动")
@@ -1022,7 +1022,7 @@ def record_reported_session(connection, room_id, token, payload):
1022
1022
  session_id, client = payload.get("session_id"), payload.get("session_client")
1023
1023
  if client not in ("claude", "codex", "antigravity") or not isinstance(session_id, str) or not SESSION_ID_PATTERN.fullmatch(session_id) or not token:
1024
1024
  return
1025
- member = connection.execute("SELECT id FROM room_members WHERE room_id=? AND token_hash=? AND status='active' AND access='manual' AND adapter='manual'",
1025
+ member = connection.execute("SELECT id FROM room_members WHERE room_id=? AND token_hash=? AND status='active' AND access='manual' AND adapter IN ('manual','')",
1026
1026
  (room_id, token_hash(token))).fetchone()
1027
1027
  if member:
1028
1028
  connection.execute("""INSERT INTO member_sessions (member_id,room_id,session_id,client) VALUES (?,?,?,?)
package/docs/workflow.md CHANGED
@@ -25,7 +25,9 @@
25
25
 
26
26
  自由协作房间从同一角色库选择一位或多位成员。用户是需求方而不是主持人:只要有能自行发言的成员(本机唤醒或手动接入),房间就始终有一位 AI 主持人,默认是第一位本机唤醒的成员,可点皇冠或在成员菜单中更换;主持人不能直接卸任,离开或被移出时由下一位成员接任,原先由用户主持的旧房间在下一条消息时自动指定。只有一位成员时,每条消息都交给它;有多位时,你的消息默认发给主持人;在正文中 `@成员` 才让其他成员发言(代码和引用中的 @ 不算,输入框中会生效的 @ 显示为蓝色),被点名的成员各自处理,主持人也可以 @ 其他成员派活。输入框下方的备注行显示这条消息会发给谁。只有网页 AI 成员时没有主持人,未点名的消息只作留言。输入 `@` 后,输入框上方弹出在场成员菜单,继续输入可筛选;用方向键与 Enter / Tab 或点击补全,Esc 关闭。选择只修改草稿。输入框 Enter 发送、Shift + Enter 换行;发送按钮始终用于发言,无正文或附件时禁用,成员处理中也不切换为停止按钮。自动成员空闲时,服务把它名下所有未处理的消息打包成一次执行交给原会话;执行期间再发的消息排队,下一次一起投递。手动成员自行读取自己的请求,在线成员由用户转交。成员按时间顺序处理,后面的消息可以修正前面的。
27
27
 
28
- 运行中可在右侧成员栏邀请 AI(沿用本机软件配置和本房间工作目录),也可让成员离开、请回或移除。自动成员的项目文件权限默认只读;需要它修改项目时,在邀请前选择“项目文件可写”,权限只应用于这位成员;App 与手动成员的文件权限由其软件设置,API 与网页成员不访问项目文件。填写已有会话 ID 时续接该原会话,不新建。离开会立即停止它的工作;请回时换发新凭证并续接它原来的会话。邀请时可选“手动接入”:成员不会被自动唤醒,而是得到一份私有凭据文件,由你在它的会话中按参与技能读写。绑定你正在使用的会话的成员会标注“借用会话”:它工作时不要在原窗口继续操作。
28
+ 运行中可在右侧成员栏邀请 AI(沿用本机软件配置和本房间工作目录),也可让成员离开、请回或移除。自动成员的项目文件权限默认只读;需要它修改项目时,在邀请前选择“项目文件可写”,权限只应用于这位成员;App 与手动成员的文件权限由其软件设置,API 与网页成员不访问项目文件。填写已有会话 ID 时续接该原会话,不新建。离开会立即停止它的工作;请回时换发新凭证并续接它原来的会话。邀请时可选“手动接入”:成员不会被自动唤醒,而是得到一份私有凭据文件,由你在它的会话中按参与技能读写。自由协作房间里,手动成员执行 `member register` 之前视为不存在:不能被点名,不收请求,不进“全体各自回应”和智能调度候选;注册后与其他成员一样可被点名,其 CLI 上报会话后本机服务按软件转为托管(Claude Code、Codex、agy),此后与自动成员无异。绑定你正在使用的会话的成员会标注“借用会话”:它工作时不要在原窗口继续操作。
29
+
30
+ 输入 `@全体成员`(或 `@all`)等同于点名当前所有可点名成员(含主持人,不含未注册的手动成员),各自回答,主持人不汇总;成员不止一位时 @ 菜单首项提供该选项。
29
31
 
30
32
  可以把一位本机成员设为主持人。之后没有点名的消息交给主持人分配;主持人在回答中点名成员派活,只点名一位成员时,其回答直接面向用户,主持人不再被唤醒;点名多位成员时,都结束后主持人再被唤醒一次,继续汇总或安排下一步。派出的成员全部失败时不再唤醒主持人;同一条消息引出的 AI 请求最多 8 次,用完即停,成员栏会提示,发一条新消息即重新开始。
31
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.2.63",
3
+ "version": "0.2.65",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -266,8 +266,9 @@ class RoomWorker {
266
266
  }
267
267
  /** A manual Claude or Codex expert that has registered and whose CLI reported its session is handed to the service, which resumes that session from the next turn on. */
268
268
  async adopt_manual_members() {
269
- if (this.config.mode === 'free') return;
270
- for (const member of this.runner.members || []) {
269
+ // A discussion runner lists members itself; a free room's registered manual members come from the room view.
270
+ const members = this.config.mode === 'free' ? (await api(this.config, '')).free?.members || [] : this.runner.members || [];
271
+ for (const member of members) {
271
272
  const key = `${member.name}:${member.reported_session}`;
272
273
  if (member.access !== 'manual' || member.status !== 'active' || !member.registered_at || !member.reported_session || this.adopt_failed.has(key)) continue;
273
274
  try {
package/web/app.js CHANGED
@@ -938,9 +938,9 @@ function message_payload() {
938
938
  if (online) return { author: online.name, text, reply_to: state.reply_to, kind: "chat", submitted_by: "用户",
939
939
  request_ids: state.room.free.requests.filter(item => item.member === online.name && item.status === "pending").map(item => item.id) };
940
940
  const payload = { author: "用户", text, reply_to: state.reply_to, kind: "chat", submitted_by: "用户" };
941
- const active = (state.room.free?.members || []).filter(member => member.status === "active").map(member => member.name);
942
- // @mentions outside code name members; without any, the server gives the message to the only member or the host.
943
- const named = active.filter(name => mentioned(text, name));
941
+ const active = (state.room.free?.members || []).filter(member => member.status === "active" && (member.access !== "manual" || member.registered_at)).map(member => member.name);
942
+ // @mentions outside code name members (@全体成员 or @all names them all); without any, the server gives the message to the only member or the host.
943
+ const named = mentioned(text, "全体成员") || mentioned(text, "all") ? active : active.filter(name => mentioned(text, name));
944
944
  if (named.length) payload.to = named;
945
945
  return payload;
946
946
  }
@@ -989,7 +989,7 @@ function reset_create_form() {
989
989
  state.create_parent = null;
990
990
  state.launch.create = true;
991
991
  state.launch.workspace = {};
992
- state.launch.scheduler = {};
992
+ state.launch.scheduler = default_scheduler();
993
993
  state.named_followup = false;
994
994
  state.create_discussion = false;
995
995
  state.launch.profiles = JSON.parse(JSON.stringify(state.launch.saved_profiles || {}));
@@ -1744,6 +1744,8 @@ async function load_launch_settings() {
1744
1744
  scheduler_providers: settings.scheduler_providers || {}, error: "",
1745
1745
  notice: local_connection ? "已连接本机服务,讨论保存在当前网站" : "" });
1746
1746
  Object.values(state.launch.profiles).forEach(fill_api_key);
1747
+ // Settings arrive after the form was reset; only an untouched draft gets the default.
1748
+ if (!Object.keys(state.launch.scheduler).length) state.launch.scheduler = default_scheduler();
1747
1749
  load_room_models();
1748
1750
  } catch (error) { state.launch.available = false; state.launch.room_models = null; state.launch.error = error.message; }
1749
1751
  finally { refresh_launch_recoveries(); render_launch_settings(); publish_view(); }
@@ -1953,6 +1955,15 @@ function write_create_discussion(value) {
1953
1955
  publish_view();
1954
1956
  }
1955
1957
 
1958
+ /** A fresh form turns smart scheduling on when some provider already has a saved key. */
1959
+ function default_scheduler() {
1960
+ const providers = state.launch.scheduler_providers || {};
1961
+ const provider = Object.keys(providers).find((name) => state.launch.api_keys?.[providers[name].base_url]);
1962
+ if (!provider) return {};
1963
+ const { base_url, models } = providers[provider];
1964
+ return { enabled: true, provider, model: models[0] || "", api_key_ref: state.launch.api_keys[base_url] };
1965
+ }
1966
+
1956
1967
  /** Change the smart scheduling draft; a new provider starts from its preset model and any key saved for its root. */
1957
1968
  function write_scheduler(field, value) {
1958
1969
  const scheduler = state.launch.scheduler;
@@ -2435,8 +2446,7 @@ function download_member_access(name) {
2435
2446
  if (!access.can_manage || !member?.available || member.access === "online") throw new Error("当前浏览器没有此成员可下载的私有配置");
2436
2447
  download_private_json({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token }, `member-${room.id}-${name}.json`);
2437
2448
  state.access_error = "";
2438
- state.access_notice = member.access === "runner" ? `已下载 ${name} 的持久成员配置;自动执行仍须使用本次任务凭据。`
2439
- : `已下载 ${name} 的私有配置;交给该成员的本地会话,下载不代表已接入。`;
2449
+ flash_access_notice(`已下载 ${name} 的私有配置`);
2440
2450
  }
2441
2451
 
2442
2452
  /** Copy the same room-scoped credential as one line of text for an agent that cannot take a file. */
@@ -2445,8 +2455,19 @@ async function copy_member_access_text(name) {
2445
2455
  const member = access.members.find(item => item.name === name);
2446
2456
  if (!access.can_manage || !member?.available || member.access === "online") throw new Error("当前浏览器没有此成员可复制的凭证");
2447
2457
  await copy_text(JSON.stringify({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token }));
2458
+ flash_access_notice(`已复制 ${name} 的凭证文本(含 token)`);
2459
+ }
2460
+
2461
+ /** Confirm a copy or download briefly in the member rail; the note clears itself. */
2462
+ function flash_access_notice(message) {
2448
2463
  state.access_error = "";
2449
- state.access_notice = `已复制 ${name} 的凭证文本,含 token,仅交给该成员;用环境变量 DISCUSS_ACCESS 或 --access-file - 使用,复制不代表已接入。`;
2464
+ state.access_notice = message;
2465
+ clearTimeout(state.access_notice_timer);
2466
+ state.access_notice_timer = setTimeout(() => {
2467
+ if (state.access_notice !== message) return;
2468
+ state.access_notice = "";
2469
+ publish_view();
2470
+ }, TOAST_DURATION_MS);
2450
2471
  }
2451
2472
 
2452
2473
  /** Copy a member-specific entry instruction; only the launch variant carries the credential, never conversation context. */
@@ -2469,11 +2490,7 @@ async function copy_member_join(name, with_credential = false) {
2469
2490
  ${instruction}
2470
2491
  房间入口:${address}
2471
2492
  先核对最新阶段、任务和原文编号,再按房间协议参与。${credential}`);
2472
- if (state.room?.id === room.id) {
2473
- state.access_error = "";
2474
- state.access_notice = with_credential ? `已复制 ${name} 的启动指令,含 token,只交给该成员;复制不代表已经送达或接入。`
2475
- : `已复制 ${name} 的参与说明,未包含凭据;复制不代表已经送达或接入。`;
2476
- }
2493
+ if (state.room?.id === room.id) flash_access_notice(with_credential ? `已复制 ${name} 的启动指令(含 token)` : `已复制 ${name} 的参与说明`);
2477
2494
  }
2478
2495
 
2479
2496
  /** How a manual member learns it is its turn: free rooms are pulled by the member, discussions only on the host's assignment. */