ai_co_discuss 0.2.32 → 0.2.33

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.
@@ -9,7 +9,7 @@ import { parseArgs } from 'node:util';
9
9
  import { setTimeout as sleep } from 'node:timers/promises';
10
10
  import { atomic_json, deliver_task, remove_agy_projects, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
11
11
  import { read_api_key } from './discussion_api.mjs';
12
- import { suggest_interjection, validate_scheduler } from './scheduler.mjs';
12
+ import { decide_interjection, decide_round, validate_scheduler } from './scheduler.mjs';
13
13
 
14
14
  const POLL_MS = 2000;
15
15
  /** Headless CLIs know their session at start, so the runner enters the execution instead of spending model turns on it. */
@@ -29,6 +29,23 @@ export const CODEX_ADAPTERS = ['codex', 'codex_cli'];
29
29
  const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
30
30
  /** Project files in cwd are read-only unless the launch or participant explicitly grants write. */
31
31
  const PROJECT_ACCESS = ['read', 'write'];
32
+ const ACTIVE_PHASES = ['initial', 'discussing', 'reviewing'];
33
+ const MAX_PLAN_TURNS = 10;
34
+ const CONTEXT_MESSAGES = 12;
35
+ /** Session recorded when smart scheduling performs the host's writes without waking its software. */
36
+ const SCHEDULER_SESSION = 'smart-scheduler';
37
+ const MAX_QUOTE_CHARS = 300;
38
+
39
+ /** The shared prompt for rounds bridged without named follow-ups: experts answer and question each other. */
40
+ export function peer_round_question(round) {
41
+ return `第 ${round} 轮:先读取上一轮其他专家的最新回答,回应你不同意或需要补充的观点;被其他专家 @ 提问时优先回答,也可以 @ 某位专家提出问题。`;
42
+ }
43
+
44
+ /** Ask one expert to answer a user's interjection before the rest of the plan continues. */
45
+ function interjection_question(message) {
46
+ const text = String(message.text || '').replace(/\s+/g, ' ').trim();
47
+ return `用户在 #${message.id} 留言:「${text.length > MAX_QUOTE_CHARS ? `${text.slice(0, MAX_QUOTE_CHARS)}…` : text}」请先读取原文并回应这条留言,再继续你的观点。`;
48
+ }
32
49
 
33
50
  /** Explicit per-room configuration is the only source of software paths and capabilities. */
34
51
  export function load_config(path) {
@@ -180,7 +197,7 @@ export function acquire_lock(directory) {
180
197
  }
181
198
 
182
199
  /** Write a non-secret task separately from its CLI-only credential file; suggestions are smart-scheduling hints for the host. */
183
- export function task_files(config, event, token, binding, suggestions = []) {
200
+ export function task_files(config, event, token, binding, suggestions = [], entered = false) {
184
201
  const id = `event-${event.id}`;
185
202
  const draft_directory = config.draft_directory || `${config.directory}-drafts`;
186
203
  mkdirSync(draft_directory, { recursive: true });
@@ -190,7 +207,7 @@ export function task_files(config, event, token, binding, suggestions = []) {
190
207
  const skills = [join(config.project, '.agents/skills/discuss-participant/SKILL.md')];
191
208
  if (event.role === 'host') skills.push(join(config.project, '.agents/skills/discuss-host/SKILL.md'));
192
209
  const adapter = config.participants[event.participant];
193
- const auto_enter = AUTO_ENTER_ADAPTERS.has(adapter.adapter);
210
+ const auto_enter = entered || AUTO_ENTER_ADAPTERS.has(adapter.adapter);
194
211
  const skills_directory = join(config.project, '.agents', 'skills');
195
212
  // Codex and agy run commands through PowerShell on Windows, whose execution policy blocks npm's codiscuss.ps1 shim.
196
213
  const cli = process.platform === 'win32' && adapter.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
@@ -216,7 +233,7 @@ ${readFileSync(task_path, 'utf8')}
216
233
 
217
234
  /** A running dispatcher retains only the processes it created itself. */
218
235
  export class DiscussionRunner {
219
- constructor(config, store, deliver = deliver_task, log = () => {}) {
236
+ constructor(config, store, deliver = deliver_task, log = () => {}, fetch_impl = fetch) {
220
237
  this.config = config;
221
238
  this.store = store;
222
239
  this.deliver = deliver;
@@ -226,7 +243,7 @@ export class DiscussionRunner {
226
243
  this.running = new Map();
227
244
  this.seen = new Set();
228
245
  this.blocked = new Set();
229
- this.suggestions = new Map();
246
+ this.fetch = fetch_impl;
230
247
  }
231
248
  /** Append one timestamped stage to the event's trace file when CODISCUSS_TRACE=1; tracing must never affect delivery. */
232
249
  trace(id, stage, detail = {}) {
@@ -235,33 +252,34 @@ export class DiscussionRunner {
235
252
  catch { /* Diagnostics only. */ }
236
253
  }
237
254
  /** Reserve with a durable token so a lost HTTP response can be reconciled. */
238
- async dispatch(row, event) {
255
+ async dispatch(row, event, options = {}) {
239
256
  const token = row.token || randomUUID();
240
257
  this.store.record(row.id, { token, status: 'reserving' });
241
258
  this.log(`派发 #${row.id} → ${event.participant}(${event.role},turn ${event.turn_id ?? '-'})`);
242
259
  this.trace(row.id, 'reserve_start');
243
260
  await api(this.config, `/events/${row.id}`, { action: 'reserve', execution_token: token });
244
261
  this.trace(row.id, 'reserve_done');
245
- await this.deliver_reserved({ ...row, token }, event);
262
+ await this.deliver_reserved({ ...row, token }, event, options);
246
263
  }
247
- /** Retry only proven non-delivery, keeping the event, token and task unchanged. */
248
- async deliver_reserved(row, event) {
264
+ /**
265
+ * Retry only proven non-delivery, keeping the event, token and task unchanged. `entered` marks an execution
266
+ * smart scheduling already entered before handing it back, so the software must not enter it again.
267
+ */
268
+ async deliver_reserved(row, event, { suggestions = [], entered: pre_entered = false } = {}) {
249
269
  const adapter = this.config.participants[event.participant];
250
270
  if (!adapter) throw new Error(`未配置 ${event.participant} 的本机接入`);
251
271
  const binding = this.store.binding(event.participant);
252
272
  if (!binding.session_id && adapter.session_id) binding.session_id = adapter.session_id;
253
273
  const attempts = row.attempts + 1;
254
- // Ask before recording the dispatch, so a slow or failed suggestion never leaves an uncertain delivery behind.
255
- const suggestions = event.role === 'host' ? await this.interjection_suggestions() : [];
256
274
  this.store.record(row.id, { status: 'dispatching', sent_at: Date.now(), attempts, error: '', retry_at: 0 });
257
275
  let invoked = false;
258
- let entered = false;
276
+ let entered = pre_entered;
259
277
  try {
260
- const task = task_files(this.config, event, row.token, binding, suggestions);
278
+ const task = task_files(this.config, event, row.token, binding, suggestions, pre_entered);
261
279
  invoked = true;
262
280
  const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
263
281
  api_context: { site: this.config.site, room: this.config.room, token: row.token, home: this.config.api_home,
264
- event_id: row.id, role: event.role, turn_id: event.turn_id, scheduler_suggestions: suggestions },
282
+ event_id: row.id, role: event.role, turn_id: event.turn_id, scheduler_suggestions: suggestions, entered: pre_entered },
265
283
  on_trace: (stage, detail) => this.trace(row.id, stage, detail),
266
284
  on_binding: session => {
267
285
  this.store.bind(event.participant, session);
@@ -283,31 +301,91 @@ export class DiscussionRunner {
283
301
  }
284
302
  }
285
303
  /**
286
- * With smart scheduling on, suggest how to handle each user message the host has not processed yet.
287
- * Suggestions never act on the room; any failure only leaves the host without them.
304
+ * Smart scheduling before waking the host: decide read-only first; only a confident decision reserves the host's
305
+ * execution, enters it and performs the host's own writes with that credential. Returns {done} when the host
306
+ * need not be woken, otherwise the suggestions to hand over. Every decision is recorded for the transcript.
288
307
  */
289
- async interjection_suggestions() {
290
- if (!this.config.scheduler) return [];
308
+ async smart_schedule(row, event) {
309
+ if (!this.config.scheduler) return {};
310
+ let plan;
311
+ try { plan = await this.plan_schedule(); }
312
+ catch (error) { this.log(`智能调度判断失败,交给主持人:${error.message}`); return {}; }
313
+ if (!plan) return {};
314
+ const suggestions = plan.message_id ? [{ message_id: plan.message_id, suggestion: plan.label, confidence: plan.confidence }] : [];
315
+ if (plan.action === 'host') {
316
+ await this.record_decision(plan, false);
317
+ return { suggestions };
318
+ }
319
+ const token = randomUUID();
320
+ this.store.record(row.id, { token, status: 'reserving' });
321
+ await api(this.config, `/events/${row.id}`, { action: 'reserve', execution_token: token });
322
+ try { await api(this.config, `/events/${row.id}`, { action: 'enter', session_id: SCHEDULER_SESSION }, token); }
323
+ catch (error) {
324
+ this.log(`智能调度进入执行失败,交给主持人:${error.message}`);
325
+ await this.deliver_reserved({ ...row, token }, event, { suggestions });
326
+ return { done: true };
327
+ }
328
+ this.store.record(row.id, { status: 'started', last_renew: Date.now() });
291
329
  try {
292
- const room = await api(this.config, '');
293
- if (!room.held_since) return [];
294
- const page = await api(this.config, `/messages?after=${Math.max(0, room.held_since - 11)}`);
295
- const experts = Object.keys(room.experts || {}).filter(name => name !== room.host);
296
- const suggestions = [];
297
- for (const message of page.messages.filter(item => item.id >= room.held_since && item.author === '用户')) {
298
- if (!this.suggestions.has(message.id)) {
299
- const suggestion = await suggest_interjection(this.config.api_home, this.config.scheduler,
300
- { title: room.title, context: page.messages, message, host: room.host, experts });
301
- this.suggestions.set(message.id, suggestion);
302
- this.log(`智能调度建议 #${message.id}:${suggestion ? `${suggestion.suggestion}(置信度 ${suggestion.confidence})` : '无可用建议'}`);
303
- }
304
- if (this.suggestions.get(message.id)) suggestions.push(this.suggestions.get(message.id));
305
- }
306
- return suggestions;
330
+ await this.apply_schedule(plan, token);
331
+ await this.record_decision(plan, true);
332
+ this.log(`智能调度 #${row.id}:${plan.label}(置信度 ${plan.confidence})`);
307
333
  } catch (error) {
308
- this.log(`智能调度建议失败,主持人照常处理:${error.message}`);
309
- return [];
334
+ this.log(`智能调度执行失败,交给主持人:${error.message}`);
335
+ await this.record_decision({ ...plan, label: `${plan.label};执行未成功,已交给主持人` }, false);
336
+ await this.deliver_reserved({ ...row, token }, event, { suggestions, entered: true });
310
337
  }
338
+ return { done: true };
339
+ }
340
+ /** Read the room and decide one interjection, or the next round when named follow-ups are off. Null leaves the host alone. */
341
+ async plan_schedule() {
342
+ const room = await api(this.config, '');
343
+ if (room.limits?.reached || !ACTIVE_PHASES.includes(room.phase)) return null;
344
+ if (room.current_turn && ['pending', 'notified'].includes(room.current_turn.status)) return null;
345
+ const page = await api(this.config, `/messages?after=${Math.max(0, room.latest_id - CONTEXT_MESSAGES)}`);
346
+ const experts = Object.keys(room.experts || {});
347
+ const context = { title: room.title, context: page.messages, host: room.host };
348
+ if (room.held_since) {
349
+ const pending = page.messages.filter(item => item.id >= room.held_since && item.author === '用户');
350
+ // Topic edits and several messages at once need the host's judgement.
351
+ if (pending.length !== 1) return null;
352
+ const message = pending[0];
353
+ const decision = await decide_interjection(this.config.api_home, this.config.scheduler,
354
+ { ...context, message, experts: experts.filter(name => name !== room.host) }, this.fetch);
355
+ const plan = { ...decision, kind: 'interjection', message_id: message.id, after_message_id: message.id };
356
+ if (decision.action === 'ask') {
357
+ return { ...plan, turns: [{ expert: decision.expert, question: interjection_question(message) },
358
+ ...room.queued_turns.map(({ expert, question }) => ({ expert, question }))].slice(0, MAX_PLAN_TURNS) };
359
+ }
360
+ if (decision.action === 'note' && room.queued_turns.length) return { ...plan, continue_plan: true };
361
+ // A note after the plan ran out falls through to deciding the next round.
362
+ if (decision.action !== 'note') return plan;
363
+ }
364
+ if (room.named_followup || room.queued_turns.length || !room.rounds?.at(-1)?.completed) return null;
365
+ const decision = await decide_round(this.config.api_home, this.config.scheduler,
366
+ { ...context, experts, last_speaker: room.current_turn?.expert }, this.fetch);
367
+ const plan = { ...decision, kind: 'round', after_message_id: room.latest_id };
368
+ if (decision.action !== 'continue') return plan;
369
+ const round = room.completed_rounds + 1;
370
+ const order = [decision.first, ...experts.filter(name => name !== decision.first)].slice(0, MAX_PLAN_TURNS);
371
+ return { ...plan, phase: room.phase === 'initial' ? 'discussing' : undefined,
372
+ turns: order.map(expert => ({ expert, question: peer_round_question(round) })) };
373
+ }
374
+ /** Perform the host's writes for a decided plan under its own execution credential. */
375
+ async apply_schedule(plan, token) {
376
+ const room = await api(this.config, '');
377
+ const actor = room.host;
378
+ if (room.floor.holder !== actor) await api(this.config, '/floor', { actor, speaker: actor, action: 'take', revision: room.floor.revision }, token);
379
+ if (plan.phase) await api(this.config, '/topic', { actor, phase: plan.phase }, token);
380
+ if (plan.continue_plan) await api(this.config, '/turns/continue', { actor }, token);
381
+ else await api(this.config, '/turns', { actor, turns: plan.turns, submission_id: randomUUID() }, token);
382
+ }
383
+ /** Show the decision in the transcript; a failed record never affects the discussion. */
384
+ async record_decision(plan, applied) {
385
+ try {
386
+ await api(this.config, '/scheduler-decisions', { kind: plan.kind, label: plan.label, confidence: plan.confidence ?? null,
387
+ applied, provider: this.config.scheduler.provider, after_message_id: plan.after_message_id || 0 });
388
+ } catch (error) { this.log(`智能调度决策记录未保存:${error.message}`); }
311
389
  }
312
390
  /** Enter once per delivery; a failure is recorded so reconcile revokes the execution. */
313
391
  async enter(row, session_id) {
@@ -431,7 +509,8 @@ export class DiscussionRunner {
431
509
  if (next && !this.running.get(next.event.participant)) {
432
510
  const fresh = await api(this.config, `/events/${next.row.id}`, { action: 'view' });
433
511
  if (fresh.status === 'pending') {
434
- await this.dispatch(next.row, fresh);
512
+ const scheduled = fresh.role === 'host' ? await this.smart_schedule(next.row, fresh) : {};
513
+ if (!scheduled.done) await this.dispatch(next.row, fresh, scheduled);
435
514
  }
436
515
  else this.store.record(next.row.id, { status: fresh.status });
437
516
  }
@@ -69,15 +69,13 @@ export function free_task_files(config, member, execution, binding) {
69
69
  const host_rule = member.role === 'host'
70
70
  ? `你是本房间的主持人:purpose 为 route 的请求是用户未点名的消息,handoff 表示你派出的成员都已结束。先用 ${cli} room view ${config.room} 查看成员与状态,再决定由谁处理;需要派活时在回答中加 --to 成员名(可重复,不能点名自己),被点名的成员都结束后你会再被唤醒一次。不需要他人时不加 --to,本次即结束。只派分给各成员的文件或模块,避免多人改同一文件。`
71
71
  : '';
72
- const scheduled_rule = execution.requests.some(item => item.purpose === 'scheduled')
73
- ? 'purpose 为 scheduled 的消息用户没有点名,是智能调度判断交给你的;若它其实需要主持人拆分或协调,简短说明后结束即可。' : '';
74
72
  atomic_json(task_path, { site: config.site, room: config.room, mode: 'free', member: member.name,
75
73
  execution_id: execution.id, request_messages: execution.requests.map(item => item.message_id),
76
74
  request_purposes: execution.requests.map(item => item.purpose || 'ask'), role: member.role || 'member',
77
75
  read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
78
76
  skills: [join(config.project, '.agents/skills/discuss-participant/SKILL.md')], cli, subcommands: TASK_SUBCOMMANDS,
79
77
  access_file: access_path,
80
- instructions: `自由协作房间:用户点名了你(${messages})。先用 ${cli} message list ${config.room} --after ${execution.read_until} --access-file 凭据路径 读取新消息,按时间顺序处理,后面的消息可能修正前面的,以最新指令为准。${host_rule}${scheduled_rule}完成后用 ${cli} message post ${config.room} --as ${member.name} --kind chat --execution ${execution.id} --body-file 草稿 --submission-id 新UUID --access-file 凭据路径 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 --execution 改作普通发言。草稿只写入 draft_directory;${project_rule}不读取或打印凭据内容,不建定时任务。` });
78
+ instructions: `自由协作房间:用户点名了你(${messages})。先用 ${cli} message list ${config.room} --after ${execution.read_until} --access-file 凭据路径 读取新消息,按时间顺序处理,后面的消息可能修正前面的,以最新指令为准。${host_rule}完成后用 ${cli} message post ${config.room} --as ${member.name} --kind chat --execution ${execution.id} --body-file 草稿 --submission-id 新UUID --access-file 凭据路径 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 --execution 改作普通发言。草稿只写入 draft_directory;${project_rule}不读取或打印凭据内容,不建定时任务。` });
81
79
  const prompt = `用户在自由协作房间点名了你(${member.name}),对应消息 ${messages}。请先读取任务文件:${task_path},按其中说明读取新消息、完成请求,并用 ${cli} message post 带 --execution ${execution.id} 提交一次回答后结束。命令只用任务内 subcommands 列出的 ${cli} 子命令;${project_rule}不读取凭据内容。`;
82
80
  return { id, participant: member.name, directory: config.directory, draft_directory,
83
81
  skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt };
@@ -101,6 +99,7 @@ export class FreeRoomRunner {
101
99
  this.log = log;
102
100
  this.fetch = fetch_impl;
103
101
  this.routed = new Set();
102
+ this.decisions = new Set();
104
103
  this.children = new Set();
105
104
  this.members = new Map();
106
105
  this.exited = new Set();
@@ -139,10 +138,10 @@ export class FreeRoomRunner {
139
138
  }
140
139
  /** One bounded iteration: reconcile live executions, then deliver idle members with open requests. */
141
140
  async tick() {
142
- let inbox = await api(this.config, '/inbox');
141
+ const inbox = await api(this.config, '/inbox');
143
142
  const room = await api(this.config, '');
144
- // Re-read after smart scheduling moved a request, so the host is not woken for it in this same pass.
145
- if (this.config.scheduler && !inbox.archived && await this.route_unnamed(inbox, room)) inbox = await api(this.config, '/inbox');
143
+ // Smart scheduling runs beside delivery: the host gets the message now, a recommendation may follow.
144
+ if (this.config.scheduler && !inbox.archived) this.recommend_unnamed(inbox, room);
146
145
  this.room_title = room.title;
147
146
  const active = new Map(inbox.executions.map(item => [item.id, item]));
148
147
  const present = new Set(inbox.members.map(member => member.name));
@@ -158,36 +157,33 @@ export class FreeRoomRunner {
158
157
  }
159
158
  }
160
159
  /**
161
- * Smart scheduling: give each unnamed message waiting for the host directly to one woken member when the
162
- * provider is confident. Each message is decided once; a failure or low confidence leaves it with the host.
160
+ * Smart scheduling: for each new unnamed message routed to the host, ask the provider in the background whether
161
+ * another woken member suits it better, and post a recommendation the user may adopt. Nothing waits on it.
163
162
  */
164
- async route_unnamed(inbox, room) {
163
+ recommend_unnamed(inbox, room) {
165
164
  const host = inbox.members.find(member => member.role === 'host');
166
165
  const waiting = host?.pending.filter(request => request.purpose === 'route' && !this.routed.has(request.id)) || [];
167
- const members = inbox.members.filter(member => member !== host && this.config.participants[member.name]).map(member => ({ name: member.name,
166
+ const members = inbox.members.filter(member => member !== host && this.config.participants[member.name]).map(member => ({ id: member.id, name: member.name,
168
167
  description: member_description(member.name, this.config.participants[member.name]) }));
169
- let moved = false;
170
168
  for (const request of waiting) {
171
169
  this.routed.add(request.id);
172
170
  if (!members.length) continue;
173
- try {
174
- const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - 11)}`);
175
- const message = page.messages.find(item => item.id === request.message_id);
176
- if (!message) continue;
177
- const decision = await route_free_message(this.config.api_home, this.config.scheduler,
178
- { title: room.title, context: page.messages, message, host: host.name, members }, this.fetch);
179
- this.log(`智能调度 #${message.id}:${decision.member ? `派给 ${decision.member}` : '交回主持人'}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
180
- if (!decision.member) continue;
181
- const target = inbox.members.find(member => member.name === decision.member);
182
- await api(this.config, `/requests/${request.id}`, { action: 'schedule', member_id: target.id,
183
- provider: this.config.scheduler.provider, confidence: decision.confidence });
184
- moved = true;
185
- } catch (error) {
186
- // A request the host already took, or any provider failure, simply stays with the host.
187
- this.log(`智能调度 #${request.message_id} 未改派,交回主持人:${error.message}`);
188
- }
171
+ const decision = this.recommend(request, room, host, members).catch(error => this.log(`智能调度 #${request.message_id} 未给出推荐:${error.message}`));
172
+ this.decisions.add(decision);
173
+ decision.finally(() => this.decisions.delete(decision));
189
174
  }
190
- return moved;
175
+ }
176
+ /** Decide one message and record a confident recommendation; the host's handling is unaffected either way. */
177
+ async recommend(request, room, host, members) {
178
+ const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - 11)}`);
179
+ const message = page.messages.find(item => item.id === request.message_id);
180
+ if (!message) return;
181
+ const decision = await route_free_message(this.config.api_home, this.config.scheduler,
182
+ { title: room.title, context: page.messages, message, host: host.name, members }, this.fetch);
183
+ this.log(`智能调度 #${message.id}:${decision.member ? `推荐 ${decision.member}` : '不推荐,交主持人'}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
184
+ if (!decision.member) return;
185
+ await api(this.config, '/recommendations', { message_id: message.id, member_id: members.find(member => member.name === decision.member).id,
186
+ provider: this.config.scheduler.provider, confidence: decision.confidence });
191
187
  }
192
188
  /** Reserve with a persisted client id before invoking any software. */
193
189
  async start(member, row) {
@@ -85,15 +85,41 @@ export async function route_free_message(home, scheduler, { title, context, mess
85
85
  return { member, choice: answer.choice ?? null, confidence: answer.confidence ?? null };
86
86
  }
87
87
 
88
- /** Suggest how the host might handle one interjection in a discussion; the host decides and may ignore it. */
89
- export async function suggest_interjection(home, scheduler, { title, context, message, host, experts }, fetch_impl) {
88
+ /**
89
+ * Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
90
+ * Anything but a confident note or ask is returned as host.
91
+ */
92
+ export async function decide_interjection(home, scheduler, { title, context, message, host, experts }, fetch_impl) {
90
93
  const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言` })),
91
94
  { value: HOST_CHOICE, description: `由主持人 ${host} 处理:调整目标、方向、安排,或需要主持人决定` },
92
95
  { value: NOTE_CHOICE, description: '仅补充信息:不需要专人回应,按原计划继续' }];
93
96
  const answers = await decide(home, scheduler, scheduler_state(title, context, message), [{ type: 'choice', name: 'handling',
94
97
  instructions: '讨论进行中用户发来这条留言。判断它最适合怎样处理。', choices }], fetch_impl);
95
- const answer = answers.handling || {};
96
- const label = answer.choice === HOST_CHOICE ? '主持人处理' : answer.choice === NOTE_CHOICE ? '仅补充信息,按原计划继续'
97
- : experts.includes(answer.choice) ? `请 ${answer.choice} 回应` : '';
98
- return label ? { message_id: message.id, suggestion: label, confidence: answer.confidence ?? null } : null;
98
+ const { choice, confidence = null } = answers.handling || {};
99
+ const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
100
+ if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, label: '仅补充信息,按原计划继续' };
101
+ if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, label: `先请 ${choice} 回应这条留言` };
102
+ return { action: 'host', confidence, label: '交给主持人处理这条留言' };
103
+ }
104
+
105
+ /**
106
+ * At the end of a round without named follow-ups: continue with another peer round, or wake the host
107
+ * (to conclude, or because the discussion drifted, repeats itself or is stuck). The first speaker is never the last one.
108
+ */
109
+ export async function decide_round(home, scheduler, { title, context, host, experts, last_speaker }, fetch_impl) {
110
+ const candidates = experts.filter(name => name !== last_speaker);
111
+ const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
112
+ const state = [`讨论:${title || ''}`, recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
113
+ const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。判断下一步:专家之间是否还有值得继续交锋的分歧?', choices: [
114
+ { value: 'continue', description: '继续下一轮:仍有未回应的质疑、分歧或新观点' },
115
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:已可收束,或讨论跑偏、重复立场、卡住,需要主持人引导` }] }];
116
+ if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?优先选被其他专家点名追问、最需要回应的人。',
117
+ choices: candidates.map(name => ({ value: name, description: `先请 ${name}` })) });
118
+ const answers = await decide(home, scheduler, state, questions, fetch_impl);
119
+ const { choice, confidence = null } = answers.next || {};
120
+ if (choice !== 'continue' || confidence === null || confidence < ROUTE_CONFIDENCE || !candidates.length) {
121
+ return { action: 'host', confidence, label: '交给主持人:判断是否收束或调整方向' };
122
+ }
123
+ const first = candidates.includes(answers.first?.choice) ? answers.first.choice : candidates[0];
124
+ return { action: 'continue', first, confidence, label: `继续下一轮,先请 ${first} 发言` };
99
125
  }
package/web/app.js CHANGED
@@ -38,7 +38,7 @@ const PLATFORM_IDENTITIES = {
38
38
  };
39
39
  const IDENTITIES = by_platform(PLATFORM_IDENTITIES, (identity, name) => ({ ...identity, label: name, description: "AI 实例" }));
40
40
  const state = { rooms: [], folders: [], room: null, page: 1, pages: 1, query: "", mention_only: false, free_notice: "", free_error: "",
41
- free_proxy: "", create_parent: null,
41
+ free_proxy: "", create_parent: null, named_followup: false,
42
42
  archive_view: false, directory_sort: "manual", management: null, dragged_room: null, folder_moves: new Map(), folder_version: 0,
43
43
  reply_to: null, turn_reply_to: null, tab: "topic", refresh_version: 0, context_dirty: false, topic_dirty: false,
44
44
  snapshot_id: 0, pending_submission: null, pending_turn: null, pending_room: null, toast_timer: null,
@@ -841,6 +841,7 @@ function reset_create_form() {
841
841
  state.launch.create = true;
842
842
  state.launch.workspace = {};
843
843
  state.launch.scheduler = {};
844
+ state.named_followup = false;
844
845
  state.launch.profiles = JSON.parse(JSON.stringify(state.launch.saved_profiles || {}));
845
846
  Object.values(state.launch.profiles).forEach(fill_api_key);
846
847
  state.launch.notice = "";
@@ -1342,7 +1343,7 @@ function room_creation_payload(folder_id) {
1342
1343
  // Free rooms carry their task in the conversation, so no opening brief is sent even if one was typed for a discussion.
1343
1344
  if (element("new-room-kind").value === "free") return { ...common, user_request: "", mode: "free", host: common.host || "用户" };
1344
1345
  return { ...common, mode: "brainstorm", background: element("new-room-background").value,
1345
- boundaries: element("new-room-boundaries").value, runtime_mode: "automatic", limits: read_limits("new-room"),
1346
+ boundaries: element("new-room-boundaries").value, runtime_mode: "automatic", limits: read_limits("new-room"), named_followup: state.named_followup,
1346
1347
  ...(state.create_parent ? { parent_room_id: state.create_parent } : {}) };
1347
1348
  }
1348
1349
 
@@ -1758,6 +1759,12 @@ function creation_launch_options() {
1758
1759
  return { ...launch_options(), ...(enabled && provider && model && api_key_ref ? { scheduler: { provider, model, api_key_ref } } : {}) };
1759
1760
  }
1760
1761
 
1762
+ /** Choose whether the new discussion's host writes named follow-ups for each expert between rounds. */
1763
+ function write_named_followup(value) {
1764
+ state.named_followup = Boolean(value);
1765
+ publish_view();
1766
+ }
1767
+
1761
1768
  /** Change the smart scheduling draft; a new provider starts from its preset model and any key saved for its root. */
1762
1769
  function write_scheduler(field, value) {
1763
1770
  const scheduler = state.launch.scheduler;
@@ -2260,6 +2267,13 @@ async function convert_suggestion({ message_id, member_id }) {
2260
2267
  await refresh_room();
2261
2268
  }
2262
2269
 
2270
+ /** Adopt smart scheduling's recommendation: interrupt the host now, or ask the member once the host is done. */
2271
+ async function adopt_recommendation({ message_id, mode }) {
2272
+ const room_id = state.room.id;
2273
+ await request_json(`/api/rooms/${room_id}/recommendations/${message_id}`, { action: "adopt", mode });
2274
+ if (state.room?.id === room_id) await refresh_room();
2275
+ }
2276
+
2263
2277
  /** A completed creation starts the next form with fresh mode defaults. */
2264
2278
  function reset_creation_mode() {
2265
2279
  state.creation_mode = "brainstorm";
@@ -2869,7 +2883,7 @@ function view_snapshot() {
2869
2883
  free_notice: state.free_notice, free_error: state.free_error, free_proxy: state.free_proxy,
2870
2884
  create_parent: state.create_parent, access: access_snapshot(),
2871
2885
  tab: state.tab, snapshot_id: state.snapshot_id, context_dirty: state.context_dirty,
2872
- theme: document.documentElement.dataset.theme, fields, experts, launch: structuredClone(state.launch) };
2886
+ theme: document.documentElement.dataset.theme, fields, experts, launch: structuredClone(state.launch), named_followup: state.named_followup };
2873
2887
  }
2874
2888
 
2875
2889
  /** Update a shared draft using the same input events as the original controls. */
@@ -3041,7 +3055,7 @@ async function execute_view_action(name, value) {
3041
3055
  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, force_close, conclude_discussion,
3042
3056
  save_launch_defaults, launch_current_room, resume_launch, discard_launch, retry_launch_connection, check_launch_connection: load_launch_settings,
3043
3057
  load_room_configuration, save_room_configuration, interrupt_member, change_member, invite_member, interrupt_free, set_proxy, copy_online_notice,
3044
- prepare_brainstorm, return_conclusion, convert_suggestion,
3058
+ prepare_brainstorm, return_conclusion, convert_suggestion, adopt_recommendation,
3045
3059
  busy: () => { throw new Error("上一个操作仍在处理中,请稍候;长时间无响应请刷新页面"); },
3046
3060
  copy_reading: () => copy_text(`${location.origin}/r/${state.room.id}`), copy: copy_text };
3047
3061
  if (!["refresh", "busy"].includes(name)) state.free_error = "";
@@ -3064,7 +3078,7 @@ function change_view_theme(theme) {
3064
3078
 
3065
3079
  window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
3066
3080
  write_launch: write_launch_field, write_room_connection, write_room_adapter, launch_clients: LAUNCH_CLIENTS,
3067
- launch_model_state, load_launch_models, write_launch_model, save_launch_api_key, write_scheduler, save_scheduler_api_key, check_launch_api, launch_summary, launch_can_set_access, launch_can_bind_session, pick_launch_directory, restore_launch_profile, reset_create_form, empty_discussion_message,
3081
+ launch_model_state, load_launch_models, write_launch_model, save_launch_api_key, write_scheduler, save_scheduler_api_key, write_named_followup, check_launch_api, launch_summary, launch_can_set_access, launch_can_bind_session, pick_launch_directory, restore_launch_profile, reset_create_form, empty_discussion_message,
3068
3082
  start_drag: start_directory_drag,
3069
3083
  attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments,
3070
3084
  write_expert: write_view_expert, add_instance: add_view_instance, platform_of, execute: execute_view_action, theme: change_view_theme,