ai_co_discuss 0.3.7 → 0.3.9

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.
@@ -462,6 +462,18 @@ REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM me
462
462
  WHERE source.member_id=room_members.id AND source.source='web') OR EXISTS(SELECT 1 FROM member_registrations r
463
463
  WHERE r.member_id=room_members.id AND r.token_hash=room_members.token_hash))"""
464
464
 
465
+ # One snapshot for group routing; the Worker uses the same SQL inside its atomic write assertion.
466
+ GROUP_STATE_SQL = f"""SELECT json_object(
467
+ 'latest_id',(SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=rooms.id),
468
+ 'title',rooms.title,'goal',rooms.goal,
469
+ 'archived',(SELECT archived FROM room_directory WHERE room_id=rooms.id),
470
+ 'order',COALESCE((SELECT member_ids FROM speaking_orders WHERE room_id=rooms.id),'[]'),
471
+ 'members',(SELECT json_group_array(json_object('id',id,'name',name,'role',role,'status',status,'access',access,
472
+ 'registered',{REGISTERED_MEMBER},'description',COALESCE((SELECT description FROM member_descriptions d
473
+ WHERE d.room_id=room_members.room_id AND d.name=room_members.name),'')))
474
+ FROM (SELECT * FROM room_members WHERE room_id=rooms.id ORDER BY id) AS room_members)) AS state
475
+ FROM rooms WHERE id=?"""
476
+
465
477
 
466
478
  def free_view(connection, room_id):
467
479
  """Summarize members, their live execution, recent requests and possible file conflicts."""
@@ -1258,7 +1270,8 @@ def inbox(connection, room_id, token):
1258
1270
  for row in connection.execute(f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id", (room_id,)):
1259
1271
  pending = [dict(item) for item in connection.execute("""SELECT r.id,r.message_id,r.purpose,
1260
1272
  sr.id AS session_request_id,sr.session_id,sr.round,sr.position,sr.read_until,sr.question AS source_question,
1261
- t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id
1273
+ t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id,
1274
+ (SELECT action FROM group_routes WHERE message_id=r.message_id) AS group_action
1262
1275
  FROM member_requests r LEFT JOIN session_requests sr ON sr.member_request_id=r.id
1263
1276
  LEFT JOIN task_message_sources t ON t.session_request_id=sr.id
1264
1277
  WHERE r.member_id=? AND r.status='pending' ORDER BY r.id""", (row["id"],))]
@@ -1309,29 +1322,74 @@ def release_recommendations(connection, room_id):
1309
1322
  VALUES (?,?,?,?,'ask','user',?)""", (room_id, row["member_id"], row["message_id"], row["message_id"], int(time.time())))
1310
1323
 
1311
1324
 
