ai_co_discuss 0.2.42 → 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) {
@@ -363,7 +368,10 @@ export class DiscussionRunner {
363
368
  const experts = Object.keys(room.experts || {});
364
369
  // Only experts the runner can wake are offered to the decision model; manual ones wait for the host.
365
370
  const automatic = experts.filter(name => this.config.participants[name]);
366
- const context = { title: room.title, goal: room.goal, user_request: room.user_request, context: page.messages, host: room.host };
371
+ const last_plan = room.host_arrangements?.at(-1);
372
+ const context = { title: room.title, goal: room.goal, user_request: room.user_request, context: page.messages, host: room.host,
373
+ brief: room.host_brief?.text, focus: room.focus,
374
+ arrangement: last_plan?.turns.map(turn => `${turn.expert}:${String(turn.question).slice(0, 120)}`).join(';') };
367
375
  if (room.held_since) {
368
376
  const pending = page.messages.filter(item => item.id >= room.held_since && item.author === '用户');
369
377
  // Topic edits and several messages at once need the host's judgement.
@@ -391,8 +399,10 @@ export class DiscussionRunner {
391
399
  if (!waiting.length) return skip('round', '本轮未发言的专家都是手动接入,无法自动安排');
392
400
  // An expert the user just skipped is not picked again unless nobody else is left.
393
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', '本轮只剩主持人未发言');
394
404
  const candidates = waiting.filter(name => name !== skipped);
395
- return this.next_speaker_plan(room, context, candidates.length ? candidates : waiting, anchor);
405
+ return this.next_speaker_plan(room, { ...context, spoken: [...spoken] }, candidates.length ? candidates : waiting, anchor);
396
406
  }
397
407
  if (room.named_followup) return skip('round', '点名追问已开启,由主持人安排下一轮');
398
408
  const decision = await decide_round(this.config.api_home, this.config.scheduler,
@@ -432,13 +442,18 @@ export class DiscussionRunner {
432
442
  return { action: 'restore', kind: 'round', confidence: null, label, after_message_id: anchor, turns: plan_turns };
433
443
  }
434
444
  /** Perform the host's writes for a decided plan under its own execution credential. */
435
- async apply_schedule(plan, token) {
445
+ async apply_schedule(plan, token, seen) {
436
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('判断期间房间有新留言或用户操作,放弃应用');
437
448
  const actor = room.host;
438
449
  if (room.floor.holder !== actor) await api(this.config, '/floor', { actor, speaker: actor, action: 'take', revision: room.floor.revision }, token);
439
450
  if (plan.phase) await api(this.config, '/topic', { actor, phase: plan.phase }, token);
440
451
  if (plan.continue_plan) await api(this.config, '/turns/continue', { actor }, token);
441
- 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
+ }
442
457
  }
443
458
  /** Show the decision in the transcript; a failed record never affects the discussion. */
444
459
  async record_decision(plan, applied) {
@@ -13,6 +13,7 @@ const HOST_CHOICE = '__host__';
13
13
  const NOTE_CHOICE = '__note__';
14
14
  const CONTEXT_MESSAGES = 10;
15
15
  const MAX_MESSAGE_CHARS = 600;
16
+ const MAX_BRIEF_CHARS = 1200;
16
17
 
17
18
  /** Normalize a launch's scheduler choice and prove its key exists for that provider's root. */
18
19
  export function validate_scheduler(home, scheduler) {
@@ -50,13 +51,17 @@ export async function decide(home, scheduler, state, questions, fetch_impl = fet
50
51
  const key = read_saved_api_key(home, scheduler.base_url, scheduler.api_key_ref);
51
52
  const request = scheduler_request(scheduler, state, questions);
52
53
  let response;
54
+ const started = Date.now();
53
55
  try {
54
56
  response = await fetch_impl(`${scheduler.base_url}${request.path}`, { method: 'POST', redirect: 'error',
55
57
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
56
58
  body: JSON.stringify(request.body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
57
59
  } catch { throw new Error('智能调度请求超时或连接失败'); }
58
60
  if (!response.ok) throw new Error(api_status_message(response.status));
59
- return scheduler_answers(scheduler, await response.json());
61
+ const answers = scheduler_answers(scheduler, await response.json());
62
+ // Timing rides along without being an answer, so callers iterating answers by question name never see it.
63
+ Object.defineProperty(answers, 'elapsed_ms', { value: Date.now() - started });
64
+ return answers;
60
65
  }
61
66
 
62
67
  /** Send one tiny yes/no question so provider, model and key are checked together before a room relies on them. */
@@ -67,10 +72,19 @@ export async function scheduler_check(home, input, fetch_impl = fetch) {
67
72
  return { ok: true };
68
73
  }
69
74
 
70
- /** What was asked (the instruction and options only, never the room excerpts) and what the provider chose, for the folded record. */
75
+ /** What was asked (the instruction and options only, never the room excerpts), what the provider chose and how long the call took. */
71
76
  function detail_of(questions, answers) {
72
- return questions.map(question => ({ question: question.instructions, choices: question.choices.map(choice => choice.value),
73
- choice: answers[question.name]?.choice ?? null, probabilities: answers[question.name]?.probabilities ?? {} }));
77
+ return questions.map(question => {
78
+ const answer = answers[question.name];
79
+ // A yes/no question is recorded as a two-way choice so the card reads it like any other.
80
+ if (question.type === 'predicate') {
81
+ const yes = typeof answer?.probability === 'number' ? answer.probability : null;
82
+ return { question: question.instructions, choices: ['是', '否'], choice: yes === null ? null : yes >= 0.5 ? '是' : '否',
83
+ probabilities: yes === null ? {} : { 是: yes, 否: Number((1 - yes).toFixed(4)) }, duration_ms: answers.elapsed_ms ?? null };
84
+ }
85
+ return { question: question.instructions, choices: question.choices.map(choice => choice.value),
86
+ choice: answer?.choice ?? null, probabilities: answer?.probabilities ?? {}, duration_ms: answers.elapsed_ms ?? null };
87
+ });
74
88
  }
75
89
 
76
90
  /** Say which option the provider leaned to when it was not sure enough, so the transcript shows how close it was. */
@@ -85,14 +99,16 @@ function excerpt(message) {
85
99
  }
86
100
 
87
101
  /** The room's purpose in the user's words, so a decision can tell drifting from progress. */
88
- function topic_lines(title, goal, user_request) {
89
- return [`房间:${title || ''}`, goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`].filter(Boolean).join('\n');
102
+ function topic_lines(title, goal, user_request, { brief, focus, arrangement, progress } = {}) {
103
+ return [`房间:${title || ''}`, goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
104
+ focus && `当前焦点:${focus}`, brief && `主持简报(含分工与规则):${String(brief).slice(0, MAX_BRIEF_CHARS)}`,
105
+ arrangement && `主持人最近的安排:${arrangement}`, progress].filter(Boolean).join('\n');
90
106
  }
91
107
 
92
108
  /** State shared by both uses: the room, a few earlier messages, and the message to place. */
93
- export function scheduler_state(title, context, message, goal = '', user_request = '') {
109
+ export function scheduler_state(title, context, message, goal = '', user_request = '', extra = {}) {
94
110
  const earlier = context.filter(item => item.id < message.id).slice(-CONTEXT_MESSAGES).map(excerpt);
95
- return [topic_lines(title, goal, user_request), earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
111
+ return [topic_lines(title, goal, user_request, extra), earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
96
112
  }
97
113
 
98
114
  /**
@@ -123,12 +139,12 @@ export async function route_free_message(home, scheduler, { title, goal, context
123
139
  * Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
124
140
  * Anything but a confident note or ask is returned as host.
125
141
  */
126
- export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts }, fetch_impl) {
127
- const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言` })),
128
- { value: HOST_CHOICE, description: `由主持人 ${host} 处理:调整目标、方向、安排,或需要主持人决定` },
129
- { value: NOTE_CHOICE, description: '仅补充信息:不需要专人回应,按原计划继续' }];
130
- const questions = [{ type: 'choice', name: 'handling', instructions: '讨论进行中用户发来这条留言。结合讨论目标判断它最适合怎样处理。', choices }];
131
- const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, user_request), questions, fetch_impl);
142
+ export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, ...extra }, fetch_impl) {
143
+ const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言:留言是向该专家(或其负责的内容)提问、质疑或补充要求` })),
144
+ { value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,或同时涉及多位专家` },
145
+ { value: NOTE_CHOICE, description: '仅补充信息:留言只是旁注、信息或鼓励,不要求任何人回应,按当前安排继续' }];
146
+ const questions = [{ type: 'choice', name: 'handling', instructions: '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。', choices }];
147
+ const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, user_request, extra), questions, fetch_impl);
132
148
  const detail = detail_of(questions, answers);
