ai_co_discuss 0.2.43 → 0.2.44

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/runner/discuss.py CHANGED
@@ -188,12 +188,15 @@ def room_commands(subcommands, registry):
188
188
  """Register room discovery, progressive reading, creation, and updates."""
189
189
  for action, description in (("list", "列出房间概要"), ("view", "只读目标、阶段、焦点及当前安排"),
190
190
  ("brief", "按需读取背景、边界和专家配置"),
191
+ ("conclusion", "收束前申请最新收束状态(阻碍项与 revision);主持人收束前必做"),
191
192
  ("protocol", "按需读取当前服务的参与协议")):
192
193
  parser = add_leaf(subcommands, registry, f"room {action}", description, action != "list")
193
194
  if action == "list":
194
195
  parser.add_argument("--submission-id", type=submission_id, help="按建房提交编号精确查询")
195
- limits = add_leaf(subcommands, registry, "room limits", "查看上限;--check 检查并执行到限处理", True)
196
+ limits = add_leaf(subcommands, registry, "room limits", "查看上限;--check 检查并执行到限处理;--done 通知程序目标已完成", True)
196
197
  limits.add_argument("--check", action="store_true")
198
+ limits.add_argument("--done", action="store_true", help="主持人宣告目标已完成:按到限方式暂停或进入总结,不直接收束;须带 --conclusion")
199
+ limits.add_argument("--conclusion", help="room conclusion 返回的 revision;--done 必填")
197
200
  create = add_leaf(subcommands, registry, "room create", "创建话题;不自动启动讨论或监控")
198
201
  create.add_argument("--folder-id", help="直接在指定文件夹中创建;省略则未归类")
199
202
  add_topic(create, create=True)
@@ -201,6 +204,7 @@ def room_commands(subcommands, registry):
201
204
  help="新话题唯一编号;未知结果先查询,重试沿用编号和原始资料")
202
205
  update = add_leaf(subcommands, registry, "room update", "维护话题;省略的字段保持不变", True)
203
206
  add_topic(update)
207
+ update.add_argument("--conclusion", help="room conclusion 返回的 revision;主持人设为 concluded 时必填,状态变化后需重新获取")
204
208
 
205
209
 
206
210
  def message_commands(subcommands, registry):
@@ -255,7 +259,8 @@ def turn_commands(subcommands, registry):
255
259
  """Keep planning, notification text, and delivery confirmation distinct."""
256
260
  descriptions = {"list": "分页读取安排历史", "current": "读取阶段和最近安排;需核对状态及专家",
257
261
  "view": "读取一个安排", "prompt": "生成简短通知;不发送、不标记送达",
258
- "create": "安排一位专家,或用 --plan-file 一次安排整轮;不发送通知",
262
+ "roster": "申请最新专家状态(在场专家、本轮待发言者与 revision);整轮安排前必做",
263
+ "create": "安排一位专家,或用 --plan-file 一次安排整轮(须带 --roster);不发送通知",
259
264
  "continue": "处理用户操作或打断后,按原排队继续下一位",
260
265
  "mark-notified": "实际送达后显式确认", "cancel": "取消指定未完成安排"}
261
266
  for action, description in descriptions.items():
@@ -263,12 +268,13 @@ def turn_commands(subcommands, registry):
263
268
  if action == "list":
264
269
  parser.add_argument("--page", type=positive_int, default=1)
265
270
  parser.add_argument("--submission-id", type=submission_id, help="按安排提交编号精确查询结果")
266
- elif action not in ("create", "current", "continue"):
271
+ elif action not in ("create", "current", "continue", "roster"):
267
272
  parser.add_argument("id", type=positive_int, help="安排编号")
268
273
  if action == "create":
269
274
  parser.add_argument("--expert", type=ai_name, help="单个安排的专家;与 --plan-file 二选一")
270
275
  add_text(parser, "question")
271
276
  parser.add_argument("--plan-file", help='整轮安排 JSON 数组 [{"expert":"Claude","question":"…"}],按顺序接力;- 从标准输入读取')
277
+ parser.add_argument("--roster", help="turn roster 返回的 revision;整轮安排(--plan-file)必填,专家状态变化后需重新获取")
272
278
  parser.add_argument("--submission-id", type=submission_id, required=True,
273
279
  help="新安排唯一编号;重试沿用同编号和内容")
274
280
 
