ai_co_discuss 0.2.28 → 0.2.31

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.
@@ -59,10 +59,16 @@ export function saved_api_keys(home) {
59
59
  /** Resolve only a validated reference bound to this exact API root. */
60
60
  export function read_api_key(home, profile) {
61
61
  validate_api_profile(profile);
62
+ return read_saved_api_key(home, profile.base_url, profile.api_key_ref);
63
+ }
64
+
65
+ /** Read one saved key by reference, refusing a key saved for a different root. */
66
+ export function read_saved_api_key(home, base_url, api_key_ref) {
67
+ if (!/^[a-f0-9-]{36}$/i.test(api_key_ref || '')) throw new Error('请保存此 Base URL 对应的 API Key');
62
68
  let saved;
63
- try { saved = JSON.parse(readFileSync(join(home, 'api-keys', `${profile.api_key_ref}.json`), 'utf8')); }
69
+ try { saved = JSON.parse(readFileSync(join(home, 'api-keys', `${api_key_ref}.json`), 'utf8')); }
64
70
  catch { throw new Error('本机 API Key 不存在,请重新保存'); }
65
- if (saved.base_url !== api_base_url(profile.base_url)) throw new Error('Base URL 已变化,请为新地址重新填写 API Key');
71
+ if (saved.base_url !== api_base_url(base_url)) throw new Error('Base URL 已变化,请为新地址重新填写 API Key');
66
72
  return saved.api_key;
67
73
  }
68
74
 
@@ -90,6 +96,16 @@ function api_headers(protocol, key) {
90
96
  return { Authorization: `Bearer ${key}` };
91
97
  }
92
98
 
99
+ /** Say whether fixing the setup or waiting helps; a wait hint never implies an uncertain run may be replayed. */
100
+ export function api_status_message(status) {
101
+ const head = `模型 API 返回 HTTP ${status}`;
102
+ if (status === 401 || status === 403) return `${head}:密钥无效或无权访问该模型,请核对 API Key 与模型权限`;
103
+ if (status === 404) return `${head}:地址或模型 ID 不存在,请核对 Base URL、请求协议和模型 ID`;
104
+ if (status === 429) return `${head}:请求过多或额度已用尽,请等待恢复后再试(执行中出现时,先核对房间里是否已有本次内容)`;
105
+ if (status >= 500) return `${head}:服务暂时不可用,请稍后再试(执行中出现时,先核对房间里是否已有本次内容)`;
106
+ return `${head},请核对地址、密钥、模型和请求协议`;
107
+ }
108
+
93
109
  /** A bounded request; errors expose only status, never provider bodies or authorization. */
94
110
  async function provider_fetch(profile, key, request, signal) {
95
111
  let response;
@@ -100,8 +116,8 @@ async function provider_fetch(profile, key, request, signal) {
100
116
  body: request.body === undefined ? undefined : JSON.stringify(request.body),
101
117
  signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS),
102
118
  });
103
- } catch { throw new Error(signal?.aborted ? 'API 执行已取消' : 'API 请求超时或连接失败,请核对 Base URL'); }
104
- if (!response.ok) throw new Error(`模型 API 返回 HTTP ${response.status},请核对地址、密钥、模型和请求协议`);
119
+ } catch { throw new Error(signal?.aborted ? 'API 执行已取消' : 'API 请求超时或连接失败:可能是网络暂时不通或 Base URL 有误,请核对'); }
120
+ if (!response.ok) throw new Error(api_status_message(response.status));
105
121
  try { return await response.json(); } catch { throw new Error('模型 API 返回的内容不是有效 JSON'); }
106
122
  }
107
123
 
@@ -11,6 +11,7 @@ import { parseArgs } from 'node:util';
11
11
  import { atomic_json } from './discussion_adapters.mjs';
12
12
  import { software_models } from './discussion_models.mjs';
13
13
  import { API_PROVIDERS, api_check, api_models, save_api_key, saved_api_keys, validate_api_profile } from './discussion_api.mjs';