1312
- def route_wide(connection, room_id, message_id, action):
1325
+ def group_route_plan(connection, room_id, token, message_id):
1326
+ """Read the current group, eligible members, order and revision before arranging everyone's requests."""
1327
+ require_owner(connection, room_id, token)
1328
+ group = connection.execute("""SELECT id,message_id,status FROM member_requests
1329
+ WHERE room_id=? AND message_id=? AND purpose='group' ORDER BY id DESC LIMIT 1""", (room_id, message_id)).fetchone()
1330
+ previous = connection.execute("SELECT action,request_id,message_id FROM group_routes WHERE room_id=? AND message_id=?",
1331
+ (room_id, message_id)).fetchone()
1332
+ if not group and not previous:
1333
+ raise LookupError("这条消息没有全体待安排请求")
1334
+ state = connection.execute(GROUP_STATE_SQL, (room_id,)).fetchone()["state"]
1335
+ snapshot = json.loads(state)
1336
+ members = [member for member in snapshot["members"] if member["status"] == "active" and member["registered"]]
1337
+ rank = {member_id: index for index, member_id in enumerate(json.loads(snapshot["order"]))}
1338
+ members.sort(key=lambda member: (member["id"] not in rank, rank.get(member["id"], 0), member["role"] != "host", member["id"]))
1339
+ return {"message_id": message_id, "request": dict(group) if group else None, "members": members,
1340
+ "latest_id": snapshot["latest_id"], "revision": hashlib.sha256(state.encode("utf-8")).hexdigest(),
1341
+ "result": dict(previous) if previous else None}
1342
+
1343
+
1344
+ def route_group(connection, room_id, group, payload):
1345
+ """Arrange a pending group atomically; only the single-object handback to its host needs no roster revision."""
1346
+ action = payload["action"]
1347
+ if action not in ("all", "sequential", "host"):
1348
+ raise ValueError("请全体成员回应的消息只能选 action=all、sequential 或 host")
1349
+ if group["status"] != "pending":
1350
+ raise FileExistsError("这条消息已不再等待安排")
1351
+ if action != "host":
1352
+ if not payload.get("revision"):
1353
+ raise ValueError("安排全体前须先读取 message group-plan,并带上其 revision")
1354
+ state = connection.execute(GROUP_STATE_SQL, (room_id,)).fetchone()["state"]
1355
+ if payload["revision"] != hashlib.sha256(state.encode("utf-8")).hexdigest():
1356
+ raise FileExistsError("成员、顺序或消息已变化,请重新读取全体安排状态")
1357
+ if action == "host":
1358
+ connection.execute("UPDATE member_requests SET purpose='route' WHERE id=?", (group["id"],))
1359
+ elif action == "sequential":
1360
+ connection.execute("DELETE FROM member_requests WHERE id=?", (group["id"],))
1361
+ members = connection.execute(f"SELECT id FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))
1362
+ queue_in_turn(connection, room_id, group["message_id"], [row["id"] for row in members])
1363
+ else:
1364
+ connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (group["id"],))
1365
+ connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
1366
+ SELECT room_id,id,?,?,'all','user',? FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id""",
1367
+ (group["message_id"], group["chain_id"], int(time.time()), room_id))
1368
+ connection.execute("INSERT INTO group_routes (message_id,room_id,request_id,action) VALUES (?,?,?,?)",
1369
+ (group["message_id"], room_id, group["id"], action))
1370
+ return {"action": action, "request_id": group["id"], "message_id": group["message_id"]}
1371
+
1372
+
1373
+ def route_wide(connection, room_id, message_id, payload):
1313
1374
  """Smart scheduling answers an unnamed message with everyone (each member once, as if the user named them all) or no one.
1314
1375
 
1315
1376
  A message naming everyone (a group request) is answered by all at once or, with action sequential, one after another.
1316
1377
  Only a host request that has not started changes; a started host already handles the message its own way.
1317
1378
  """
1379
+ action = payload["action"]
1380
+ previous = connection.execute("SELECT action,request_id,message_id FROM group_routes WHERE room_id=? AND message_id=?",
1381
+ (room_id, message_id)).fetchone()
1382
+ if previous:
1383
+ if previous["action"] != action:
1384
+ raise FileExistsError("这条全体消息已经安排,不能改用其他动作")
1385
+ return dict(previous)
1386
+ group = connection.execute("""SELECT * FROM member_requests WHERE room_id=? AND message_id=? AND purpose='group'
1387
+ ORDER BY id DESC LIMIT 1""", (room_id, message_id)).fetchone()
1388
+ if group:
1389
+ return route_group(connection, room_id, group, payload)
1390
+ if action in ("sequential", "host"):
1391
+ raise ValueError("只有请全体成员回应的消息可以依次回答或交回主持人")
1318
1392
  route = host_route_request(connection, message_id)
1319
- group = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND purpose='group'", (message_id,)).fetchone()
1320
- if group and group["room_id"] == room_id:
1321
- if action not in ("all", "sequential"):
1322
- raise ValueError("请全体成员回应的消息只能选 action=all 或 sequential")
1323
- if group["status"] != "pending":
1324
- raise FileExistsError("这条消息已不再等待安排")
1325
- if action == "sequential":
1326
- # The row is removed rather than ended so the host takes its place in the queue like everyone else.
1327
- connection.execute("DELETE FROM member_requests WHERE id=?", (group["id"],))
1328
- members = connection.execute(f"SELECT id FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))
1329
- queue_in_turn(connection, room_id, message_id, [row["id"] for row in members])
1330
- requests = connection.execute("SELECT * FROM member_requests WHERE message_id=? ORDER BY id", (message_id,)).fetchall()
1331
- return {"action": action, "requests": [dict(row) for row in requests]}
1332
- route = group
1333
- elif action == "sequential":
1334
- raise ValueError("只有请全体成员回应的消息可以依次回答")
1335
1393
  if not route or route["room_id"] != room_id:
1336
1394
  raise LookupError("只能处理交给主持人的未点名消息")
1337
1395
  if route["status"] != "pending":
@@ -1356,8 +1414,8 @@ def adopt_recommendation(connection, room_id, token, message_id, payload):
1356
1414
  """