@@ -435,13 +441,20 @@ def plan_room(args):
435
441
  return RequestPlan("/api/rooms", "POST", payload, "overview")
436
442
  base = f"/api/rooms/{args.room}"
437
443
  if args.action == "limits":
444
+ if args.done:
445
+ return RequestPlan(base + "/limits", "POST", {"actor": require_actor(args), "action": "done",
446
+ **({"conclusion": args.conclusion} if args.conclusion else {})}, "overview")
438
447
  return RequestPlan(base + "/limits", "POST", {"actor": require_actor(args)}, "overview") if args.check else RequestPlan(base + "/limits")
439
448
  if args.action == "update":
440
449
  payload = topic_payload(args)
441
450
  if not payload:
442
451
  raise CliError("room update 至少需要一个话题字段")
443
452
  payload["actor"] = require_actor(args)
453
+ if args.conclusion:
454
+ payload["conclusion"] = args.conclusion
444
455
  return RequestPlan(base + "/topic", "POST", payload, "overview")
456
+ if args.action == "conclusion":
457
+ return RequestPlan(base + "/conclusion")
445
458
  if args.action == "protocol":
446
459
  return RequestPlan(f"/r/{args.room}/protocol")
447
460
  return RequestPlan(base, view="brief" if args.action == "brief" else "overview")
@@ -531,6 +544,8 @@ def plan_turn(args):
531
544
  if args.submission_id:
532
545
  params["submission_id"] = args.submission_id
533
546
  return RequestPlan(base + "/turns", params=params)
547
+ if args.action == "roster":
548
+ return RequestPlan(base + "/turns/roster")
534
549
  if args.action == "create":
535
550
  return RequestPlan(base + "/turns", "POST", turn_payload(args))
536
551
  if args.action == "continue":
@@ -555,7 +570,7 @@ def turn_payload(args):
555
570
  raise CliError(f"无效 JSON: {error}") from error
556
571
  if not isinstance(turns, list) or not all(isinstance(item, dict) for item in turns):
557
572
  raise CliError("--plan-file 必须是由 expert、question 对象组成的数组")
558
- return {**payload, "turns": turns}
573
+ return {**payload, "turns": turns, **({"roster": args.roster} if args.roster else {})}
559
574
  if not args.expert or question is None:
560
575
  raise CliError("需要 --expert 与 --question/--question-file,或改用 --plan-file")
561
576
  return {**payload, "expert": args.expert, "question": question}
@@ -230,7 +230,7 @@ export function task_files(config, event, token, binding, { suggestions = [], en
230
230
  instructions: `${adapter.adapter === 'antigravity_cli' ? `无人值守下 run_command 只能运行 ${cli} 的 subcommands 子命令,其他命令会被自动拒绝并使会话直接结束;浏览项目用 list_dir、find_by_name、grep_search、view_file。` : ''}读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;${auto_enter ? '接入程序已进入本次执行,不要再执行 execution enter。' : `使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。进入成功后才处理本次安排;`}凭据文件由 CLI 内部读取,不要打印或读取其 token。${access_rule}答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${project_rule}只处理本次工作,不建定时任务。主持创建下一安排或收束即结束,专家提交原文后结束。无效或已进入的任务不要重复执行。${suggestion_rule}` });
231
231
  const read_rule = windows_rg() ? '读取含中文的技能或文本文件时用 rg --encoding utf-8 . 文件路径,不用 Get-Content(Windows PowerShell 会按系统编码读出乱码,需要反复重试)。' : '';
232
232
  const scope = `本次只放行任务内 subcommands 列出的 ${cli} 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口。任务内 cwd 是讨论项目目录;${project_rule}项目读写仍受软件实际权限和议题授权约束。不读取凭据内容。${read_rule}`;
233
- const plan_rule = event.role === 'host' ? '安排专家回应时先写发言计划,用 turn create --plan-file 一次排满本轮每位专家,不要每次只排一位。' : '';
233
+ const plan_rule = event.role === 'host' ? '安排专家回应时先写发言计划,先用 turn roster 取得最新专家状态,再用 turn create --plan-file 加 --roster 一次排满 waiting 中的每位专家,不要每次只排一位。' : '';
234
234
  const intro = `用户已启动此房间的全自动讨论。请以 ${event.participant} 的${event.role === 'host' ? '主持' : '专家'}职责处理本次工作。${plan_rule}`;
