ai_co_discuss 0.2.32 → 0.2.34

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,124 @@ 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(event); }
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
+ /**
341
+ * Read the room and decide one interjection, restore a plan voided by a roster change, or decide the next round
342
+ * when named follow-ups are off. Anything left to the host says why; null (the opening, or nothing to do) records nothing.
343
+ */
344
+ async plan_schedule(event) {
345
+ if (String(event.cause || '').startsWith('start')) return null;
346
+ const room = await api(this.config, '');
347
+ if (!ACTIVE_PHASES.includes(room.phase)) return null;
348
+ if (room.current_turn && ['pending', 'notified'].includes(room.current_turn.status)) return null;
349
+ // Every record sits at the end of the latest expert answer, the card that just finished speaking.
350
+ const anchor = room.current_turn?.answer_id || room.latest_id;
351
+ const skip = (kind, reason) => ({ action: 'host', kind, confidence: null, label: `未判断:${reason},交给主持人`, after_message_id: anchor });
352
+ if (room.limits?.reached) return skip('round', '已到讨论上限');
353
+ const page = await api(this.config, `/messages?after=${Math.max(0, room.latest_id - CONTEXT_MESSAGES)}`);
354
+ const experts = Object.keys(room.experts || {});
355
+ const context = { title: room.title, goal: room.goal, user_request: room.user_request, context: page.messages, host: room.host };
356
+ if (room.held_since) {
357
+ const pending = page.messages.filter(item => item.id >= room.held_since && item.author === '用户');
358
+ // Topic edits and several messages at once need the host's judgement.
359
+ if (pending.length !== 1) return skip('interjection', pending.length ? '同时有多条留言' : '话题设置有修改');
360
+ const message = pending[0];
361
+ const decision = await decide_interjection(this.config.api_home, this.config.scheduler,
362
+ { ...context, message, experts: experts.filter(name => name !== room.host) }, this.fetch);
363
+ const plan = { ...decision, kind: 'interjection', message_id: message.id, after_message_id: anchor };
364
+ if (decision.action === 'ask') {
365
+ return { ...plan, turns: [{ expert: decision.expert, question: interjection_question(message) },
366
+ ...room.queued_turns.map(({ expert, question }) => ({ expert, question }))].slice(0, MAX_PLAN_TURNS) };
367
+ }
368
+ if (decision.action === 'note' && room.queued_turns.length) return { ...plan, continue_plan: true };
369
+ // A note after the plan ran out falls through to deciding the next round.
370
+ if (decision.action !== 'note') return plan;
371
+ }
372
+ if (room.queued_turns.length) return null;
373
+ const round_open = !room.rounds?.at(-1)?.completed;
374
+ if (round_open) {
375
+ const restored = await this.restore_voided_plan(room, experts, anchor);
376
+ return restored || skip('round', '本轮还有专家未发言但没有后续安排');
377
+ }
378
+ if (room.named_followup) return skip('round', '点名追问已开启,由主持人安排下一轮');
379
+ const decision = await decide_round(this.config.api_home, this.config.scheduler,
380
+ { ...context, experts, last_speaker: room.current_turn?.expert }, this.fetch);
381
+ const plan = { ...decision, kind: 'round', after_message_id: anchor };
382
+ if (decision.action !== 'continue') return plan;
383
+ const round = room.completed_rounds + 1;
384
+ const order = [decision.first, ...experts.filter(name => name !== decision.first)].slice(0, MAX_PLAN_TURNS);
385
+ return { ...plan, phase: room.phase === 'initial' ? 'discussing' : undefined,
386
+ turns: order.map(expert => ({ expert, question: peer_round_question(round) })) };
387
+ }
388
+ /**
389
+ * A roster change voids queued turns, leaving them skipped after the last real arrangement. Put back the ones whose
390
+ * experts are still present, then ask each expert who has not spoken this round (such as a new member) to open.
391
+ * This needs no model: it only reinstates what the host had already planned.
392
+ */
393
+ async restore_voided_plan(room, experts, anchor) {
394
+ const turns = (await api(this.config, '/turns')).turns || [];
395
+ const last_real = Math.max(0, ...turns.filter(turn => turn.status !== 'skipped').map(turn => turn.id));
396
+ const voided = turns.filter(turn => turn.status === 'skipped' && turn.id > last_real && experts.includes(turn.expert));
397
+ if (!turns.some(turn => turn.status === 'skipped' && turn.id > last_real)) return null;
398
+ const spoken = new Set(room.rounds?.at(-1)?.speakers || []);
399
+ const planned = new Set(voided.map(turn => turn.expert));
400
+ const joining = experts.filter(name => !spoken.has(name) && !planned.has(name));
401
+ const opening = room.phase === 'initial' ? '欢迎加入:请先读取议题,独立给出你的开场观点。' : '欢迎加入:请先读取议题和已有回答,给出你的观点。';
402
+ const plan_turns = [...voided.map(({ expert, question }) => ({ expert, question })), ...joining.map(expert => ({ expert, question: opening }))].slice(0, MAX_PLAN_TURNS);
403
+ if (!plan_turns.length) return null;
404
+ const label = `成员有变化:${voided.length ? `补回原计划(${voided.map(turn => turn.expert).join('、')})` : '原计划成员已离开'}${joining.length ? `,请 ${joining.join('、')} 开场` : ''}`;
405
+ return { action: 'restore', kind: 'round', confidence: null, label, after_message_id: anchor, turns: plan_turns };
406
+ }
407
+ /** Perform the host's writes for a decided plan under its own execution credential. */
408
+ async apply_schedule(plan, token) {
409
+ const room = await api(this.config, '');
410
+ const actor = room.host;
411
+ if (room.floor.holder !== actor) await api(this.config, '/floor', { actor, speaker: actor, action: 'take', revision: room.floor.revision }, token);
412
+ if (plan.phase) await api(this.config, '/topic', { actor, phase: plan.phase }, token);
413
+ if (plan.continue_plan) await api(this.config, '/turns/continue', { actor }, token);
414
+ else await api(this.config, '/turns', { actor, turns: plan.turns, submission_id: randomUUID() }, token);
415
+ }
416
+ /** Show the decision in the transcript; a failed record never affects the discussion. */
417
+ async record_decision(plan, applied) {
418
+ try {
419
+ await api(this.config, '/scheduler-decisions', { kind: plan.kind, label: plan.label, confidence: plan.confidence ?? null,
420
+ applied, provider: this.config.scheduler.provider, after_message_id: plan.after_message_id || 0 });
421
+ } catch (error) { this.log(`智能调度决策记录未保存:${error.message}`); }
311
422
  }