14
+ import { SCHEDULER_PROVIDERS } from './scheduler.mjs';
14
15
  import { acquire_lock, api, CODEX_ADAPTERS, DiscussionStore, service_drafts, task_files, validate_config } from './discussion_runner.mjs';
15
16
  import { assert_shared_home, CONNECTION_FIELDS, configuration_snapshot, read_registry, registered_identity, register_room, service_home, service_pid, start_service } from './discussion_service.mjs';
16
17
 
@@ -220,6 +221,11 @@ function launch_input(input) {
220
221
  return value;
221
222
  }
222
223
 
224
+ /** Discussion experts the member list marks as manual: they answer with their own credential and need no local software. */
225
+ function manual_experts(room) {
226
+ return new Set((room.members || []).filter(member => member && typeof member === 'object' && member.access === 'manual').map(member => member.name));
227
+ }
228
+
223
229
  /** Merge machine defaults and explicit room overrides, preserving the selected participants. */
224
230
  function launch_config(home, input, room, directory, options) {
225
231
  if (room.mode === 'free') return free_launch_config(home, input, room, directory, options);
@@ -228,12 +234,15 @@ function launch_config(home, input, room, directory, options) {
228
234
  const online = Object.entries(room.experts).filter(([, mode]) => mode !== 'cli').map(([name]) => name);
229
235
  if (online.length) throw new Error(`全自动无法接入在线专家:${online.join('、')}。请调整参与方式或改用手动推进`);
230
236
  const defaults = options.defaults || launch_settings(home).participants;
237
+ const manual = manual_experts(room);
238
+ if (manual.has(room.host)) throw new Error('AI 主持人由本机服务续接,不能选择手动接入');
231
239
  const participants = {};
232
240
  for (const name of new Set([room.host, ...Object.keys(room.experts)])) {
233
- participants[name] = participant_profile(name, defaults, input, name === room.host);
241
+ if (!manual.has(name)) participants[name] = participant_profile(name, defaults, input, name === room.host);
234
242
  }
235
243
  const members = launch_members(input, Object.keys(participants), options);
236
244
  return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, participants, members, api_home: home,
245
+ scheduler: input.scheduler || undefined,
237
246
  cwd: input.cwd, discussion_project: input.discussion_project, project_access: input.project_access || undefined,
238
247
  data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
239
248
  }
@@ -248,7 +257,7 @@ function free_launch_config(home, input, room, directory, options) {
248
257
  const participants = Object.fromEntries(names.map(name => [name, participant_profile(name, defaults, input, false)]));
249
258
  const members = launch_members(input, names, options);
250
259
  return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, mode: 'free', participants, members, api_home: home,
251
- cwd: input.cwd, project_access: input.project_access || undefined,
260
+ scheduler: input.scheduler || undefined, cwd: input.cwd, project_access: input.project_access || undefined,
252
261
  data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
253
262
  }
254
263
 
@@ -344,15 +353,15 @@ function manual_access(path, input, name, token) {
344
353
  /**
345
354
  * Invite (add) or bring back (rejoin) one member of a running free room or discussion.
346
355
  * The local configuration is written before the website call, so a retry reuses the same credential.
347
- * Discussions only admit members the local service wakes.
356
+ * A discussion's manual expert answers with its own credential and is never woken.
348
357
  */
349
358
  export async function room_member(home, raw, locked = false) {
350
359
  const { input, path, config: registered } = await member_room_context(home, raw);
351
360
  const { action, name } = input;
352
361
  const access = input.access || 'runner';
353
362
  const free = registered.mode === 'free';
354
- if (!['add', 'rejoin'].includes(action) || !platform_of(name) || (access !== 'manual' && !clients_of(name)) || !(free ? ['runner', 'manual'] : ['runner']).includes(access)) {
355
- throw new Error(free ? 'action 须为 add/rejoin,成员须为支持的 AI,接入方式须为 runner/manual' : '讨论房间只接受本机唤醒的成员;action 须为 add/rejoin');
363
+ if (!['add', 'rejoin'].includes(action) || !platform_of(name) || (access !== 'manual' && !clients_of(name)) || !['runner', 'manual'].includes(access)) {
364
+ throw new Error('action 须为 add/rejoin,成员须为支持的 AI,接入方式须为 runner/manual');
356
365
  }
357
366
  const release = locked ? () => {} : acquire_lock(join(dirname(path), 'configuration-edit'));
358
367
  try {
@@ -663,8 +672,8 @@ async function perform_launch(home, context, options) {
663
672
  members: [...Object.keys(config.participants).map(name => ({ name, adapter: config.participants[name].adapter,
664
673
  token: config.members[name].token, access: 'runner', session_mode: config.participants[name].session_id ? 'borrowed' : 'new' })),
665
674
  // Manual and online members keep the credentials the browser generated; nothing local is configured for them.
666
- ...(free ? input.room.members.filter(member => !woken_member(member))
667
- .map(({ name, token, access }) => ({ name, token, access, adapter: '', session_mode: 'new' })) : [])],
675
+ ...(input.room.members || []).filter(member => free ? !woken_member(member) : member?.access === 'manual')
676
+ .map(({ name, token, access }) => ({ name, token, access, adapter: '', session_mode: 'new' }))],
668
677
  });
669
678
  config.room = room.id;
670
679
  await verify_control(config, room);
@@ -705,7 +714,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
705
714
  assert_shared_home(home);
706
715
  let result;
707
716
  if (positionals[0] === 'settings') result = { ...(input ? save_launch_settings(home, input) : launch_settings(home)), api_providers: API_PROVIDERS,
708
- api_keys: saved_api_keys(home) };
717
+ scheduler_providers: SCHEDULER_PROVIDERS, api_keys: saved_api_keys(home) };
709
718
  else if (positionals[0] === 'api-key' && input) result = save_api_key(home, input);
710
719
  else if (positionals[0] === 'api-check' && input) result = await api_check(home, input);
711
720
  else if (positionals[0] === 'room-models' && input) result = room_models(home, input);
@@ -9,6 +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
13
 
13
14
  const POLL_MS = 2000;
14
15
  /** Headless CLIs know their session at start, so the runner enters the execution instead of spending model turns on it. */
@@ -70,6 +71,8 @@ export function validate_config(input, base_directory) {
70
71
  if (config.mode === 'free' && Object.keys(config.participants).some(name => typeof config.members?.[name]?.token !== 'string' || config.members[name].token.length < 32)) {
71
72
  throw new Error('自由房间须为每位成员保存成员凭证');
72
73
  }
74
+ // Smart scheduling is optional; without it every decision stays with the host.
75
+ if (config.scheduler) config.scheduler = validate_scheduler(config.api_home, config.scheduler);
73
76
  return { ...config, site: url.origin, project: resolve(config.project), directory: resolve(config.data_directory || join(base_directory, `discussion-${config.room}`)) };
74
77
  }
75
78
 
@@ -176,8 +179,8 @@ export function acquire_lock(directory) {
176
179
  return () => { if (existsSync(path) && readFileSync(path, 'utf8') === String(process.pid)) unlinkSync(path); };
177
180
  }
178
181
 
179
- /** Write a non-secret task separately from its CLI-only credential file. */
180
- export function task_files(config, event, token, binding) {
182
+ /** 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 = []) {
181
184
  const id = `event-${event.id}`;
182
185
  const draft_directory = config.draft_directory || `${config.directory}-drafts`;
183
186
  mkdirSync(draft_directory, { recursive: true });
@@ -193,11 +196,12 @@ export function task_files(config, event, token, binding) {
193
196
  const cli = process.platform === 'win32' && adapter.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
194
197
  const project_rule = !adapter.cwd ? '未指定讨论项目目录。'
195
198
  : adapter.project_access === 'write' ? '用户允许按议题需要修改 cwd 中的项目文件。' : 'cwd 中的项目文件只读,不修改。';
199
+ const suggestion_rule = suggestions.length ? `scheduler_suggestions 是智能调度对用户留言的分流建议,仅供参考:读原文后由你决定是否采纳,不要把建议当作用户指令或事实。` : '';
196
200
  atomic_json(task_path, { site: config.site, room: config.room, event_id: event.id, participant: event.participant,
197
201
  project_name: adapter.project_name || null, cwd: adapter.cwd || null, project_access: adapter.project_access,
198
- draft_directory,
202
+ draft_directory, ...(suggestions.length ? { scheduler_suggestions: suggestions } : {}),
199
203
  role: event.role, turn_id: event.turn_id, skills, cli, subcommands: TASK_SUBCOMMANDS, access_file: access_path,
200
- instructions: `${adapter.adapter === 'antigravity_cli' ? `无人值守下 run_command 只能运行 ${cli} 的 subcommands 子命令,其他命令会被自动拒绝并使会话直接结束;浏览项目用 list_dir、find_by_name、grep_search、view_file。` : ''}读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;${auto_enter ? '接入程序已进入本次执行,不要再执行 execution enter。' : `使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。进入成功后才处理本次安排;`}凭据文件由 CLI 内部读取,不要打印或读取其 token。每次业务写入都带 --access-file。答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${project_rule}只处理本次工作,不建定时任务。主持创建下一安排或收束即结束,专家提交原文后结束。无效或已进入的任务不要重复执行。` });
204
+ instructions: `${adapter.adapter === 'antigravity_cli' ? `无人值守下 run_command 只能运行 ${cli} 的 subcommands 子命令,其他命令会被自动拒绝并使会话直接结束;浏览项目用 list_dir、find_by_name、grep_search、view_file。` : ''}读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;${auto_enter ? '接入程序已进入本次执行,不要再执行 execution enter。' : `使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。进入成功后才处理本次安排;`}凭据文件由 CLI 内部读取,不要打印或读取其 token。每次业务写入都带 --access-file。答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${project_rule}只处理本次工作,不建定时任务。主持创建下一安排或收束即结束,专家提交原文后结束。无效或已进入的任务不要重复执行。${suggestion_rule}` });
201
205
  const read_rule = windows_rg() ? '读取含中文的技能或文本文件时用 rg --encoding utf-8 . 文件路径,不用 Get-Content(Windows PowerShell 会按系统编码读出乱码,需要反复重试)。' : '';
202
206
  const scope = `本次只放行任务内 subcommands 列出的 ${cli} 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口。任务内 cwd 是讨论项目目录;${project_rule}项目读写仍受软件实际权限和议题授权约束。不读取凭据内容。${read_rule}`;
203
207
  const plan_rule = event.role === 'host' ? '安排专家回应时先写发言计划,用 turn create --plan-file 一次排满本轮每位专家,不要每次只排一位。' : '';
@@ -222,6 +226,7 @@ export class DiscussionRunner {
222
226
  this.running = new Map();
223
227
  this.seen = new Set();
224
228
  this.blocked = new Set();
229
+ this.suggestions = new Map();
225
230
  }
226
231
  /** Append one timestamped stage to the event's trace file when CODISCUSS_TRACE=1; tracing must never affect delivery. */
227
232
  trace(id, stage, detail = {}) {
@@ -246,15 +251,17 @@ export class DiscussionRunner {
246
251
  const binding = this.store.binding(event.participant);
247
252
  if (!binding.session_id && adapter.session_id) binding.session_id = adapter.session_id;
248
253
  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() : [];
249
256
  this.store.record(row.id, { status: 'dispatching', sent_at: Date.now(), attempts, error: '', retry_at: 0 });
250
257
  let invoked = false;
251
258
  let entered = false;
252
259
  try {
253
- const task = task_files(this.config, event, row.token, binding);
260
+ const task = task_files(this.config, event, row.token, binding, suggestions);
254
261
  invoked = true;
255
262
  const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
256
263
  api_context: { site: this.config.site, room: this.config.room, token: row.token, home: this.config.api_home,
257
- event_id: row.id, role: event.role, turn_id: event.turn_id },
264
+ event_id: row.id, role: event.role, turn_id: event.turn_id, scheduler_suggestions: suggestions },
258
265
  on_trace: (stage, detail) => this.trace(row.id, stage, detail),
259
266
  on_binding: session => {
260
267
  this.store.bind(event.participant, session);
@@ -275,6 +282,33 @@ export class DiscussionRunner {
275
282
  retry_at: not_sent ? Date.now() + (DELIVERY_RETRY_DELAYS_MS[attempts - 1] ?? 0) : 0 });
276
283
  }
277
284
  }
285
+ /**
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.
288
+ */
289
+ async interjection_suggestions() {
290
+ if (!this.config.scheduler) return [];
291
+ 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;
307
+ } catch (error) {
308
+ this.log(`智能调度建议失败,主持人照常处理:${error.message}`);
309
+ return [];
310
+ }
311
+ }
278
312
  /** Enter once per delivery; a failure is recorded so reconcile revokes the execution. */
279
313
  async enter(row, session_id) {
280
314
  try {
@@ -314,7 +348,7 @@ export class DiscussionRunner {
314
348
  if (this.exited.has(row.id) && ['dispatched', 'started'].includes(event.status)) {
315
349
  // A business write may have just completed the task; recheck before declaring a silent exit.
316
350
  const latest = await api(this.config, `/events/${row.id}`, { action: 'view' });
317
- if (['dispatched', 'started'].includes(latest.status)) return this.fail(row, `${event.participant} 会话已结束,但未保存本次结果${this.exited.get(row.id) ? `:${this.exited.get(row.id)}` : ''}`);
351
+ if (['dispatched', 'started'].includes(latest.status)) return this.fail(row, `${event.participant} 会话已结束,但未保存本次结果${this.exited.get(row.id) ? `:${this.exited.get(row.id)}` : ''};请核对原会话确认是否已有内容`);
318
352
  return this.reconcile(row, latest);
319
353
  }
320
354
  if (event.status === 'started' && event.lease_until <= Math.floor(Date.now() / 1000)) return this.fail(row, '执行租约到期;旧会话不能继续写入');
@@ -387,6 +421,8 @@ export class DiscussionRunner {
387
421
  }
388
422
  for (const { row, event } of pending) if (!this.seen.has(row.id)) { this.seen.add(row.id); this.trace(row.id, 'event_seen', { event_created_at: event.created_at }); }
389
423
  const next = eligible.at(-1);
424
+ // A manual expert has no local configuration: its pending event waits for the member's own answer.
425
+ if (next && !this.config.participants[next.event.participant]) return;
390
426
  if (next && this.running.get(next.event.participant) && !this.blocked.has(next.row.id)) {
391
427
  this.blocked.add(next.row.id);
392
428
  this.trace(next.row.id, 'blocked_by_running_process', { participant: next.event.participant });
@@ -261,7 +261,7 @@ class RoomWorker {
261
261
 
262
262
  /** Keep workers aligned with the registry and tick each one per poll. */
263
263
  export class DiscussionService {
264
- constructor(home, create_runner = (config, store, log) => config.mode === 'free' ? new FreeRoomRunner(config, store) : new DiscussionRunner(config, store, undefined, log), log = () => {}) {
264
+ constructor(home, create_runner = (config, store, log) => config.mode === 'free' ? new FreeRoomRunner(config, store, undefined, log) : new DiscussionRunner(config, store, undefined, log), log = () => {}) {
265
265
  this.home = home;
266
266
  this.create_runner = create_runner;
267
267
  this.log = log;
@@ -5,6 +5,7 @@ import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
5
5
  import { 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_free_message } from './scheduler.mjs';
8
9
 
9
10
  const RENEW_MS = 30_000;
10
11
  const MAX_DELIVERY_ATTEMPTS = 3;
@@ -68,24 +69,38 @@ export function free_task_files(config, member, execution, binding) {
68
69
  const host_rule = member.role === 'host'
69
70
  ? `你是本房间的主持人:purpose 为 route 的请求是用户未点名的消息,handoff 表示你派出的成员都已结束。先用 ${cli} room view ${config.room} 查看成员与状态,再决定由谁处理;需要派活时在回答中加 --to 成员名(可重复,不能点名自己),被点名的成员都结束后你会再被唤醒一次。不需要他人时不加 --to,本次即结束。只派分给各成员的文件或模块,避免多人改同一文件。`
70
71
  : '';
72
+ const scheduled_rule = execution.requests.some(item => item.purpose === 'scheduled')
73
+ ? 'purpose 为 scheduled 的消息用户没有点名,是智能调度判断交给你的;若它其实需要主持人拆分或协调,简短说明后结束即可。' : '';
71
74
  atomic_json(task_path, { site: config.site, room: config.room, mode: 'free', member: member.name,
72
75
  execution_id: execution.id, request_messages: execution.requests.map(item => item.message_id),
73
76
  request_purposes: execution.requests.map(item => item.purpose || 'ask'), role: member.role || 'member',
74
77
  read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
75
78
  skills: [join(config.project, '.agents/skills/discuss-participant/SKILL.md')], cli, subcommands: TASK_SUBCOMMANDS,
76
79
  access_file: access_path,
77
- 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}不读取或打印凭据内容,不建定时任务。` });
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
81
  const prompt = `用户在自由协作房间点名了你(${member.name}),对应消息 ${messages}。请先读取任务文件:${task_path},按其中说明读取新消息、完成请求,并用 ${cli} message post 带 --execution ${execution.id} 提交一次回答后结束。命令只用任务内 subcommands 列出的 ${cli} 子命令;${project_rule}不读取凭据内容。`;
79
82
  return { id, participant: member.name, directory: config.directory, draft_directory,
80
83
  skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt };
81
84
  }
82
85
 
86
+ const ADAPTER_LABELS = { codex: 'Codex App', codex_cli: 'Codex CLI', claude: 'Claude Code', antigravity: 'Antigravity App',
87
+ antigravity_cli: 'Antigravity CLI', api: '模型 API,只能读写房间消息' };
88
+
89
+ /** What a scheduler can know about a member without a role field: its software and project access. */
90
+ function member_description(name, profile) {
91
+ const access = profile.adapter === 'api' ? '' : profile.project_access === 'write' ? ',可修改项目文件' : ',项目文件只读';
92
+ return `${name}(${ADAPTER_LABELS[profile.adapter] || profile.adapter}${access})`;
93
+ }
94
+
83
95
  /** Delivers free-room requests through the same software adapters as discussions. */
84
96
  export class FreeRoomRunner {
85
- constructor(config, store, deliver = deliver_task) {
97
+ constructor(config, store, deliver = deliver_task, log = () => {}, fetch_impl = fetch) {
86
98
  this.config = config;
87
99
  this.store = store;
88
100
  this.deliver = deliver;
101
+ this.log = log;
102
+ this.fetch = fetch_impl;
103
+ this.routed = new Set();
89
104
  this.children = new Set();
90
105
  this.members = new Map();
91
106
  this.exited = new Set();
@@ -124,8 +139,10 @@ export class FreeRoomRunner {
124
139
  }
125
140
  /** One bounded iteration: reconcile live executions, then deliver idle members with open requests. */
126
141
  async tick() {
127
- const inbox = await api(this.config, '/inbox');
142
+ let inbox = await api(this.config, '/inbox');
128
143
  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');
129
146
  this.room_title = room.title;
130
147
  const active = new Map(inbox.executions.map(item => [item.id, item]));
131
148
  const present = new Set(inbox.members.map(member => member.name));
@@ -140,6 +157,38 @@ export class FreeRoomRunner {
140
157
  else if (!inbox.archived && member.pending.length && !this.members.get(member.name)?.size) await this.start(member, row);
141
158
  }
142
159
  }
160
+ /**
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.
163
+ */
164
+ async route_unnamed(inbox, room) {
165
+ const host = inbox.members.find(member => member.role === 'host');
166
+ 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,
168
+ description: member_description(member.name, this.config.participants[member.name]) }));
169
+ let moved = false;
170
+ for (const request of waiting) {
171
+ this.routed.add(request.id);
172
+ 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
+ }
189
+ }
190
+ return moved;
191
+ }
143
192
  /** Reserve with a persisted client id before invoking any software. */
144
193
  async start(member, row) {
145
194
  const record = this.save(member.name, { client_id: randomUUID(), execution_id: 0, status: 'reserving', error: '',
@@ -216,10 +265,10 @@ export class FreeRoomRunner {
216
265
  if (row.error) return this.fail(member.name, execution.id, row.error);
217
266
  if (this.exited.has(execution.id)) {
218
267
  const latest = await api(this.config, `/executions/${execution.id}`);
219
- if (['dispatched', 'started'].includes(latest.status)) return this.fail(member.name, execution.id, `${member.name} 会话已结束,但未提交本次回答`);
268
+ if (['dispatched', 'started'].includes(latest.status)) return this.fail(member.name, execution.id, `${member.name} 会话已结束,但未提交本次回答;请核对原会话确认是否已有内容`);
220
269
  return this.save(member.name, { status: 'idle' });
221
270
  }
222
- if (Date.now() >= execution.created_at * 1000 + minutes * 60_000) return this.fail(member.name, execution.id, `超过单次执行时限 ${minutes} 分钟`);
271
+ if (Date.now() >= execution.created_at * 1000 + minutes * 60_000) return this.fail(member.name, execution.id, `超过单次执行时限 ${minutes} 分钟;请核对原会话确认是否已有内容`);
223
272
  if (Date.now() - row.last_renew >= RENEW_MS) {
224
273
  await api(this.config, `/executions/${execution.id}`, { action: 'renew' });
225
274
  this.save(member.name, { last_renew: Date.now() });
@@ -0,0 +1,99 @@
1
+ /** Optional smart scheduling: typed decisions from TypeSafe Jev or OpenAI Decisions, never generated text. */
2
+ import { api_status_message, read_saved_api_key } from './discussion_api.mjs';
3
+
4
+ /** Each provider keeps its official root; the key saved for that root is the only credential used. */
5
+ export const SCHEDULER_PROVIDERS = {
6
+ jev: { label: 'TypeSafe Jev', base_url: 'https://api.typesafe.ai/v1', path: '/systemone', models: ['jev-latest'] },
7
+ decisions: { label: 'OpenAI Decisions', base_url: 'https://api.openai.com/v1', path: '/decisions', models: ['gpt-6-luna'] },
8
+ };
9
+ const REQUEST_TIMEOUT_MS = 15_000;
10
+ /** Below this confidence a free-room message stays with the host, exactly as without scheduling. */
11
+ export const ROUTE_CONFIDENCE = 0.8;
12
+ const HOST_CHOICE = '__host__';
13
+ const NOTE_CHOICE = '__note__';
14
+ const CONTEXT_MESSAGES = 10;
15
+ const MAX_MESSAGE_CHARS = 600;
16
+
17
+ /** Normalize a launch's scheduler choice and prove its key exists for that provider's root. */
18
+ export function validate_scheduler(home, scheduler) {
19
+ const provider = SCHEDULER_PROVIDERS[scheduler?.provider];
20
+ if (!provider) throw new Error(`智能调度供应商只能为 ${Object.keys(SCHEDULER_PROVIDERS).join(' / ')}`);
21
+ if (!scheduler.model || typeof scheduler.model !== 'string') throw new Error('请填写智能调度模型');
22
+ const normalized = { provider: scheduler.provider, model: scheduler.model.trim(), base_url: provider.base_url, api_key_ref: scheduler.api_key_ref };
23
+ read_saved_api_key(home, normalized.base_url, normalized.api_key_ref);
24
+ return normalized;
25
+ }
26
+
27
+ /** Convert neutral questions ({type: choice|predicate, name, instructions, choices}) to one provider's body. */
28
+ export function scheduler_request(scheduler, state, questions) {
29
+ const { path } = SCHEDULER_PROVIDERS[scheduler.provider];
30
+ if (scheduler.provider === 'jev') return { path, body: { model: scheduler.model, state, questions: Object.fromEntries(questions.map(question => [question.name,
31
+ question.type === 'choice'
32
+ ? { type: 'choice', instructions: question.instructions, criteria: Object.fromEntries(question.choices.map(choice => [choice.value, choice.description])) }
33
+ : { type: 'noul', instructions: question.instructions }])) } };
34
+ return { path, body: { model: scheduler.model, input: state, questions: questions.map(question => question.type === 'choice'
35
+ ? { type: 'choice', name: question.name, instructions: question.instructions, choices: question.choices.map(({ value, description }) => ({ value, description })) }
36
+ : { type: 'predicate', name: question.name, instructions: question.instructions }) } };
37
+ }
38
+
39
+ /** Read answers by question name: choices as {choice, confidence, probabilities}, predicates as {probability}. */
40
+ export function scheduler_answers(scheduler, data) {
41
+ if (scheduler.provider === 'jev') return Object.fromEntries(Object.entries(data?.answers || {}).map(([name, answer]) => [name,
42
+ answer.type === 'choice' ? { choice: answer.choice, confidence: answer.confidence, probabilities: answer.probabilities || {} } : { probability: answer.noul }]));
43
+ return Object.fromEntries((data?.answers || []).map(answer => [answer.name, answer.type === 'choice'
44
+ ? { choice: answer.choice, confidence: answer.confidence, probabilities: Object.fromEntries((answer.probabilities || []).map(item => [item.value, item.probability])) }
45
+ : { probability: answer.probability }]));
46
+ }
47
+
48
+ /** Ask the configured provider once; errors carry only the HTTP status, never response bodies or keys. */
49
+ export async function decide(home, scheduler, state, questions, fetch_impl = fetch) {
50
+ const key = read_saved_api_key(home, scheduler.base_url, scheduler.api_key_ref);
51
+ const request = scheduler_request(scheduler, state, questions);
52
+ let response;
53
+ try {
54
+ response = await fetch_impl(`${scheduler.base_url}${request.path}`, { method: 'POST', redirect: 'error',
55
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
56
+ body: JSON.stringify(request.body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
57
+ } catch { throw new Error('智能调度请求超时或连接失败'); }
58
+ if (!response.ok) throw new Error(api_status_message(response.status));
59
+ return scheduler_answers(scheduler, await response.json());
60
+ }
61
+
62
+ /** Shorten a message so recent context stays small; the decision never needs whole answers. */
63
+ function excerpt(message) {
64
+ const text = String(message.text || '').replace(/\s+/g, ' ').trim();
65
+ return `#${message.id} ${message.author}:${text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text}`;
66
+ }
67
+
68
+ /** State shared by both uses: the room, a few earlier messages, and the message to place. */
69
+ export function scheduler_state(title, context, message) {
70
+ 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');
72
+ }
73
+
74
+ /**
75
+ * Pick the member for one unnamed free-room message, or null to leave it with the host.
76
+ * Members are [{name, description}] without the host; only a confident member choice is returned.
77
+ */
78
+ export async function route_free_message(home, scheduler, { title, context, message, host, members }, fetch_impl) {
79
+ const choices = [...members.map(member => ({ value: member.name, description: member.description })),
80
+ { value: HOST_CHOICE, description: `交回主持人 ${host}:需要拆分任务、补充说明、多人协作,或无法确定由谁处理` }];
81
+ const answers = await decide(home, scheduler, scheduler_state(title, context, message), [{ type: 'choice', name: 'handler',
82
+ instructions: '这条用户消息没有点名成员。判断它能否直接交给一位成员独立处理;需要主持人先理解、拆分或协调时选交回主持人。', choices }], fetch_impl);
83
+ 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 };
86
+ }
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) {
90
+ const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言` })),
91
+ { value: HOST_CHOICE, description: `由主持人 ${host} 处理:调整目标、方向、安排,或需要主持人决定` },
92
+ { 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;
99
+ }