235
235
  const prompt = auto_enter
236
236
  ? `${intro}接入程序已为本次执行完成 execution enter,不要再执行。任务内容如下(同一份保存在 ${task_path},无需再读):
@@ -316,8 +316,13 @@ export class DiscussionRunner {
316
316
  */
317
317
  async smart_schedule(row, event) {
318
318
  if (!this.config.scheduler) return {};
319
- let plan;
320
- try { plan = await this.plan_schedule(event); }
319
+ let plan, seen;
320
+ try {
321
+ // What the decision is based on; applying it first checks nothing new (a message or a topic edit) arrived since.
322
+ const room = await api(this.config, '');
323
+ seen = { latest_id: room.latest_id, held_since: room.held_since || 0 };
324
+ plan = await this.plan_schedule(event);
325
+ }
321
326
  catch (error) { this.log(`智能调度判断失败,交给主持人:${error.message}`); return {}; }
322
327
  if (!plan) return {};
323
328
  const suggestions = plan.message_id ? [{ message_id: plan.message_id, suggestion: plan.label, confidence: plan.confidence }] : [];
@@ -336,7 +341,7 @@ export class DiscussionRunner {
336
341
  }
337
342
  this.store.record(row.id, { status: 'started', last_renew: Date.now() });
338
343
  try {
339
- await this.apply_schedule(plan, token);
344
+ await this.apply_schedule(plan, token, seen);
340
345
  await this.record_decision(plan, true);
341
346
  this.log(`智能调度 #${row.id}:${plan.label}(置信度 ${plan.confidence})`);