312
423
  /** Enter once per delivery; a failure is recorded so reconcile revokes the execution. */
313
424
  async enter(row, session_id) {
@@ -390,6 +501,7 @@ export class DiscussionRunner {
390
501
  if (room.runtime.generation !== generation) this.stop_children();
391
502
  }
392
503
  this.room_title = room.title;
504
+ this.members = room.members || [];
393
505
  const run = room.runtime;
394
506
  if (run.run_id) this.store.scope(this.config, run.run_id);
395
507
  if (run.mode !== 'automatic' || run.status !== 'running' || run.expired) {
@@ -431,7 +543,8 @@ export class DiscussionRunner {
431
543
  if (next && !this.running.get(next.event.participant)) {
432
544
  const fresh = await api(this.config, `/events/${next.row.id}`, { action: 'view' });
433
545
  if (fresh.status === 'pending') {
434
- await this.dispatch(next.row, fresh);
546
+ const scheduled = fresh.role === 'host' ? await this.smart_schedule(next.row, fresh) : {};
547
+ if (!scheduled.done) await this.dispatch(next.row, fresh, scheduled);
435
548
  }
436
549
  else this.store.record(next.row.id, { status: fresh.status });
437
550
  }
@@ -173,6 +173,7 @@ class RoomWorker {
173
173
  this.create_runner = create_runner;
174
174
  this.log = log;
175
175
  this.state = { config: path, status: 'starting', error: '' };
176
+ this.adopt_failed = new Set();
176
177
  }
177
178
  /** Open lazily so a busy lock or broken file is retried instead of stopping the service. */
178
179
  open() {
@@ -219,6 +220,23 @@ class RoomWorker {
219
220
  }
220
221
  this.state.config_version = snapshot.version;
221
222
  }
223
+ /** A manual Claude expert whose CLI reported its session is handed to the service, which resumes that session from the next turn on. */
224
+ async adopt_manual_members() {
225
+ if (this.config.mode === 'free') return;
226
+ for (const member of this.runner.members || []) {
227
+ const key = `${member.name}:${member.reported_session}`;
228
+ if (member.access !== 'manual' || member.status !== 'active' || !member.reported_session || this.adopt_failed.has(key)) continue;
229
+ try {
230
+ const { adopt_member_profile } = await import('./discussion_launch.mjs');
231
+ adopt_member_profile(this.home, this.path, member.name, member.reported_session);
232
+ await api(this.config, `/members/${member.id}`, { action: 'adopt', adapter: 'claude', session_id: member.reported_session });
233
+ this.log(`${this.config.room}: ${member.name} 已转为本机托管`);
234
+ } catch (error) {
235
+ this.adopt_failed.add(key);
236
+ this.log(`${this.config.room}: ${member.name} 未能转为托管,保持手动:${error.message}`);
237
+ }
238
+ }
239
+ }
222
240
  /** Run one bounded dispatcher iteration and keep the last result for status. */
223
241
  async tick() {
224
242
  const previous_status = this.state.status;
@@ -226,6 +244,7 @@ class RoomWorker {
226
244
  if (!this.runner) this.open();
227
245
  await this.refresh_control();
228
246
  const result = await this.runner.tick();
247
+ await this.adopt_manual_members();
229
248
  Object.assign(this.state, { status: TICK_STATES[result], error: '' });
230
249
  } catch (error) {
231
250
  if (await this.deleted(error)) { this.removed = true; return; }
@@ -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, goal: room.goal, 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) {
@@ -6,7 +6,7 @@ export const SCHEDULER_PROVIDERS = {
6
6
  jev: { label: 'TypeSafe Jev', base_url: 'https://api.typesafe.ai/v1', path: '/systemone', models: ['jev-latest'] },
7
7
  decisions: { label: 'OpenAI Decisions', base_url: 'https://api.openai.com/v1', path: '/decisions', models: ['gpt-6-luna'] },
8
8
  };
9
- const REQUEST_TIMEOUT_MS = 15_000;
9
+ const REQUEST_TIMEOUT_MS = 30_000;
10
10
  /** Below this confidence a free-room message stays with the host, exactly as without scheduling. */
11
11
  export const ROUTE_CONFIDENCE = 0.8;
12
12
  const HOST_CHOICE = '__host__';
@@ -59,41 +59,93 @@ export async function decide(home, scheduler, state, questions, fetch_impl = fet
59
59
  return scheduler_answers(scheduler, await response.json());
60
60
  }
61
61
 
62
+ /** Send one tiny yes/no question so provider, model and key are checked together before a room relies on them. */
63
+ export async function scheduler_check(home, input, fetch_impl = fetch) {
64
+ const scheduler = validate_scheduler(home, input);
65
+ const answers = await decide(home, scheduler, '这是一条连通性检查消息。', [{ type: 'predicate', name: 'check', instructions: '这条内容是否是连通性检查?' }], fetch_impl);
66
+ if (typeof answers.check?.probability !== 'number') throw new Error('决策模型未返回可用答案,请核对供应商与模型');
67
+ return { ok: true };
68
+ }
69
+
62
70
  /** Shorten a message so recent context stays small; the decision never needs whole answers. */
63
71
  function excerpt(message) {
64
72
  const text = String(message.text || '').replace(/\s+/g, ' ').trim();
65
73
  return `#${message.id} ${message.author}:${text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text}`;
66
74
  }
67
75
 
76
+ /** The room's purpose in the user's words, so a decision can tell drifting from progress. */
77
+ function topic_lines(title, goal, user_request) {
78
+ return [`房间:${title || ''}`, goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`].filter(Boolean).join('\n');
79
+ }
80
+
68
81
  /** State shared by both uses: the room, a few earlier messages, and the message to place. */
69
- export function scheduler_state(title, context, message) {
82
+ export function scheduler_state(title, context, message, goal = '', user_request = '') {
70
83
  const earlier = context.filter(item => item.id < message.id).slice(-CONTEXT_MESSAGES).map(excerpt);
71
- return [`房间:${title || ''}`, earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
84
+ return [topic_lines(title, goal, user_request), earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
85
+ }
86
+
87
+ /**
88
+ * How sure the provider is of its pick: the chosen option's probability when given. Jev's separate `confidence`
89
+ * runs lower than that probability on two-way choices, so it is only the fallback.
90
+ */
91
+ export function certainty(answer) {
92
+ const probability = answer?.probabilities?.[answer.choice];
93
+ return typeof probability === 'number' ? probability : typeof answer?.confidence === 'number' ? answer.confidence : null;
72
94
  }
73
95
 
74
96
  /**
75
97
  * Pick the member for one unnamed free-room message, or null to leave it with the host.
76
98
  * Members are [{name, description}] without the host; only a confident member choice is returned.
77
99
  */
78
- export async function route_free_message(home, scheduler, { title, context, message, host, members }, fetch_impl) {
100
+ export async function route_free_message(home, scheduler, { title, goal, context, message, host, members }, fetch_impl) {
79
101
  const choices = [...members.map(member => ({ value: member.name, description: member.description })),
80
102
  { value: HOST_CHOICE, description: `交回主持人 ${host}:需要拆分任务、补充说明、多人协作,或无法确定由谁处理` }];
81
- const answers = await decide(home, scheduler, scheduler_state(title, context, message), [{ type: 'choice', name: 'handler',
103
+ const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal), [{ type: 'choice', name: 'handler',
82
104
  instructions: '这条用户消息没有点名成员。判断它能否直接交给一位成员独立处理;需要主持人先理解、拆分或协调时选交回主持人。', choices }], fetch_impl);
83
105
  const answer = answers.handler || {};
84
- const member = members.some(item => item.name === answer.choice) && answer.confidence >= ROUTE_CONFIDENCE ? answer.choice : null;
85
- return { member, choice: answer.choice ?? null, confidence: answer.confidence ?? null };
106
+ const confidence = certainty(answer);
107
+ const member = members.some(item => item.name === answer.choice) && confidence >= ROUTE_CONFIDENCE ? answer.choice : null;
108
+ return { member, choice: answer.choice ?? null, confidence };
86
109
  }
87
110
 
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) {
111
+ /**
112
+ * Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
113
+ * Anything but a confident note or ask is returned as host.
114
+ */
115
+ export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts }, fetch_impl) {
90
116
  const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言` })),
91
117
  { value: HOST_CHOICE, description: `由主持人 ${host} 处理:调整目标、方向、安排,或需要主持人决定` },
92
118
  { value: NOTE_CHOICE, description: '仅补充信息:不需要专人回应,按原计划继续' }];
93
- const answers = await decide(home, scheduler, scheduler_state(title, context, message), [{ type: 'choice', name: 'handling',
94
- 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;
119
+ const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, user_request), [{ type: 'choice', name: 'handling',
120
+ instructions: '讨论进行中用户发来这条留言。结合讨论目标判断它最适合怎样处理。', choices }], fetch_impl);
121
+ const choice = answers.handling?.choice;
122
+ const confidence = certainty(answers.handling);
123
+ const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
124
+ if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, label: '仅补充信息,按原计划继续' };
125
+ if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, label: `先请 ${choice} 回应这条留言` };
126
+ return { action: 'host', confidence, label: choice === HOST_CHOICE && sure ? '这条留言需要主持人处理' : '拿不准这条留言如何处理,交给主持人' };
127
+ }
128
+
129
+ /**
130
+ * At the end of a round without named follow-ups: continue with another peer round, or wake the host
131
+ * (to conclude, or because the discussion drifted, repeats itself or is stuck). The first speaker is never the last one.
132
+ */
133
+ export async function decide_round(home, scheduler, { title, goal, user_request, context, host, experts, last_speaker }, fetch_impl) {
134
+ const candidates = experts.filter(name => name !== last_speaker);
135
+ const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
136
+ const state = [topic_lines(title, goal, user_request), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
137
+ const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。结合讨论目标判断:是否值得让大家继续下一轮,互相回应、追问或补充?', choices: [
138
+ { value: 'continue', description: '继续下一轮:还有可以互相回应、追问、补充或展开的内容' },
139
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或讨论偏离目标、反复重复、卡住,需要主持人引导` }] }];
140
+ if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?优先选被其他专家点名追问、最需要回应的人。',
141
+ choices: candidates.map(name => ({ value: name, description: `先请 ${name}` })) });
142
+ const answers = await decide(home, scheduler, state, questions, fetch_impl);
143
+ const choice = answers.next?.choice;
144
+ const confidence = certainty(answers.next);
145
+ if (choice !== 'continue' || confidence === null || confidence < ROUTE_CONFIDENCE || !candidates.length) {
146
+ const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
147
+ return { action: 'host', confidence, label: choice === HOST_CHOICE && sure ? '建议收束或调整方向,交给主持人' : '拿不准是否继续下一轮,交给主持人' };
148
+ }
149
+ const first = candidates.includes(answers.first?.choice) ? answers.first.choice : candidates[0];
150
+ return { action: 'continue', first, confidence, label: `继续下一轮,先请 ${first} 发言` };
99
151
  }