1357
1415
  require_owner(connection, room_id, token)
1358
1416
  begin_write(connection)
1359
- if payload.get("action") in ("all", "none", "sequential"):
1360
- return route_wide(connection, room_id, message_id, payload["action"])
1417
+ if payload.get("action") in ("all", "none", "sequential", "host"):
1418
+ return route_wide(connection, room_id, message_id, payload)
1361
1419
  if payload.get("action") != "adopt" or payload.get("mode") not in ("interrupt", "after"):
1362
1420
  raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体、依次或无需回应为 action=all、sequential 或 none")
1363
1421
  row = connection.execute("SELECT * FROM member_recommendations WHERE room_id=? AND message_id=?", (room_id, message_id)).fetchone()
@@ -434,6 +434,14 @@ CREATE TABLE IF NOT EXISTS member_requests (
434
434
  UNIQUE(message_id,member_id)
435
435
  );
436
436
  CREATE INDEX IF NOT EXISTS member_requests_open ON member_requests(member_id,status);
437
+ -- One durable disposition per @everyone message; also prevents a host fallback from being routed again after restart.
438
+ -- The original group request may be deleted for a sequential queue, so request_id is a receipt, not a foreign key.
439
+ CREATE TABLE IF NOT EXISTS group_routes (
440
+ message_id INTEGER PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
441
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
442
+ request_id INTEGER NOT NULL,
443
+ action TEXT NOT NULL CHECK(action IN ('all','sequential','host'))
444
+ );
437
445
  -- Discussion options chosen at creation. A room without a row predates the option and keeps named follow-ups.
