ai_co_discuss 0.2.70 → 0.2.72

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.
@@ -262,11 +262,74 @@ function tree_killable(child) {
262
262
  return child;
263
263
  }
264
264
 
265
+ const EDIT_TOOLS = /^(Edit|MultiEdit|Write|NotebookEdit|write_file|write_to_file|replace_file_content|multi_replace_file_content)$/;
266
+
267
+ /**
268
+ * What a streamed CLI event says the agent is doing: thinking, command, tool, editing or writing.
269
+ * Returns '' when the event says nothing about it; the page only shows the latest phase.
270
+ */
271
+ export function activity_phase(adapter, event) {
272
+ if (adapter === 'claude') {
273
+ if (event.type === 'system' || event.type === 'user') return 'thinking';
274
+ const block = event.type === 'assistant' ? event.message?.content?.at(-1) : null;
275
+ if (block?.type === 'tool_use') return /^(Bash|PowerShell)$/.test(block.name) ? 'command' : EDIT_TOOLS.test(block.name) ? 'editing' : 'tool';
276
+ return block?.type === 'text' ? 'writing' : block?.type === 'thinking' ? 'thinking' : '';
277
+ }
278
+ if (adapter === 'codex_cli') {
279
+ if (event.type === 'turn.started') return 'thinking';
280
+ const kind = event.item?.type;
281
+ if (event.type === 'item.started') {
282
+ return { command_execution: 'command', file_change: 'editing', mcp_tool_call: 'tool', web_search: 'tool', reasoning: 'thinking' }[kind] || '';
283
+ }
284
+ if (event.type === 'item.completed') return kind === 'agent_message' ? 'writing' : 'thinking';
285
+ return '';
286
+ }
287
+ if (event.event === 'init') return 'thinking';
288
+ const step = event.step_update;
289
+ if (!step) return '';
290
+ if (step.step_type === 'agent_response' && step.state === 'ACTIVE') return 'writing';
291
+ if (step.step_type !== 'tool' || ['DONE', 'ERROR'].includes(step.state)) return 'thinking';
292
+ return step.tool_name === 'run_command' ? 'command' : EDIT_TOOLS.test(step.tool_name || '') ? 'editing' : 'tool';
293
+ }
294
+
295
+ /**
296
+ * Text the agent wrote to the user in one streamed event, such as a progress remark between tool calls.
297
+ * agy streams a response in deltas, so its text is collected per step in `responses` and returned when the step ends.
298
+ */
299
+ export function progress_note(adapter, event, responses = new Map()) {
300
+ if (adapter === 'claude') {
301
+ if (event.type !== 'assistant') return '';
302
+ return (event.message?.content || []).filter(block => block.type === 'text').map(block => block.text).join('\n').trim();
303
+ }
304
+ if (adapter === 'codex_cli') return event.type === 'item.completed' && event.item?.type === 'agent_message' ? String(event.item.text || '').trim() : '';
305
+ const step = event.step_update;
306
+ if (step?.step_type !== 'agent_response') return '';
307
+ responses.set(step.step_index, (responses.get(step.step_index) || '') + (step.text_delta || ''));
308
+ if (step.state === 'ACTIVE') return '';
309
+ const text = responses.get(step.step_index).trim();
310
+ responses.delete(step.step_index);
311
+ return text;
312
+ }
313
+
314
+ /** Report the phase and any progress note of each complete JSON line; malformed lines are left to the caller's own parsing. */
315
+ function watch_activity(adapter, stream, task) {
316
+ if (!task.on_activity && !task.on_note) return;
317
+ createInterface({ input: stream, crlfDelay: Infinity }).on('line', line => {
318
+ let event;
319
+ try { event = JSON.parse(line); } catch { return; }
320
+ const phase = activity_phase(adapter, event);
321
+ if (phase) task.on_activity?.(phase);
322
+ const note = progress_note(adapter, event);
323
+ if (note) task.on_note?.(note);
324
+ });
325
+ }
326
+
265
327
  async function claude_delivery(config, binding, task, spawn_process = spawn) {
266
328
  const session_id = binding.session_id || randomUUID();
267
329
  try { bind_access(task.access_path, session_id); }
268
330
  catch (error) { throw not_sent(error); }
269
- const args = ['-p', '--output-format', 'json', binding.session_id ? '--resume' : '--session-id', session_id,
331
+ // Streamed events show the turn's progress; the final line is still the result that decides success.
332
+ const args = ['-p', '--output-format', 'stream-json', '--verbose', binding.session_id ? '--resume' : '--session-id', session_id,
270
333
  ...(config.model ? ['--model', config.model] : []), ...(config.effort ? ['--effort', config.effort] : []), ...claude_permissions(config, task)];
271
334
  let child;
272
335
  try {
@@ -281,6 +344,7 @@ async function claude_delivery(config, binding, task, spawn_process = spawn) {
281
344
  let output = '';
282
345
  child.stdout.on('data', chunk => { output += chunk; task.on_output(log_path, chunk); });
283
346
  child.stderr.on('data', chunk => task.on_output(log_path, chunk));
347
+ watch_activity('claude', child.stdout, task);
284
348
  child.once('close', code => {
285
349
  if (!spawned) return;
286
350
  const error = claude_exit_error(code, output);
@@ -445,7 +509,7 @@ function cli_exit_error(state, code, signal) {
445
509
 
446
510
  /** Parse complete UTF-8 JSON lines while keeping raw output in the private task log. */
447
511
  function monitor_cli(child, config, binding, task) {
448
- const state = { child, session_id: binding.session_id || '', initialized: false, completed: false, error: '', diagnostic: '' };
512
+ const state = { child, session_id: binding.session_id || '', initialized: false, completed: false, error: '', diagnostic: '', responses: new Map() };
449
513
  const log_path = resolve(task.directory, `${config.adapter}-${task.id}.log`);
450
514
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
451
515
  const max_run_ms = Number(process.env.CODISCUSS_CLI_MAX_MS) || CLI_MAX_RUN_MS;
@@ -463,6 +527,10 @@ function monitor_cli(child, config, binding, task) {
463
527
  try { event = JSON.parse(line); }
464
528
  catch { state.diagnostic = 'CLI 输出中存在无效 JSON,详情见本机日志'; return; }
465
529
  trace_cli_event(event, task);
530
+ const phase = activity_phase(config.adapter, event);
531
+ if (phase) task.on_activity?.(phase);
532
+ const note = progress_note(config.adapter, event, state.responses);
533
+ if (note) task.on_note?.(note);
466
534
  try { cli_event(config.adapter, state, event, task); }
467
535
  catch (error) { state.error = error.message; child.kill(); }
468
536
  });
@@ -462,7 +462,7 @@ export class DiscussionRunner {
462
462
  async record_decision(plan, applied) {
463
463
  try {
464
464
  await api(this.config, '/scheduler-decisions', { kind: plan.kind, label: plan.label, confidence: plan.confidence ?? null,
465
- applied, provider: this.config.scheduler.provider, after_message_id: plan.after_message_id || 0, detail: plan.detail });
465
+ applied, provider: this.config.scheduler.provider, after_message_id: plan.after_message_id || 0, detail: plan.detail, input: plan.input });
466
466
  } catch (error) { this.log(`智能调度决策记录未保存:${error.message}`); }
467
467
  }
468
468
  /** Enter once per delivery; a failure is recorded so reconcile revokes the execution. */
@@ -5,10 +5,16 @@ import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
5
5
  import { basename, join, resolve } from 'node:path';
6
6
  import { atomic_json, deliver_task, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
7
7
  import { api } from './discussion_runner.mjs';
8
- import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, route_free_message } from './scheduler.mjs';
8
+ import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, route_free_message } from './scheduler.mjs';
9
9
 
10
10
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
11
11
  const RENEW_MS = 30_000;
12
+ /** A changed phase is reported early, but no more often than this, since each report refreshes open pages. */
13
+ const ACTIVITY_REPORT_MS = 10_000;
14
+ /** Progress notes kept per execution, each cut to this length, and how many one renewal carries; the website enforces the same. */
15
+ const MAX_EXECUTION_NOTES = 50;
16
+ const MAX_NOTE_LENGTH = 500;
17
+ const MAX_NOTES_PER_RENEW = 10;
12
18
  const MAX_DELIVERY_ATTEMPTS = 3;
13
19
  const LIVE = ['reserving', 'delivering', 'sidecar_pending', 'running'];
14
20
  const MAX_HASHED_BYTES = 5 * 1024 * 1024;
@@ -121,6 +127,8 @@ export class FreeRoomRunner {
121
127
  this.log = log;
122
128
  this.fetch = fetch_impl;
123
129
  this.routed = new Set();
130
+ // Group requests (@全体成员) being arranged; a failed arrangement leaves the set so the next tick retries.
131
+ this.grouped = new Set();
124
132
  this.decisions = new Set();
125
133
  // Routed requests whose decision is still pending: their host waits, so one pick replaces the host's start.
126
134
  this.routing = new Set();
@@ -128,6 +136,9 @@ export class FreeRoomRunner {
128
136
  this.members = new Map();
129
137
  this.exited = new Set();
130
138
  this.snapshots = new Map();
139
+ // Execution id -> { phase, at, sent_phase, sent_at, notes, next_seq }: what the member's software last showed it doing,
140
+ // and the progress notes not yet on the website.
141
+ this.activity = new Map();
131
142
  this.context_errors = new Map();
132
143
  this.adopt(config);
133
144
  this.store.db.exec(`CREATE TABLE IF NOT EXISTS free_deliveries(member TEXT PRIMARY KEY, client_id TEXT NOT NULL,
@@ -166,6 +177,7 @@ export class FreeRoomRunner {
166
177
  const room = await api(this.config, '');
167
178
  // Smart scheduling runs beside delivery: the host waits for its answer (or failure) before starting, see `routing`.
168
179
  if (this.config.scheduler && !inbox.archived) this.recommend_unnamed(inbox, room);
180
+ if (!inbox.archived) this.arrange_groups(inbox, room);
169
181
  this.room_title = room.title;
170
182
  const active = new Map(inbox.executions.map(item => [item.id, item]));
171
183
  const present = new Set(inbox.members.map(member => member.name));
@@ -177,7 +189,7 @@ export class FreeRoomRunner {
177
189
  for (const member of inbox.members) {
178
190
  const row = this.row(member.name);
179
191
  if (row && LIVE.includes(row.status)) await this.reconcile(member, row, active, inbox.execution_minutes);
180
- else if (!inbox.archived && member.pending.length && !this.members.get(member.name)?.size
192
+ else if (!inbox.archived && member.pending.some(request => request.purpose !== 'group') && !this.members.get(member.name)?.size
181
193
  && !member.pending.some(request => this.routing.has(request.id))) await this.start(member, row);
182
194
  }
183
195
  }
@@ -202,6 +214,39 @@ export class FreeRoomRunner {
202
214
  decision.finally(() => this.decisions.delete(decision));
203
215
  }
204
216
  }
217
+ /** For each message naming everyone, have them answer at once, or one after another when smart scheduling says so. */
218
+ arrange_groups(inbox, room) {
219
+ const everyone = [...inbox.members, ...(inbox.observers || [])];
220
+ for (const request of everyone.flatMap(member => member.pending).filter(item => item.purpose === 'group' && !this.grouped.has(item.id))) {
221
+ this.grouped.add(request.id);
222
+ const arranged = (this.config.scheduler ? this.mark_routing(request.message_id, true).then(() => this.order_group(request, room, everyone))
223
+ .finally(() => this.mark_routing(request.message_id, false)) : this.apply_group(request.message_id, { action: 'all' }))
224
+ .catch(error => { this.grouped.delete(request.id); this.log(`全体成员 #${request.message_id} 未能安排:${error.message}`); });
225
+ this.decisions.add(arranged);
226
+ arranged.finally(() => this.decisions.delete(arranged));
227
+ }
228
+ }
229
+ /** Ask the scheduler how everyone answers; a failed call still has them answer at once. */
230
+ async order_group(request, room, everyone) {
231
+ const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES - 1)}`);
232
+ const message = page.messages.find(item => item.id === request.message_id);
233
+ const members = (room.speaking_order?.items || []).map(item => item.name).filter(name => everyone.some(member => member.name === name));
234
+ const decision = await decide_group_order(this.config.api_home, this.config.scheduler,
235
+ { title: room.title, goal: room.goal, context: page.messages, message, members: members.length ? members : everyone.map(member => member.name) }, this.fetch)
236
+ .catch(error => { this.log(`智能调度 #${request.message_id} 未判断全体回答方式:${error.message}`); return { action: 'all' }; });
237
+ await this.apply_group(request.message_id, decision);
238
+ if (decision.detail) {
239
+ const label = decision.action === 'sequential' ? '请全体按发言顺序依次回答' : '请全体同时回答';
240
+ await this.record_decision(request.message_id, decision, label, true);
241
+ }
242
+ }
243
+ /** Replace the host's group request with everyone's requests; a message already arranged elsewhere is left as is. */
244
+ async apply_group(message_id, decision) {
245
+ this.log(`全体成员 #${message_id}:${decision.action === 'sequential' ? '依次回答' : '同时回答'}`);
246
+ await api(this.config, `/recommendations/${message_id}`, { action: decision.action }).catch(error => {
247
+ if (!/HTTP (404|409)/.test(error.message)) throw error;
248
+ });
249
+ }
205
250
  /** Tell the website a message is being analysed so it can show the card; a failed mark never blocks routing. */
206
251
  async mark_routing(message_id, active) {
207
252
  try { await api(this.config, '/scheduler-routing', { message_id, active }); }
@@ -248,7 +293,7 @@ export class FreeRoomRunner {
248
293
  async record_decision(message_id, decision, label, applied) {
249
294
  try {
250
295
  await api(this.config, '/scheduler-decisions', { kind: 'interjection', label, confidence: decision.confidence ?? null,
251
- applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail,
296
+ applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail, input: decision.input,
252
297
  context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE,
253
298
  prompt: ROUTE_PROMPT_VERSION, package: PACKAGE_VERSION } });
254
299
  } catch (error) { this.log(`智能调度 #${message_id} 决策记录未保存:${error.message}`); }
@@ -293,7 +338,9 @@ export class FreeRoomRunner {
293
338
  requests: execution.requests, read_after: execution.read_until },
294
339
  on_binding: session => this.store.bind(member.name, session),
295
340
  on_process: child => this.track(member.name, child),
296
- on_output: (path, chunk) => appendFileSync(path, chunk, { mode: 0o600 }),
341
+ on_output: (path, chunk) => { appendFileSync(path, chunk, { mode: 0o600 }); this.touch(execution.id); },
342
+ on_activity: phase => this.touch(execution.id, phase),
343
+ on_note: text => this.note(execution.id, text),
297
344
  // A stopped process may report after the next execution started; only its own execution takes the result.
298
345
  on_failure: error => { try { if (this.row(member.name)?.execution_id === execution.id) this.save(member.name, { error }); } catch { /* Store may already be closed at shutdown. */ } },
299
346
  on_exit: () => this.exited.add(execution.id),
@@ -321,6 +368,7 @@ export class FreeRoomRunner {
321
368
  }
322
369
  const execution = active.get(row.execution_id);
323
370
  if (!execution) {
371
+ this.activity.delete(row.execution_id);
324
372
  this.stop_member(member.name);
325
373
  return this.save(member.name, { status: 'idle', error: '' });
326
374
  }
@@ -333,11 +381,34 @@ export class FreeRoomRunner {
333
381
  return this.save(member.name, { status: 'idle' });
334
382
  }
335
383
  if (Date.now() >= execution.created_at * 1000 + minutes * 60_000) return this.fail(member.name, execution.id, `超过单次执行时限 ${minutes} 分钟;请核对原会话确认是否已有内容`);
336
- if (Date.now() - row.last_renew >= RENEW_MS) {
337
- await api(this.config, `/executions/${execution.id}`, { action: 'renew' });
384
+ const activity = this.activity.get(execution.id);
385
+ const changed = activity && (activity.phase !== activity.sent_phase || activity.at !== activity.sent_at || activity.notes.length);
386
+ const elapsed = Date.now() - row.last_renew;
387
+ if (elapsed >= RENEW_MS || (changed && elapsed >= ACTIVITY_REPORT_MS)) {
388
+ const notes = activity?.notes.slice(0, MAX_NOTES_PER_RENEW) || [];
389
+ await api(this.config, `/executions/${execution.id}`, { action: 'renew',
390
+ ...(activity ? { activity: activity.phase, activity_at: Math.floor(activity.at / 1000) } : {}), ...(notes.length ? { notes } : {}) });
391
+ if (activity) Object.assign(activity, { sent_phase: activity.phase, sent_at: activity.at, notes: activity.notes.slice(notes.length) });
338
392
  this.save(member.name, { last_renew: Date.now() });
339
393
  }
340
394
  }
395
+ /** Note that the software produced output now, and its phase when the output names one. */
396
+ touch(execution_id, phase) {
397
+ // Updated in place: a renewal in flight marks what it sent on this same record.
398
+ if (!this.activity.has(execution_id)) this.activity.set(execution_id, { phase: '', notes: [], next_seq: 0 });
399
+ const current = this.activity.get(execution_id);
400
+ if (phase) current.phase = phase;
401
+ current.at = Date.now();
402
+ }
403
+ /** Queue one progress note for the next renewal; numbering makes a retried renewal harmless, and notes past the cap are dropped. */
404
+ note(execution_id, text) {
405
+ this.touch(execution_id);
406
+ const current = this.activity.get(execution_id);
407
+ if (current.next_seq >= MAX_EXECUTION_NOTES) return;
408
+ current.next_seq += 1;
409
+ // Cut by UTF-16 units like the website's check, without leaving half of a surrogate pair.
410
+ current.notes.push({ seq: current.next_seq, text: text.slice(0, MAX_NOTE_LENGTH).replace(/[\uD800-\uDBFF]$/, '') });
411
+ }
341
412
  /** Report observed file changes once an execution has ended and its member's processes have exited. */
342
413
  async report_finished(active) {
343
414
  for (const [execution_id, snapshot] of this.snapshots) {
@@ -352,6 +423,7 @@ export class FreeRoomRunner {
352
423
  }
353
424
  /** Record the cause on the website, then stop this member's own processes. */
354
425
  async fail(name, execution_id, reason) {
426
+ this.activity.delete(execution_id);
355
427
  try { await api(this.config, `/executions/${execution_id}`, { action: 'fail', reason }); }
356
428
  catch (error) { if (!/HTTP 409/.test(error.message)) throw error; }
357
429
  this.save(name, { status: 'idle', error: reason });
@@ -12,7 +12,7 @@ export const ROUTE_CONFIDENCE = 0.7;
12
12
  /** Waking everyone or no one costs more when wrong (N runs, or an ignored message), so it needs more certainty. */
13
13
  export const ROUTE_WIDE_CONFIDENCE = 0.85;
14
14
  /** Bump when the routing question or its option descriptions change, so ratings can be compared across wordings. */
15
- export const ROUTE_PROMPT_VERSION = 3;
15
+ export const ROUTE_PROMPT_VERSION = 4;
16
16
  const HOST_CHOICE = '__host__';
17
17
  const NOTE_CHOICE = '__note__';
18
18
  const ALL_CHOICE = '__all__';
@@ -95,6 +95,11 @@ function detail_of(questions, answers) {
95
95
  });
96
96
  }
97
97
 
98
+ /** The shown detail plus the exact input (state and full questions), kept so a rated-bad decision can be reviewed later. */
99
+ function judged_of(state, questions, answers) {
100
+ return { detail: detail_of(questions, answers), input: { state, questions } };
101
+ }
102
+
98
103
  /** Say which option the provider leaned to when it was not sure enough, so the transcript shows how close it was. */
99
104
  function unsure_label(lean) {
100
105
  return lean ? `倾向${lean},把握不足,交给主持人` : '拿不准,交给主持人';
@@ -108,8 +113,8 @@ function excerpt(message) {
108
113
  }
109
114
 
110
115
  /** The room's purpose in the user's words, so a decision can tell drifting from progress. */
111
- function topic_lines(title, goal, user_request, { brief, focus, arrangement, progress } = {}) {
112
- return [`房间:${title || ''}`, goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
116
+ function topic_lines(title, goal, user_request, { roster, brief, focus, arrangement, progress } = {}) {
117
+ return [`房间:${title || ''}`, roster && `房间成员(名字不区分大小写):${roster}`, goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
113
118
  focus && `当前焦点:${focus}`, brief && `主持简报(含分工与规则):${String(brief).slice(0, MAX_BRIEF_CHARS)}`,
114
119
  arrangement && `主持人最近的安排:${arrangement}`, progress].filter(Boolean).join('\n');
115
120
  }
@@ -136,21 +141,39 @@ export function certainty(answer) {
136
141
  */
137
142
  export async function route_free_message(home, scheduler, { title, goal, context, message, host, members }, fetch_impl) {
138
143
  const choices = [...members.map(member => ({ value: member.name, description: member.description })),
139
- { value: HOST_CHOICE, description: `主持人 ${host.description}:消息在延续主持人自己的回答,或对象不明确,或需要分工、排先后、互相回应` },
140
- { value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要配合,如打招呼、自我介绍、各说一句看法' },
144
+ { value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人(如单独一句“hi”“你好”这类问候、闲聊,由主持人代表房间回应),或需要分工、排先后、互相回应` },
145
+ { value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要配合;须是用户明确要求大家各自回应,如“大家/都出来打个招呼”“各自介绍一下”“各说一句看法”' },
141
146
  { value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答' }];
142
147
  const questions = [{ type: 'choice', name: 'handler', choices,
143
- instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象是否已经确定:正文叫了某位成员的名字或在向其提问;在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息);在补充或纠正一条正由某位成员处理的消息。以上情况选该成员,是主持人就选主持人。要求所有成员各自回应一次选全体;只是确认、致谢或旁注选无需回应;对象不明确,或需要分工、排先后、互相回应时选主持人。' }];
144
- const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, '', {}, ROUTE_CONTEXT_MESSAGES), questions, fetch_impl);
145
- const detail = detail_of(questions, answers);
148
+ instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字,或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)明确要求所有成员各自回应一次(用了“大家”“都”“各自”等)选全体;4)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;5)只是确认、致谢或旁注选无需回应;6)对象不明确,或需要分工、排先后、互相回应时选主持人。' }];
149
+ const roster = [`${host.name}(主持人)`, ...members.map(member => member.name)].join('、');
150
+ const state = scheduler_state(title, context, message, goal, '', { roster }, ROUTE_CONTEXT_MESSAGES);
151
+ const answers = await decide(home, scheduler, state, questions, fetch_impl);
152
+ const judged = judged_of(state, questions, answers);
146
153
  const choice = answers.handler?.choice ?? null;
147
154
  const confidence = certainty(answers.handler);
148
155
  const wide = choice === ALL_CHOICE || choice === NONE_CHOICE;
149
156
  const sure = confidence !== null && confidence >= (wide ? ROUTE_WIDE_CONFIDENCE : ROUTE_CONFIDENCE);
150
- if (sure && choice === ALL_CHOICE) return { action: 'all', choice, confidence, detail };
151
- if (sure && choice === NONE_CHOICE) return { action: 'none', choice, confidence, detail };
152
- if (sure && members.some(item => item.name === choice)) return { action: 'member', member: choice, choice, confidence, detail };
153
- return { action: 'host', choice, confidence, detail };
157
+ if (sure && choice === ALL_CHOICE) return { action: 'all', choice, confidence, ...judged };
158
+ if (sure && choice === NONE_CHOICE) return { action: 'none', choice, confidence, ...judged };
159
+ if (sure && members.some(item => item.name === choice)) return { action: 'member', member: choice, choice, confidence, ...judged };
160
+ return { action: 'host', choice, confidence, ...judged };
161
+ }
162
+
163
+ /**
164
+ * The user named everyone (@全体成员): decide whether they answer at once (parallel) or one after another (sequential).
165
+ * Anything but a confident sequential stays parallel, the cheaper and earlier behaviour of naming everyone.
166
+ */
167
+ export async function decide_group_order(home, scheduler, { title, goal, context, message, members }, fetch_impl) {
168
+ const questions = [{ type: 'choice', name: 'order', choices: [
169
+ { value: 'parallel', description: '同时回答:各自独立回应即可,不需要看到别人的回答,如打招呼、各自介绍、各说一句看法、各自投票' },
170
+ { value: 'sequential', description: `依次回答:后发言的需要看到前面的回答,如接龙、轮流补充或反驳、逐个评审上一位的结果、按顺序分工接力;按房间发言顺序(${members.join('、')})逐个回答` }],
171
+ instructions: '用户用 @全体成员 请房间里所有成员回应这条消息。判断大家应同时各自回答,还是需要按顺序依次回答(后一位看到前一位的回答再接着说)。' }];
172
+ const state = scheduler_state(title, context, message, goal, '', {}, ROUTE_CONTEXT_MESSAGES);
173
+ const answers = await decide(home, scheduler, state, questions, fetch_impl);
174
+ const confidence = certainty(answers.order);
175
+ const sequential = answers.order?.choice === 'sequential' && confidence !== null && confidence >= ROUTE_CONFIDENCE;
176
+ return { action: sequential ? 'sequential' : 'all', choice: answers.order?.choice ?? null, confidence, ...judged_of(state, questions, answers) };
154
177
  }
155
178
 
156
179
  /**
@@ -162,15 +185,16 @@ export async function decide_interjection(home, scheduler, { title, goal, user_r
162
185
  { value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,或同时涉及多位专家` },
163
186
  { value: NOTE_CHOICE, description: '仅补充信息:留言只是旁注、信息或鼓励,不要求任何人回应,按当前安排继续' }];
164
187
  const questions = [{ type: 'choice', name: 'handling', instructions: '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。', choices }];
165
- const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, user_request, extra), questions, fetch_impl);
166
- const detail = detail_of(questions, answers);
188
+ const state = scheduler_state(title, context, message, goal, user_request, extra);
189
+ const answers = await decide(home, scheduler, state, questions, fetch_impl);
190
+ const judged = judged_of(state, questions, answers);
167
191
  const choice = answers.handling?.choice;
168
192
  const confidence = certainty(answers.handling);
169
193
  const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
170
- if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, detail, label: '仅补充信息,按原计划继续' };
171
- if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, detail, label: `先请 ${choice} 回应这条留言` };
194
+ if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, ...judged, label: '仅补充信息,按原计划继续' };
195
+ if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, ...judged, label: `先请 ${choice} 回应这条留言` };
172
196
  const lean = choice === NOTE_CHOICE ? '按原计划继续' : experts.includes(choice) ? `请 ${choice} 回应` : choice === HOST_CHOICE ? '由主持人处理' : '';
173
- return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && sure ? '这条留言需要主持人处理' : unsure_label(lean) };
197
+ return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && sure ? '这条留言需要主持人处理' : unsure_label(lean) };
174
198
  }
175
199
 
176
200
  /**
@@ -189,17 +213,17 @@ export async function decide_next_speaker(home, scheduler, { title, goal, user_r
189
213
  { type: 'predicate', name: 'obligation',
190
214
  instructions: `是否存在明确依据要求 ${candidates.join('、')} 之一接着发言:简报分工或流程规定轮到其人,或最近发言点名追问了其人?仅仅是“本轮还没发言”不算依据。` }];
191
215
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
192
- const detail = detail_of(questions, answers);
216
+ const judged = judged_of(state, questions, answers);
193
217
  const choice = answers.speaker?.choice;
194
218
  const confidence = certainty(answers.speaker);
195
219
  const obligation = answers.obligation?.probability;
196
220
  if (candidates.includes(choice) && confidence !== null && confidence >= ROUTE_CONFIDENCE && obligation >= ROUTE_CONFIDENCE) {
197
- return { action: 'next', expert: choice, confidence, detail, label: `本轮继续,请 ${choice} 接着发言` };
221
+ return { action: 'next', expert: choice, confidence, ...judged, label: `本轮继续,请 ${choice} 接着发言` };
198
222
  }
199
223
  if (typeof obligation === 'number' && obligation < ROUTE_CONFIDENCE) {
200
- return { action: 'host', confidence, detail, label: `有明确依据的概率 ${obligation.toFixed(2)},未达 ${ROUTE_CONFIDENCE.toFixed(2)},交给主持人安排` };
224
+ return { action: 'host', confidence, ...judged, label: `有明确依据的概率 ${obligation.toFixed(2)},未达 ${ROUTE_CONFIDENCE.toFixed(2)},交给主持人安排` };
201
225
  }
202
- return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && confidence >= ROUTE_CONFIDENCE ? '建议由主持人调整安排,交给主持人' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
226
+ return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && confidence >= ROUTE_CONFIDENCE ? '建议由主持人调整安排,交给主持人' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
203
227
  }
204
228
 
205
229
  /**
@@ -216,13 +240,13 @@ export async function decide_round(home, scheduler, { title, goal, user_request,
216
240
  if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?按简报分工或接龙顺序判断;没有规定时选被其他专家点名追问、最需要回应的人。',
217
241
  choices: candidates.map(name => ({ value: name, description: `先请 ${name}` })) });
218
242
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
219
- const detail = detail_of(questions, answers);
243
+ const judged = judged_of(state, questions, answers);
220
244
  const choice = answers.next?.choice;
221
245
  const confidence = certainty(answers.next);
222
246
  if (choice !== 'continue' || confidence === null || confidence < ROUTE_CONFIDENCE || !candidates.length) {
223
247
  const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
224
- return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && sure ? '建议收束或调整方向,交给主持人' : unsure_label(choice === 'continue' ? '继续下一轮' : choice === HOST_CHOICE ? '收束或调整方向' : '') };
248
+ return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && sure ? '建议收束或调整方向,交给主持人' : unsure_label(choice === 'continue' ? '继续下一轮' : choice === HOST_CHOICE ? '收束或调整方向' : '') };
225
249
  }
226
250
  const first = candidates.includes(answers.first?.choice) ? answers.first.choice : candidates[0];
227
- return { action: 'continue', first, confidence, detail, label: `继续下一轮,先请 ${first} 发言` };
251
+ return { action: 'continue', first, confidence, ...judged, label: `继续下一轮,先请 ${first} 发言` };
228
252
  }
package/web/app.js CHANGED
@@ -939,12 +939,14 @@ function message_payload() {
939
939
  request_ids: state.room.free.requests.filter(item => item.member === online.name && item.status === "pending").map(item => item.id) };
940
940
  const payload = { author: "用户", text, reply_to: state.reply_to, kind: "chat", submitted_by: "用户" };
941
941
  const active = (state.room.free?.members || []).filter(member => member.status === "active" && (member.access !== "manual" || member.registered_at)).map(member => member.name);
942
- // @mentions outside code name members (@全体成员 or @all names them all; @全体依次 or @all-seq has them answer one after another, the host first);
943
- // without any, the server gives the message to the only member or the host.
942
+ // @mentions outside code name members (@全体成员 or @all names them all, answering at once or in turn as smart scheduling decides;
943
+ // @全体依次 or @all-seq has them answer one after another, the host first); without any, the server gives the message to the only member or the host.
944
944
  const sequential = mentioned(text, "全体依次") || mentioned(text, "all-seq");
945
- const named = sequential || mentioned(text, "全体成员") || mentioned(text, "all") ? active : active.filter(name => mentioned(text, name));
945
+ const everyone = !sequential && (mentioned(text, "全体成员") || mentioned(text, "all"));
946
+ const named = sequential || everyone ? active : active.filter(name => mentioned(text, name));
946
947
  if (named.length) payload.to = named;
947
948
  if (sequential && named.length) payload.sequential = true;
949
+ if (everyone && named.length) payload.order = "auto";
948
950
  return payload;
949
951
  }
950
952
  const mode = element("message-kind").value;
@@ -960,7 +962,6 @@ function message_payload() {
960
962
  if (payload.kind === "opinion" && payload.author !== "用户") {
961
963
  payload.summary = element("message-summary").value.trim();
962
964
  payload.summary_by = element("message-summary-source").value === "submitter" ? payload.submitted_by : payload.author;
963
- if (!payload.summary) throw new Error("请补充本次回答的摘要,保留关键结论、条件与不确定性");
964
965
  if ([...payload.summary].length > MAX_SUMMARY_LENGTH) throw new Error(`回答摘要最多 ${MAX_SUMMARY_LENGTH} 字,草稿已保留`);
965
966
  }
966
967
  if ([...payload.text].length > MAX_MESSAGE_LENGTH) throw new Error(`留言最多 ${MAX_MESSAGE_LENGTH} 字,原文已保留,请调整后提交`);
@@ -2753,7 +2754,6 @@ function update_composer_role() {
2753
2754
  if (mode === "moderation") element("author-select").value = "用户";
2754
2755
  const needs_summary = ["opinion", "forward"].includes(mode) && element("author-select").value !== "用户";
2755
2756
  element("message-summary-fields").hidden = !needs_summary;
2756
- element("message-summary").required = needs_summary;
2757
2757
  element("author-note").textContent = mode === "moderation" ? "主持发言" :
2758
2758
  (mode === "forward" ? "由用户代发" : (element("author-select").value === "用户" ? "发言" : "的观点代发"));
2759
2759
  }
Binary file