342
347
  } catch (error) {
@@ -394,6 +399,8 @@ export class DiscussionRunner {
394
399
  if (!waiting.length) return skip('round', '本轮未发言的专家都是手动接入,无法自动安排');
395
400
  // An expert the user just skipped is not picked again unless nobody else is left.
396
401
  const skipped = room.current_turn?.status === 'cancelled' ? room.current_turn.expert : null;
402
+ // Asking whether to call on the host alone is pointless: handing back already wakes it.
403
+ if (waiting.length === 1 && waiting[0] === room.host) return skip('round', '本轮只剩主持人未发言');
397
404
  const candidates = waiting.filter(name => name !== skipped);
398
405
  return this.next_speaker_plan(room, { ...context, spoken: [...spoken] }, candidates.length ? candidates : waiting, anchor);
399
406
  }
@@ -435,13 +442,18 @@ export class DiscussionRunner {
435
442
  return { action: 'restore', kind: 'round', confidence: null, label, after_message_id: anchor, turns: plan_turns };
436
443
  }
437
444
  /** Perform the host's writes for a decided plan under its own execution credential. */
438
- async apply_schedule(plan, token) {
445
+ async apply_schedule(plan, token, seen) {
439
446
  const room = await api(this.config, '');
447
+ if (room.latest_id !== seen.latest_id || (room.held_since || 0) !== seen.held_since) throw new Error('判断期间房间有新留言或用户操作,放弃应用');
440
448
  const actor = room.host;
441
449
  if (room.floor.holder !== actor) await api(this.config, '/floor', { actor, speaker: actor, action: 'take', revision: room.floor.revision }, token);
442
450
  if (plan.phase) await api(this.config, '/topic', { actor, phase: plan.phase }, token);
443
451
  if (plan.continue_plan) await api(this.config, '/turns/continue', { actor }, token);
444
- else await api(this.config, '/turns', { actor, turns: plan.turns, submission_id: randomUUID() }, token);
452
+ else {
453
+ // Like any host, the program asks for the latest roster right before it arranges turns.
454
+ const { revision } = await api(this.config, '/turns/roster');
455
+ await api(this.config, '/turns', { actor, turns: plan.turns, roster: revision, submission_id: randomUUID() }, token);
456
+ }
445
457
  }
446
458
  /** Show the decision in the transcript; a failed record never affects the discussion. */
447
459
  async record_decision(plan, applied) {
@@ -179,7 +179,7 @@ export async function decide_next_speaker(home, scheduler, { title, goal, user_r
179
179
  return { action: 'next', expert: choice, confidence, detail, label: `本轮继续,请 ${choice} 接着发言` };
180
180
  }
181
181
  if (typeof obligation === 'number' && obligation < ROUTE_CONFIDENCE) {
182
- return { action: 'host', confidence, detail, label: '没有明确该谁接着发言的依据,交给主持人安排' };
182
+ return { action: 'host', confidence, detail, label: `有明确依据的概率 ${obligation.toFixed(2)},未达 ${ROUTE_CONFIDENCE.toFixed(2)},交给主持人安排` };
183
183
  }
184
184
  return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && confidence >= ROUTE_CONFIDENCE ? '建议由主持人调整安排,交给主持人' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
185
185
  }
package/web/app.js CHANGED
@@ -1046,7 +1046,7 @@ function finish_member_request(room_id, token, record, result) {
1046
1046
  }
1047
1047
 
1048
1048
  /** Owner changes use the website; only automatic members need the local service to reconnect. */
1049
- async function change_member({ member_id, name, action, role }) {
1049
+ async function change_member({ member_id, name, action, role, latest_id }) {
1050
1050
  const room_id = state.room.id;
1051
1051
  state.free_error = ""; state.free_notice = "";
1052
1052
  try {
@@ -1061,7 +1061,7 @@ async function change_member({ member_id, name, action, role }) {
1061
1061
  if (state.room?.id === room_id) state.free_notice = member.access === "manual" ? `${name} 已请回,凭证已更新;请用新的启动指令让它重新注册。` : `${name} 已请回,续接原会话。`;
1062
1062
  } else {
1063
1063
  await request_json(`/api/rooms/${room_id}/members/${member_id}${action === "interrupt" ? "/interrupt" : ""}`,
1064
- action === "interrupt" ? {} : { action, ...(role ? { role } : {}) });
1064
+ action === "interrupt" ? {} : { action, ...(role ? { role } : {}), ...(latest_id !== undefined ? { latest_id } : {}) });
1065
1065
  }
1066
1066
  if (state.room?.id === room_id) await refresh_room();
1067
1067
  } catch (error) { if (state.room?.id === room_id) state.free_error = error.message; }
@@ -1426,10 +1426,18 @@ function read_limits(prefix) {
1426
1426
  }));
1427
1427
  }
1428
1428
 
1429
+ /** Rounds against the limit: the current segment, with rounds the user appended shown as M+N (M is the original limit). */
1430
+ function limit_rounds(limits = {}) {
1431
+ const done = limits.segment_rounds ?? limits.completed_rounds ?? 0;
1432
+ const cap = limits.round_cap ?? limits.max_rounds ?? 0;
1433
+ const total = limits.extra_rounds ? `${limits.max_rounds}+${limits.extra_rounds}` : "";
1434
+ return { done, cap, total, text: cap ? `${done} / ${cap}${total ? `(${total})` : ""} 轮` : `${done} 轮` };
1435
+ }
1436
+
1429
1437
  /** Describe configured boundaries and actual progress without implying completion. */
1430
1438
  function limits_label(limits = {}) {
1431
1439
  const finished = state.room?.phase === "concluded";
1432
- const rounds = limits.max_rounds ? `${limits.completed_rounds || 0} / ${limits.max_rounds} 轮` : `${limits.completed_rounds || 0} 轮 · 未设轮次上限`;
1440
+ const rounds = limits.round_cap || limits.max_rounds ? limit_rounds(limits).text : `${limits.completed_rounds || 0} 轮 · 未设轮次上限`;
1433
1441
  const duration = limits.max_minutes ? `总时长 ${limits.max_minutes} 分钟` : "未设总时长";
1434
1442
  const progress = finished ? "讨论已收束" : !limits.started_at ? "尚未计时" : `已用 ${Math.floor((limits.elapsed_seconds || 0) / 60)} 分钟${limits.remaining_seconds == null ? "" : ` · 剩余 ${Math.ceil(limits.remaining_seconds / 60)} 分钟`}`;
1435
1443
  const status = finished ? "" : limits.closing ? "正在收束" : limits.reached ? "已达上限,停止新增安排" : "";
@@ -2330,6 +2338,38 @@ async function skip_turn() {
2330
2338
  if (state.room?.id === room_id) show_toast(resume ? "已跳过本次发言并恢复讨论,等待主持人继续安排" : "已跳过本次发言,等待主持人继续安排");
2331
2339
  }
2332
2340
 
2341
+ /** Resume the automatic run after a user decision made while paused or just reopened. */
2342
+ async function resume_after_limit_decision() {
2343
+ const path = `/api/rooms/${state.room.id}/runtime`;
2344
+ const runtime = await request_json(path);
2345
+ await request_json(path, { action: "start", mode: "automatic", revision: runtime.revision, deadline: 0 });
2346
+ }
2347
+
2348
+ /** Append rounds after a limit pause: counting restarts from now and the board shows M+N. */
2349
+ async function extend_rounds(rounds) {
2350
+ const room_id = state.room.id;
2351
+ await request_json(`/api/rooms/${room_id}/limits`, { actor: "用户", action: "extend", rounds });
2352
+ await resume_after_limit_decision();
2353
+ await refresh_room();
2354
+ show_toast(`已追加 ${rounds} 轮并重启讨论`);
2355
+ }
2356
+
2357
+ /** Ask the host for a summary of the paused discussion; it ends in the concluded state. */
2358
+ async function request_summary() {
2359
+ await request_json(`/api/rooms/${state.room.id}/limits`, { actor: "用户", action: "summarize" });
2360
+ await resume_after_limit_decision();
2361
+ await refresh_room();
2362
+ show_toast("已请主持人总结,总结后讨论收束");
2363
+ }
2364
+
2365
+ /** Reopen a concluded discussion with a fresh round and time budget; history stays. */
2366
+ async function reopen_discussion({ max_rounds, max_minutes }) {
2367
+ await request_json(`/api/rooms/${state.room.id}/limits`, { actor: "用户", action: "reopen", max_rounds, max_minutes });
2368
+ await resume_after_limit_decision();
2369
+ await refresh_room();
2370
+ show_toast("讨论已重启,按新的轮次与时长上限继续");
2371
+ }
2372
+
2333
2373
  /** End early with a host summary limited to the configured closing reserve. */
2334
2374
  async function force_close() {
2335
2375
  await request_json(`/api/rooms/${state.room.id}/limits`, { actor: "用户", force: true });
@@ -3267,7 +3307,7 @@ async function execute_view_action(name, value) {
3267
3307
  copy_turn, notify: () => change_turn_status("notified"), cancel_turn: () => change_turn_status("cancelled"), skip_turn,
3268
3308
  take_floor: () => change_floor("take"), return_floor: () => change_floor("return"),
3269
3309
  copy_online: () => copy_context(), copy_cli: () => copy_context("cli"), copy_background,
3270
- choose_runtime, export_runtime, download_member_access, copy_member_access_text, copy_member_join, copy_member_launch: name => copy_member_join(name, true), pause_runtime, check_limits, pause_discussion, restart_discussion, retry_execution, force_close, conclude_discussion,
3310
+ choose_runtime, export_runtime, download_member_access, copy_member_access_text, copy_member_join, copy_member_launch: name => copy_member_join(name, true), pause_runtime, check_limits, pause_discussion, restart_discussion, retry_execution, force_close, conclude_discussion, extend_rounds, request_summary, reopen_discussion,
3271
3311
  save_launch_defaults, launch_current_room, resume_launch, discard_launch, retry_launch_connection, check_launch_connection: load_launch_settings,
3272
3312
  load_room_configuration, save_room_configuration, interrupt_member, change_member, invite_member, set_proxy, copy_online_notice,
3273
3313
  prepare_brainstorm, return_conclusion, convert_suggestion, adopt_recommendation,
@@ -3301,7 +3341,7 @@ window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
3301
3341
  update_profile: update_admin_profile, avatar_from_file: admin_avatar_from_file, validate: validate_admin_form },
3302
3342
  request: request_json, identities: IDENTITIES, display_name, sources: SOURCE_LABELS, phases: PHASE_LABELS,
3303
3343
  runtime_label, execution_label, limits_label, participant_model, participant_access, can_retry_execution,
3304
- turns: TURN_LABELS, turn_label, turn_guidance, floor_status, progress_round, progress_speaker, brief_meta, max_message_length: MAX_MESSAGE_LENGTH, provenance: message_provenance, format_time };
3344
+ turns: TURN_LABELS, turn_label, turn_guidance, floor_status, progress_round, limit_rounds, progress_speaker, brief_meta, max_message_length: MAX_MESSAGE_LENGTH, provenance: message_provenance, format_time };
3305
3345
 
3306
3346
  restore_theme();
3307
3347
  restore_directory_sort();