438
446
  CREATE TABLE IF NOT EXISTS discussion_options (
439
447
  room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
package/backend/server.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """AI CoDiscuss 的零依赖 HTTP / SQLite 概念验证服务。"""
2
2
 
3
3
  import argparse
4
+ import collections
4
5
  import copy
5
6
  import hashlib
6
7
  import hmac
@@ -30,7 +31,7 @@ from .free_room import (add_member, after_free_message, authorize_free_write, ch
30
31
  from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions, sync_participants
31
32
  from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member, route_held_messages, require_member
32
33
  from .free_room import describe_members, execution_notes, label_members, member_descriptions, member_labels, reorder_speakers, set_chain_rounds, speaking_order, stop_room_work
33
- from .free_room import continue_chain, execution_timeline, retract_message, rewind_plan, rewind_room, withdrawals
34
+ from .free_room import continue_chain, execution_timeline, group_route_plan, retract_message, rewind_plan, rewind_room, withdrawals
34
35
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
35
36
  from .room_import import import_room, imported_attachment
36
37
  from .runtime import (authorize_write, begin_write, event_view,
@@ -46,6 +47,8 @@ ROOT = Path(__file__).resolve().parent.parent
46
47
  WEB_ROOT = ROOT / "web"
47
48
  DEFAULT_PORT = 8765
48
49
  UPDATE_HEARTBEAT_SECONDS = 15
50
+ # Changes remembered for streams that fall behind; one further back is reported as a change to the whole site.
51
+ UPDATE_LOG_SIZE = 256
49
52
  MAX_BODY_BYTES = 512 * 1024
50
53
  MAX_IMPORT_BYTES = 32 * 1024 * 1024
51
54
  MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
@@ -1985,10 +1988,17 @@ def protocol_text(room):
1985
1988
  return "\n\n".join(paragraphs).replace("HOST", room["host"]).replace("BASE", base)
1986
1989
 
1987
1990
 
1991
+ def update_frame(rooms):
1992
+ """Encode a change event; `rooms` lists the changed rooms, and without it every room may have changed."""
1993
+ return f"event: change\ndata: {json.dumps({'rooms': rooms} if rooms else {})}\n\n".encode("utf-8")
1994
+
1995
+
1988
1996
  class DiscussionServer(ThreadingHTTPServer):
1989
1997
  """HTTP server carrying the configured SQLite path."""
1990
1998
 
1991
1999
  daemon_threads = True
2000
+ # Every open page refreshes at once and the runner reads in bursts; Windows refuses connections beyond the backlog.
2001
+ request_queue_size = 128
1992
2002
  allow_reuse_address = os.name != "nt"
1993
2003
  allow_reuse_port = False
1994
2004
 
@@ -1996,6 +2006,8 @@ class DiscussionServer(ThreadingHTTPServer):
1996
2006
  self.database = Path(database)
1997
2007
  self.updates = threading.Condition()
1998
2008
  self.update_version = 0
2009
+ # Recent (version, room id or None) pairs, so a stream names the rooms that changed; None means the whole site.
2010
+ self.update_log = collections.deque(maxlen=UPDATE_LOG_SIZE)
1999
2011
  self.updates_closed = False
2000
2012
  if local_launch and address[0] != "127.0.0.1":
2001
2013
  raise ValueError("本机启动入口须绑定 127.0.0.1")
@@ -2012,12 +2024,20 @@ class DiscussionServer(ThreadingHTTPServer):
2012
2024
  self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
2013
2025
  super().server_bind()
2014
2026
 
2015
- def notify_update(self):
2016
- """Wake browser subscribers after a committed business write."""
2027
+ def notify_update(self, room_id=None):
2028
+ """Wake subscribers after a committed business write to one room, or to the site when no room is given."""
2017
2029
  with self.updates:
2018
2030
  self.update_version += 1
2031
+ self.update_log.append((self.update_version, room_id))
2019
2032
  self.updates.notify_all()
2020
2033
 
2034
+ def changed_rooms(self, version):
2035
+ """Return the rooms changed after a version, or None when the site changed or the log no longer reaches it."""
2036
+ entries = [room for number, room in self.update_log if number > version]
2037
+ if len(entries) < self.update_version - version or None in entries:
2038
+ return None
2039
+ return sorted(set(entries))
2040
+
2021
2041
  def server_close(self):
2022
2042
  """Release streaming clients when the HTTP server stops."""
2023
2043
  with self.updates:
@@ -2041,7 +2061,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2041
2061
  quiet_renewal = (parts[3:4] == ["events"] and action == "renew") or (parts[3:4] in (["events"], ["executions"]) and action == "segments")
2042
2062
  if (self.command == "POST" and 200 <= status < 300 and parts[:2] in (["api", "rooms"], ["api", "folders"])
2043
2063
  and not quiet_renewal):
2044
- self.server.notify_update()
2064
+ self.server.notify_update(parts[2] if parts[:2] == ["api", "rooms"] and len(parts) > 3 else None)
2045
2065
  if content_type == "application/json":
2046
2066
  body = json.dumps(body, ensure_ascii=False)
2047
2067
  data = body.encode("utf-8") if isinstance(body, str) else body
@@ -2143,8 +2163,9 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2143
2163
  if self.server.updates_closed:
2144
2164
  return
2145
2165
  changed = self.server.update_version != version
2166
+ rooms = self.server.changed_rooms(version) if changed else None
2146
2167
  version = self.server.update_version
2147
- self.wfile.write(b"event: change\ndata: {}\n\n" if changed else b": keepalive\n\n")
2168
+ self.wfile.write(update_frame(rooms) if changed else b": keepalive\n\n")
2148
2169
  self.wfile.flush()
2149
2170
  except OSError:
2150
2171
  pass
@@ -2689,6 +2710,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2689
2710
  result = reserve_execution(connection, room_id, token, self.read_payload())
2690
2711
  elif parts[3:] == ["recommendations"] and method == "POST":
2691
2712
  result = recommend_member(connection, room_id, token, self.read_payload())
2713
+ elif len(parts) == 5 and parts[3] == "recommendations" and method == "GET":
2714
+ result = group_route_plan(connection, room_id, token, int(parts[4]))
2692
2715
  elif len(parts) == 5 and parts[3] == "recommendations" and method == "POST":
2693
2716
  result = adopt_recommendation(connection, room_id, token, int(parts[4]), self.read_payload())
2694
2717
  elif len(parts) == 6 and parts[3] == "executions" and parts[5] == "notes" and method == "GET":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
package/runner/discuss.py CHANGED
@@ -227,6 +227,8 @@ def message_commands(subcommands, registry):
227
227
  view = add_leaf(subcommands, registry, "message view", "读取一条精确原文与来源", True)
228
228
  view.add_argument("id", type=positive_int, help="消息编号")
229
229
  view.add_argument("--request", type=positive_int, help="手动成员:按自己的有效请求限制可读原文")
230
+ group = add_leaf(subcommands, registry, "message group-plan", "房主读取全体请求、成员、顺序与 revision;安排全体前先读,提交时携带该 revision", True)
231
+ group.add_argument("id", type=positive_int, help="全体请求的原消息编号")
230
232
  post = add_leaf(subcommands, registry, "message post", "提交观点、主持说明、协作回答或代发", True)
231
233
  add_text(post, "body")
232
234
  post.add_argument("--attachment", action="append", type=attachment_id, default=[],
@@ -488,6 +490,8 @@ def plan_room(args):
488
490
  def plan_message(args):
489
491
  """Preserve operator, original author, message role, and replay identifier."""
490
492
  base = f"/api/rooms/{args.room}/messages"
493
+ if args.action == "group-plan":
494
+ return RequestPlan(f"/api/rooms/{args.room}/recommendations/{args.id}")
491
495
  if args.action == "view":
492
496
  return RequestPlan(f"{base}/{args.id}", params=read_context_params(args))
493
497
  if args.action == "list":
@@ -388,12 +388,14 @@ export class DiscussionService {
388
388
  }
389
389
  await Promise.all(checks);
390
390
  }
391
- /** Check every room of a site now, as its website reported a change. */
392
- wake(site) {
393
- for (const [path, worker] of this.workers) if (worker.state.site === site) this.start(path, worker);
391
+ /** Check a site's rooms now, as its website reported a change; `rooms` limits it to the rooms that changed. */
392
+ wake(site, rooms = null) {
393
+ for (const [path, worker] of this.workers) {
394
+ if (worker.state.site === site && (!rooms || rooms.includes(worker.state.room))) this.start(path, worker);
395
+ }
394
396
  }
395
397
  /**
396
- * Follow a site's `/api/updates` stream and wake its rooms on each change, so a message reaches the AI within
398
+ * Follow a site's `/api/updates` stream and wake the rooms each change names, so a message reaches the AI within
397
399
  * milliseconds instead of the next poll. A site without push (204) is left to polling; a dropped stream reconnects.
398
400
  */
399
401
  watch(site) {
@@ -411,7 +413,8 @@ export class DiscussionService {
411
413
  buffer += decoder.decode(chunk, { stream: true }).replaceAll('\r\n', '\n');
412
414
  for (let end = buffer.indexOf('\n\n'); end >= 0; end = buffer.indexOf('\n\n')) {
413
415
  // `ready` comes on every (re)connection, so changes missed while disconnected are picked up too.
414
- if (/^event: (change|ready)$/m.test(buffer.slice(0, end))) this.wake(site);
416
+ const frame = buffer.slice(0, end);
417
+ if (/^event: (change|ready)$/m.test(frame)) this.wake(site, changed_rooms(frame));
415
418
  buffer = buffer.slice(end + 2);
416
419
  }
417
420
  }
@@ -451,6 +454,13 @@ export class DiscussionService {
451
454
  }
452
455
  }
453
456
 
457
+ /** The rooms an update event names, or null when it names none and any room may have changed. */
458
+ function changed_rooms(frame) {
459
+ const data = frame.match(/^data: (.*)$/m)?.[1];
460
+ try { const rooms = JSON.parse(data).rooms; return Array.isArray(rooms) && rooms.length ? rooms : null; }
461
+ catch { return null; }
462
+ }
463
+
454
464
  /** Delete one room's local files: its answer drafts, and its launch folder when the service created it under launches/. */
455
465
  export function remove_room_files(home, config_path, data_directory) {
456
466
  const folder = dirname(resolve(config_path));
@@ -383,7 +383,8 @@ export class FreeRoomRunner {
383
383
  if (!adapter) return this.fail(member.name, execution.id, `未配置 ${member.name} 的本机接入`);
384
384
  if (this.context_errors.has(member.name)) return this.fail(member.name, execution.id, this.context_errors.get(member.name));
385
385
  const directory = adapter.adapter === 'api' ? undefined : adapter.cwd || this.config.cwd;
386
- const before = workspace_snapshot(directory);
386
+ // A read-only member's software cannot write the project, so files changing meanwhile were written by someone else.
387
+ const before = adapter.project_access === 'read' ? null : workspace_snapshot(directory);
387
388
  if (before) this.snapshots.set(execution.id, { member: member.name, directory, before });
388
389
  const binding = this.store.binding(member.name);
389
390
  // A member whose session a rewind restarted is bound to '' and must not fall back to the session it was invited with.