133
149
  const choice = answers.handling?.choice;
134
150
  const confidence = certainty(answers.handling);
@@ -143,20 +159,28 @@ export async function decide_interjection(home, scheduler, { title, goal, user_r
143
159
  * Mid-round with no queued turn left: pick the next speaker among the experts who can be woken and have not spoken
144
160
  * this round, or hand back to the host when unsure or when the discussion needs steering.
145
161
  */
146
- export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates }, fetch_impl) {
162
+ export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates, spoken = [], ...extra }, fetch_impl) {
147
163
  const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
148
- const state = [topic_lines(title, goal, user_request), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
149
- const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言` })),
150
- { value: HOST_CHOICE, description: `交给主持人 ${host}:讨论偏离目标、卡住,或需要主持人调整安排` }];
164
+ const progress = `本轮已发言:${spoken.join('、') || '无'};本轮尚未发言:${candidates.join('、')}。`;
165
+ const state = [topic_lines(title, goal, user_request, { ...extra, progress }), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
166
+ const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言:简报的分工或接龙顺序轮到 ${name},或刚才的发言点名追问了 ${name}` })),
167
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:没有明确该谁发言,或需要主持人重新安排` }];
168
+ // Having not spoken yet is no reason to speak: the host's plan may have ended on purpose, so the scheduler acts only on an explicit obligation.
151
169
  const questions = [{ type: 'choice', name: 'speaker',
152
- instructions: '本轮还有专家没发言,安排被取消或用完。结合已有发言判断下一位应请谁发言:优先选被点名追问、最能回应当前焦点的人。', choices }];
170
+ instructions: '主持人预排的安排已用完或被取消。对照主持简报的分工、当前焦点和主持人最近的安排,判断下一位应请谁发言。', choices },
171
+ { type: 'predicate', name: 'obligation',
172
+ instructions: `是否存在明确依据要求 ${candidates.join('、')} 之一接着发言:简报分工或流程规定轮到其人,或最近发言点名追问了其人?仅仅是“本轮还没发言”不算依据。` }];
153
173
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
154
174
  const detail = detail_of(questions, answers);
155
175
  const choice = answers.speaker?.choice;
156
176
  const confidence = certainty(answers.speaker);
157
- if (candidates.includes(choice) && confidence !== null && confidence >= ROUTE_CONFIDENCE) {
177
+ const obligation = answers.obligation?.probability;
178
+ if (candidates.includes(choice) && confidence !== null && confidence >= ROUTE_CONFIDENCE && obligation >= ROUTE_CONFIDENCE) {
158
179
  return { action: 'next', expert: choice, confidence, detail, label: `本轮继续,请 ${choice} 接着发言` };
159
180
  }
181
+ if (typeof obligation === 'number' && obligation < ROUTE_CONFIDENCE) {
182
+ return { action: 'host', confidence, detail, label: `有明确依据的概率 ${obligation.toFixed(2)},未达 ${ROUTE_CONFIDENCE.toFixed(2)},交给主持人安排` };
183
+ }
160
184
  return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && confidence >= ROUTE_CONFIDENCE ? '建议由主持人调整安排,交给主持人' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
161
185
  }
162
186
 
@@ -164,14 +188,14 @@ export async function decide_next_speaker(home, scheduler, { title, goal, user_r
164
188
  * At the end of a round without named follow-ups: continue with another peer round, or wake the host
165
189
  * (to conclude, or because the discussion drifted, repeats itself or is stuck). The first speaker is never the last one.
166
190
  */
167
- export async function decide_round(home, scheduler, { title, goal, user_request, context, host, experts, last_speaker }, fetch_impl) {
191
+ export async function decide_round(home, scheduler, { title, goal, user_request, context, host, experts, last_speaker, ...extra }, fetch_impl) {
168
192
  const candidates = experts.filter(name => name !== last_speaker);
169
193
  const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
170
- const state = [topic_lines(title, goal, user_request), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
171
- const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。结合讨论目标判断:是否值得让大家继续下一轮,互相回应、追问或补充?', choices: [
172
- { value: 'continue', description: '继续下一轮:还有可以互相回应、追问、补充或展开的内容' },
173
- { value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或讨论偏离目标、反复重复、卡住,需要主持人引导` }] }];
174
- if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?优先选被其他专家点名追问、最需要回应的人。',
194
+ const state = [topic_lines(title, goal, user_request, extra), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
195
+ const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。对照讨论目标、主持简报与当前焦点判断:下一轮是否还有明确的内容可以让大家互相回应、追问或补充?', choices: [
196
+ { value: 'continue', description: '继续下一轮:最近发言里有未回应的分歧、追问或可展开的点,或简报规定的流程还没走完' },
197
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或最近发言在重复、偏离目标、卡住,需要主持人引导` }] }];
198
+ if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?按简报分工或接龙顺序判断;没有规定时选被其他专家点名追问、最需要回应的人。',
175
199
  choices: candidates.map(name => ({ value: name, description: `先请 ${name}` })) });
176
200
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
177
201
  const detail = detail_of(questions, answers);
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();