ai_co_discuss 0.3.12 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/runner.md CHANGED
@@ -65,7 +65,7 @@ API 成员可以担任专家或主持人,只有房间读写工具,不访问
65
65
 
66
66
  ## 智能调度(可选)
67
67
 
68
- 建房时在“智能调度”下选择供应商并填写密钥,需要连接本机服务。作用见 [使用指南](workflow.md#智能调度)。
68
+ 建房时在“智能调度”下选择供应商并填写密钥,需要连接本机服务。卡片下方的“判断方式”可选“引导(试验)”(默认,正在打磨)或“稳定”(已通过回归评测),影响未点名消息的分派和讨论中的插话、下一位发言、下一轮判断。作用见 [使用指南](workflow.md#智能调度)。
69
69
 
70
70
  翻开成员卡片,“提示”一行可以给这位专家单独写几句要求,例如“回答先给结论”。它只发给这位专家,不改变权限;部分软件带有预设,可随时恢复。卡片背面的书签图标把这张卡片的描述、提示词、项目权限、模型与强度存为专家预设,之后在“添加”行一键加入,见 [使用指南](workflow.md#成员管理)。
71
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -14,7 +14,7 @@ import { setup_sidecar } from './adapters/antigravity_app.mjs';
14
14
  import { adapter_of, adoption_adapter, BRANCHABLE_ADAPTERS, branch_session, session_settings, software_defaults, software_effort, software_models } from './adapters/index.mjs';
15
15
  import { ADAPTER_IDS, ADAPTER_PROFILES, adapter_profile, platform_adapters, same_session_family } from './adapters/profiles.mjs';
16
16
  import { API_PROVIDERS, api_check, api_models, save_api_key, saved_api_keys, validate_api_profile } from './discussion_api.mjs';
17
- import { SCHEDULER_PROVIDERS, scheduler_check } from './scheduler.mjs';
17
+ import { DEFAULT_SCHEDULER_MODE, SCHEDULER_MODES, SCHEDULER_PROVIDERS, scheduler_check } from './scheduler.mjs';
18
18
  import { access_levels, BUILTIN_EXPERT_PRESETS, MAX_EXPERT_PROMPT, PROJECT_ACCESS } from './expert_prompts.mjs';
19
19
  import { acquire_lock, api, DiscussionStore, service_drafts, validate_config } from './discussion_runner.mjs';
20
20
  import { FreeRoomRunner, free_task_files } from './free_runner.mjs';
@@ -285,7 +285,7 @@ export async function room_models(home, raw, { read_defaults = software_defaults
285
285
  own_project: Boolean(facts?.own_project_when_bound && profile.session_id) }];
286
286
  })));
287
287
  // Smart scheduling is a local choice; only which model decides is shown, never its key.
288
- const scheduler = config.scheduler ? { provider: config.scheduler.provider, model: config.scheduler.model } : null;
288
+ const scheduler = config.scheduler ? { provider: config.scheduler.provider, model: config.scheduler.model, mode: config.scheduler.mode || DEFAULT_SCHEDULER_MODE } : null;
289
289
  return { room_id: input.room_id, registered: paths.length > 0, participants, scheduler };
290
290
  }
291
291
 
@@ -1232,7 +1232,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
1232
1232
  assert_shared_home(home);
1233
1233
  let result;
1234
1234
  if (positionals[0] === 'settings') result = { ...(input ? save_launch_settings(home, input) : launch_settings(home)), api_providers: API_PROVIDERS,
1235
- scheduler_providers: SCHEDULER_PROVIDERS, api_keys: saved_api_keys(home), projects: launch_projects(home).projects, ...expert_presets(home) };
1235
+ scheduler_providers: SCHEDULER_PROVIDERS, scheduler_modes: SCHEDULER_MODES, scheduler_default_mode: DEFAULT_SCHEDULER_MODE, api_keys: saved_api_keys(home), projects: launch_projects(home).projects, ...expert_presets(home) };
1236
1236
  else if (positionals[0] === 'projects') result = input ? save_launch_projects(home, input) : launch_projects(home);
1237
1237
  else if (positionals[0] === 'presets') result = input ? save_expert_presets(home, input) : expert_presets(home);
1238
1238
  else if (positionals[0] === 'api-key' && input) result = save_api_key(home, input);
@@ -9,7 +9,7 @@ import { SegmentLog } from './execution_progress.mjs';
9
9
  import { free_task_text, TASK_SUBCOMMANDS, with_expert_prompt } from './expert_prompts.mjs';
10
10
  import { atomic_json } from './files.mjs';
11
11
  import { SessionDriver } from './session_driver.mjs';
12
- import { REVIEW_CONFIDENCE, REVIEW_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, decide_review_completion, route_free_message } from './scheduler.mjs';
12
+ import { DEFAULT_SCHEDULER_MODE, REVIEW_CONFIDENCE, REVIEW_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, decide_review_completion, route_free_message, route_prompt_version } from './scheduler.mjs';
13
13
 
14
14
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
15
15
  const RENEW_MS = 30_000;
@@ -451,7 +451,8 @@ export class FreeRoomRunner {
451
451
  let record;
452
452
  if (decision.detail) {
453
453
  if (result.status === 'failed') label += '(未生效,网站写入失败)';
454
- record = await this.record_decision(request.message_id, decision, label, result.applied && decision.action !== 'host');
454
+ // The @全体成员 question has one wording in every mode.
455
+ record = await this.record_decision(request.message_id, decision, label, result.applied && decision.action !== 'host', { prompt: ROUTE_PROMPT_VERSION });
455
456
  }
456
457
  this.save_suggestion(request, { action: decision.action, actual_action,
457
458
  applied: result.applied && decision.action !== 'host', model_judgment: Boolean(decision.detail),
@@ -481,7 +482,10 @@ export class FreeRoomRunner {
481
482
  const work = member_work(requests, member.name, message_id);
482
483
  const profile = this.config.participants[member.name];
483
484
  const described = room.descriptions?.[member.name] ? `;用户描述:${room.descriptions[member.name]}` : '';
484
- return { id: member.id, name: member.name, role: member.role, description: profile ? member_description(member.name, profile, `${work}${described}`)
485
+ // The model and the last finished message are only read by the guided wording.
486
+ const finished = (room.free?.requests || []).filter(item => item.member === member.name && item.status === 'completed' && item.message_id !== message_id).at(-1);
487
+ return { id: member.id, name: member.name, role: member.role, model: profile?.model || '', finished: finished ? `#${finished.message_id}` : '',
488
+ description: profile ? member_description(member.name, profile, `${work}${described}`)
485
489
  : `${member.name}(${member.access === 'online' ? '网页 AI' : '手动接入'},由用户代为触发${work}${described})` };
486
490
  });
487
491
  }
@@ -542,7 +546,7 @@ export class FreeRoomRunner {
542
546
  return await api(this.config, '/scheduler-decisions', { kind: 'interjection', label, confidence: decision.confidence ?? null,
543
547
  applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail, input: decision.input,
544
548
  context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE,
545
- prompt: ROUTE_PROMPT_VERSION, package: PACKAGE_VERSION, ...context } });
549
+ mode: this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE, prompt: route_prompt_version(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE), package: PACKAGE_VERSION, ...context } });
546
550
  } catch (error) { this.log(`智能调度 #${message_id} 决策记录未保存:${error.message}`); }
547
551
  }
548
552
  /** Reserve with a persisted client id before invoking any software. */
@@ -12,9 +12,27 @@ 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 = 10;
15
+ export const ROUTE_PROMPT_VERSION = 12;
16
+ /**
17
+ * How the free-room routing question is worded. guided (experimental, the default while it is being refined) states the
18
+ * purpose and the evidence in prose; stable keeps the ordered checklist that passed the regression set.
19
+ */
20
+ export const SCHEDULER_MODES = { guided: '引导(试验)', stable: '稳定' };
21
+ export const DEFAULT_SCHEDULER_MODE = 'guided';
22
+ /** The guided wording has its own version, so its ratings are never mixed with the stable checklist's. */
23
+ export const ROUTE_GUIDED_PROMPT_VERSION = 4;
24
+ /** The routing prompt version that a decision in this mode was made with. */
25
+ export function route_prompt_version(mode) {
26
+ return mode === 'stable' ? ROUTE_PROMPT_VERSION : ROUTE_GUIDED_PROMPT_VERSION;
27
+ }
16
28
  /** The same for the three discussion questions (interjection, next speaker, next round). */
17
- export const DISCUSSION_PROMPT_VERSION = 4;
29
+ export const DISCUSSION_PROMPT_VERSION = 5;
30
+ /** The guided wording of the discussion questions, versioned apart from the stable one. */
31
+ export const DISCUSSION_GUIDED_PROMPT_VERSION = 1;
32
+ /** The discussion prompt version that a decision in this mode was made with. */
33
+ export function discussion_prompt_version(mode) {
34
+ return mode === 'stable' ? DISCUSSION_PROMPT_VERSION : DISCUSSION_GUIDED_PROMPT_VERSION;
35
+ }
18
36
  /** Completion of a preauthorized implementation batch is separate from routing and discussion. */
19
37
  export const REVIEW_PROMPT_VERSION = 1;
20
38
  export const REVIEW_CONFIDENCE = 0.85;
@@ -29,13 +47,23 @@ export const ROUTE_CONTEXT_MESSAGES = 20;
29
47
  const MAX_MESSAGE_CHARS = 600;
30
48
  const MAX_BRIEF_CHARS = 1200;
31
49
  const MAX_PROFILE_CHARS = 500;
50
+ /**
51
+ * What the room is and how its people talk, so a decision reads the user's words the way a member would.
52
+ * Shared by every scene built from topic_lines; bump both prompt versions when it changes.
53
+ */
54
+ const ROOM_GUIDE = ['这是 AI CoDiscuss 的协作房间。用户是房主和需求方;其余成员都是 AI,各自运行在 Claude Code、Codex 等软件或模型 API 上,用户也称它们为专家或 AI。',
55
+ '其中一位是主持人,负责接收没有点名的消息并分派、协调,也可以自己回答;其他成员是专家,在各自的会话里工作并回复。',
56
+ '用户用 @名字 点名成员,用 @全体成员 请所有人回应。每唤醒一位成员就要花一次执行(数十秒);几位成员同时回答时彼此看不到对方的回答。',
57
+ '房间可以临时开启讨论:主持人按目标安排专家轮流发言,到达轮次或时长上限后回到自由协作。'].join('\n');
32
58
 
33
59
  /** Normalize a launch's scheduler choice and prove its key exists for that provider's root. */
34
60
  export function validate_scheduler(home, scheduler) {
35
61
  const provider = SCHEDULER_PROVIDERS[scheduler?.provider];
36
62
  if (!provider) throw new Error(`智能调度供应商只能为 ${Object.keys(SCHEDULER_PROVIDERS).join(' / ')}`);
37
63
  if (!scheduler.model || typeof scheduler.model !== 'string') throw new Error('请填写智能调度模型');
38
- const normalized = { provider: scheduler.provider, model: scheduler.model.trim(), base_url: provider.base_url, api_key_ref: scheduler.api_key_ref };
64
+ if (scheduler.mode !== undefined && !SCHEDULER_MODES[scheduler.mode]) throw new Error(`智能调度模式只能为 ${Object.keys(SCHEDULER_MODES).join(' / ')}`);
65
+ const normalized = { provider: scheduler.provider, model: scheduler.model.trim(), base_url: provider.base_url, api_key_ref: scheduler.api_key_ref,
66
+ mode: scheduler.mode || DEFAULT_SCHEDULER_MODE };
39
67
  read_saved_api_key(home, normalized.base_url, normalized.api_key_ref);
40
68
  return normalized;
41
69
  }
@@ -125,10 +153,23 @@ function excerpt(message) {
125
153
  }
126
154
 
127
155
  /** The room's purpose in the user's words, so a decision can tell drifting from progress. */
128
- function topic_lines(title, goal, user_request, { roster, profiles, brief, focus, arrangement, progress } = {}) {
129
- return [`房间:${title || ''}`, roster && `房间成员(名字不区分大小写):${roster}`, profile_lines(profiles), goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
156
+ function topic_lines(title, goal, user_request, { guide = ROOM_GUIDE, roster, people, profiles, brief, focus, arrangement, progress, upcoming } = {}) {
157
+ return [guide, `房间:${title || ''}`, roster && `房间成员(名字不区分大小写):${roster}`, people, profile_lines(profiles), goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
130
158
  focus && `当前焦点:${focus}`, brief && `主持简报(含分工与规则):${String(brief).slice(0, MAX_BRIEF_CHARS)}`,
131
- arrangement && `主持人最近的安排:${arrangement}`, progress].filter(Boolean).join('\n');
159
+ arrangement && `主持人最近的安排:${arrangement}`, progress, upcoming && `接下来按安排发言:${upcoming.length ? upcoming.join(' → ') : '暂无,没有已安排的发言'}`].filter(Boolean).join('\n');
160
+ }
161
+
162
+ /**
163
+ * Guided discussion background: how a discussion runs and what each step costs, so the model weighs the situation
164
+ * instead of matching rules. Used by the three discussion questions in the guided mode.
165
+ */
166
+ const DISCUSSION_GUIDED_GUIDE = ['这是 AI CoDiscuss 协作房间里临时开启的一场讨论。用户是房主,给出讨论目标;其余成员都是 AI,用户常称它们为专家。',
167
+ '主持人按目标安排专家轮流发言:每轮每位专家发言一次,后发言的人能看到前面所有发言;主持简报里记着分工、顺序和规则。到达轮次或时长上限、目标达成或用户结束时,回到自由协作。',
168
+ '每请一位专家发言或回答,都要花一次执行(通常几十秒)。用户在讨论中随时可能留言:补充约束、追问某人、调整方向,或者只是鼓励。'].join('\n');
169
+ /** The member line of the guided routing state: the shared facts plus the model and the last finished message. */
170
+ function guided_member_line(role, member) {
171
+ const extra = [member.model && `模型 ${member.model}`, member.finished && `最近完成 ${member.finished}`].filter(Boolean).join(';');
172
+ return `- ${role} ${extra ? String(member.description).replace(/)$/, `;${extra})`) : member.description}`;
132
173
  }
133
174
 
134
175
  /** The owner's descriptions of members, so a decision can weigh who is good at what. */
@@ -152,20 +193,44 @@ export function certainty(answer) {
152
193
  return typeof probability === 'number' ? probability : typeof answer?.confidence === 'number' ? answer.confidence : null;
153
194
  }
154
195
 
196
+ /** Stable routing wording: an ordered checklist of evidence, each item settling the addressee before the next is read. */
197
+ const ROUTE_STABLE_STEM = '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字或用其他说法指代他(对照房间成员名单和前文只可能是他),或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;同时提到几位成员时,选被直接称呼、要求其回应或做事的那位(如“A 你让 B 去改”是在对 A 说);2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)查看各成员的用户描述:已经明确由唯一一位成员负责这类任务时,直接选该成员,不需要再次点名;仅有擅长、多人分工重叠或任务还需要拆分时交主持人;4)符合“全体”选项的条件时选全体;要求成员之间讨论或互相回应的不算,按第 7 条处理;5)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;6)只是确认、致谢或旁注选无需回应;7)对象不明确,或要讨论、互相回应、分工、排先后时选主持人,由主持人安排。';
198
+ /**
199
+ * Guided routing: instead of rules, the model gets what a member of the room would know (who is who, what each is busy
200
+ * with, what each choice leads to) and the purpose of this step, and weighs the message itself.
201
+ */
202
+ const ROUTE_GUIDED_GUIDE = ['这是 AI CoDiscuss 的协作房间。用户是房主,也是提出需求的人;其余成员都是 AI,各自运行在 Claude Code、Codex 等软件或模型 API 上,用户常称它们为专家。',
203
+ '主持人是其中一位:平时接收没人点名的消息,自己回答或分派给别人,也负责协调需要几个人配合的事。专家们在各自的会话里工作,回复都发在房间里,所有人都看得到。',
204
+ '用户找某人时通常会 @ 他,但聊顺了也常直接说话:叫名字、换个说法指代,或者接着刚才的话往下说。用户也可能事先在成员描述里交代过谁负责什么。',
205
+ '唤醒一位成员要花一次执行,通常几十秒;交给了不对的人,就得打断后重来。'].join('\n');
206
+ const ROUTE_GUIDED_STEM = '用户发来上面这条消息,没有 @ 任何人。请你像熟悉这个房间的成员一样读懂它:用户想让谁来回应?'
207
+ + '用户心里已经有明确的对象时,直接交给对方,省掉主持人转一手的时间;需要有人先理解、拆分或协调时交给主持人。'
208
+ + '用户想听每个人各自说一句时,交给全体能让大家同时开始,比主持人逐个点名快得多;但大家看不到彼此的回答,需要互相接话、讨论的事仍要主持人安排。用户只是表示收到或感谢时,不必唤醒任何人。';
209
+
210
+ /** The routing question in one mode: option texts, the stem, and what the state adds about the members. */
211
+ function route_question(mode, host, members) {
212
+ if (mode === 'stable') return { extra: { roster: [`${host.name}(主持人)`, ...members.map(member => `${member.name}(专家)`)].join('、') },
213
+ instructions: ROUTE_STABLE_STEM, choices: [...members.map(member => ({ value: member.name, description: member.description })),
214
+ { value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人且用户没有确定负责人(如单独一句“hi”“你好”这类问候、闲聊,由主持人代表房间回应),或诉求模糊、复杂,需要主持人判断怎么安排` },
215
+ { value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要看到别人的回答;只在要求简单明确、每人独立回应一次就算完成时选,如“大家打个招呼”“各自报一下在用的模型”' },
216
+ { value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答' }] };
217
+ const people = ['成员(名字不区分大小写):', guided_member_line('主持人', host), ...members.map(member => guided_member_line('专家', member))].join('\n');
218
+ return { extra: { guide: ROUTE_GUIDED_GUIDE, people }, instructions: ROUTE_GUIDED_STEM,
219
+ choices: [...members.map(member => ({ value: member.name, description: `交给 ${member.name} 回应` })),
220
+ { value: HOST_CHOICE, description: `交给主持人 ${host.name}:由他回应,或由他安排别人` },
221
+ { value: ALL_CHOICE, description: '全体成员(含主持人)同时收到这句原话,各自回应一次,彼此看不到对方的回答,也不会被分到不同的任务' },
222
+ { value: NONE_CHOICE, description: '不唤醒任何人' }] };
223
+ }
224
+
155
225
  /**
156
226
  * Decide who answers one unnamed free-room message: whether its addressee is already settled by the text or the thread.
157
227
  * Members are [{name, description}] without the host; host is {name, description}. Returns action member (with member),
158
228
  * all, none, or host for anything unsettled or not confident enough.
159
229
  */
160
230
  export async function route_free_message(home, scheduler, { title, goal, context, message, host, members }, fetch_impl) {
161
- const choices = [...members.map(member => ({ value: member.name, description: member.description })),
162
- { value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人且用户没有确定负责人(如单独一句“hi”“你好”这类问候、闲聊,由主持人代表房间回应),或诉求模糊、复杂,需要主持人判断怎么安排` },
163
- { value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要看到别人的回答;只在要求简单明确、每人独立回应一次就算完成时选,如“大家打个招呼”“各自报一下在用的模型”' },
164
- { value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答' }];
165
- const questions = [{ type: 'choice', name: 'handler', choices,
166
- instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字,或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;同时提到几位成员时,选被直接称呼、要求其回应或做事的那位(如“A 你让 B 去改”是在对 A 说);2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)查看各成员的用户描述:已经明确由唯一一位成员负责这类任务时,直接选该成员,不需要再次点名;仅有擅长、多人分工重叠或任务还需要拆分时交主持人;4)符合“全体”选项的条件时选全体;要求成员之间讨论或互相回应的不算,按第 7 条处理;5)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;6)只是确认、致谢或旁注选无需回应;7)对象不明确,或要讨论、互相回应、分工、排先后时选主持人,由主持人安排。' }];
167
- const roster = [`${host.name}(主持人)`, ...members.map(member => member.name)].join('、');
168
- const state = scheduler_state(title, context, message, goal, '', { roster }, ROUTE_CONTEXT_MESSAGES);
231
+ const { choices, instructions, extra } = route_question(scheduler.mode || DEFAULT_SCHEDULER_MODE, host, members);
232
+ const questions = [{ type: 'choice', name: 'handler', choices, instructions }];
233
+ const state = scheduler_state(title, context, message, goal, '', extra, ROUTE_CONTEXT_MESSAGES);
169
234
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
170
235
  const judged = judged_of(state, questions, answers);
171
236
  const choice = answers.handler?.choice ?? null;
@@ -201,13 +266,24 @@ export async function decide_group_order(home, scheduler, { title, goal, context
201
266
  * Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
202
267
  * Anything but a confident note or ask is returned as host.
203
268
  */
204
- export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, spoken = [], ...extra }, fetch_impl) {
269
+ export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, spoken = [], upcoming = [], ...extra }, fetch_impl) {
270
+ const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
205
271
  // Ordinary asks do not consume a discussion turn; all present members are eligible.
206
- const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言:留言是向该专家(或其负责的内容)提问、质疑或补充要求` })),
207
- { value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,同时涉及多位专家,或针对的是不在选项里的人` },
208
- { value: NOTE_CHOICE, description: '仅补充信息:留言补充背景、约束或偏好,或只是旁注、鼓励,没有要求某人立即回应;后续发言的专家都能看到它,按当前安排继续' }];
209
- const questions = [{ type: 'choice', name: 'handling', instructions: '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。', choices }];
210
- const state = scheduler_state(title, context, message, goal, user_request, { ...extra, progress: round_progress(spoken, experts.filter(name => !spoken.includes(name))) });
272
+ const choices = guided
273
+ ? [...experts.map(name => ({ value: name, description: `现在单独请 ${name} 回应这条留言:不占讨论轮次,多花一次执行` })),
274
+ { value: HOST_CHOICE, description: `交给主持人 ${host} 处理` },
275
+ { value: NOTE_CHOICE, description: '按原安排继续:留言留在房间里,后面发言的专家都会看到并可在发言中回应' }]
276
+ : [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言:留言是向该专家(或其负责的内容)提问、质疑或补充要求` })),
277
+ { value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,同时涉及多位专家,或针对的是不在选项里的人` },
278
+ { value: NOTE_CHOICE, description: '仅补充信息:留言补充背景、约束或偏好,或只是旁注、鼓励,没有要求某人立即回应;后续发言的专家都能看到它,按当前安排继续' }];
279
+ const instructions = guided
280
+ ? '讨论进行中,用户发来上面这条留言。请你像熟悉这场讨论的人一样读懂它,选出最合适的处理方式。'
281
+ + '用户向某位专家要回应时,现在单独请他回答能马上得到答复,但要多花一次执行;如果他就在接下来要发言的人里,留到他发言时一并回应更省,也不打乱讨论。'
282
+ + '补充的约束、偏好和鼓励,后面发言的人都看得到。要改目标、规则、分工或安排,或需要几位专家协调的,由主持人处理。'
283
+ : '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。';
284
+ const questions = [{ type: 'choice', name: 'handling', instructions, choices }];
285
+ const state = scheduler_state(title, context, message, goal, user_request, { ...extra, progress: round_progress(spoken, experts.filter(name => !spoken.includes(name))),
286
+ ...(guided ? { guide: DISCUSSION_GUIDED_GUIDE, upcoming } : {}) });
211
287
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
212
288
  const judged = judged_of(state, questions, answers);
213
289
  const choice = answers.handling?.choice;
@@ -244,13 +320,20 @@ export async function decide_review_completion(home, scheduler, ready, fetch_imp
244
320
  * this round, or hand back to the host when unsure or when the discussion needs steering.
245
321
  */
246
322
  export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates, spoken = [], ...extra }, fetch_impl) {
323
+ const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
247
324
  const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
248
- const state = [topic_lines(title, goal, user_request, { ...extra, progress: round_progress(spoken, candidates) }), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
325
+ const state = [topic_lines(title, goal, user_request, { ...extra, progress: round_progress(spoken, candidates), ...(guided ? { guide: DISCUSSION_GUIDED_GUIDE } : {}) }),
326
+ recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
249
327
  // Having not spoken yet is no reason to speak: the host's plan may have ended on purpose, so a candidate needs an explicit basis.
250
- const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言:简报的分工或流程规定轮到 ${name},或最近的发言点名追问了 ${name}` })),
251
- { value: HOST_CHOICE, description: `交给主持人 ${host}:简报和最近发言都没有明确规定下一位(仅仅是本轮还没发言不算依据),或需要主持人重新安排` }];
252
- const questions = [{ type: 'choice', name: 'speaker',
253
- instructions: '主持人预排的安排已用完或被取消,主持人可能有意只安排到这里。对照主持简报、当前焦点和主持人最近的安排,判断下一位应请谁发言。', choices }];
328
+ const choices = guided
329
+ ? [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言` })), { value: HOST_CHOICE, description: `交给主持人 ${host} 重新安排` }]
330
+ : [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言:简报的分工或流程规定轮到 ${name},或最近的发言点名追问了 ${name}` })),
331
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:简报和最近发言都没有明确规定下一位(仅仅是本轮还没发言不算依据),或需要主持人重新安排` }];
332
+ const instructions = guided
333
+ ? '主持人预排的发言安排已经用完或被取消。主持人有时有意只安排到这里,有时只是还没排下一位。请判断下一位该请谁发言:'
334
+ + '简报的分工、顺序,或最近有人被点名追问,都说明下一位已经明确;看不出来就交给主持人重新安排,请错人会白花一次执行,还可能违背主持人的安排。'
335
+ : '主持人预排的安排已用完或被取消,主持人可能有意只安排到这里。对照主持简报、当前焦点和主持人最近的安排,判断下一位应请谁发言。';
336
+ const questions = [{ type: 'choice', name: 'speaker', instructions, choices }];
254
337
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
255
338
  const judged = judged_of(state, questions, answers);
256
339
  const choice = answers.speaker?.choice;
@@ -268,14 +351,23 @@ export async function decide_round(home, scheduler, { title, goal, user_request,
268
351
  const candidates = experts.filter(name => name !== last_speaker);
269
352
  if (!candidates.length) return { action: 'host', confidence: null, label: '没有可选的下一轮首位,交给主持人' };
270
353
  // Telling repetition or drift needs at least the last two rounds side by side.
354
+ const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
271
355
  const recent = context.slice(-ROUTE_CONTEXT_MESSAGES).map(excerpt);
272
- const state = [topic_lines(title, goal, user_request, extra), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
273
- const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。对照讨论目标、主持简报与当前焦点判断:下一轮是否还有明确的内容可以让大家互相回应、追问或补充?', choices: [
274
- { value: 'continue', description: '继续下一轮:最近发言里有未回应的分歧、追问或可展开的点,或简报规定的流程还没走完' },
275
- { value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或最近发言在重复、偏离目标、卡住,需要主持人引导` }] }];
276
- questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?按简报分工或接龙顺序判断;没有规定时选被其他专家点名追问、需要回应的人;没有明确依据时交主持人安排。',
277
- choices: [...candidates.map(name => ({ value: name, description: `先请 ${name}:简报规定下一轮由其开头,或最近发言点名追问了该成员` })),
278
- { value: HOST_CHOICE, description: `交给主持人 ${host}:没有明确规定首位,也没有点名追问;仅仅有未决分歧不足以确定先请谁` }] });
356
+ const state = [topic_lines(title, goal, user_request, guided ? { ...extra, guide: DISCUSSION_GUIDED_GUIDE } : extra),
357
+ recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
358
+ const questions = guided ? [
359
+ { type: 'choice', name: 'next', instructions: '一轮发言刚结束。请判断讨论值不值得马上再进行一轮:还有没回应的分歧、追问,或简报规定的流程没走完时,再来一轮能推进讨论;'
360
+ + '已经达成一致、在重复、偏离目标或卡住时,多一轮只是让每位专家白花一次执行,应交给主持人收束或引导。', choices: [
361
+ { value: 'continue', description: '直接开始下一轮,每位专家再发言一次' },
362
+ { value: HOST_CHOICE, description: `交给主持人 ${host} 收束或引导` }] },
363
+ { type: 'choice', name: 'first', instructions: '如果开始下一轮,先请谁发言?简报规定了顺序,或有人被点名追问、需要先回应时,先请他;看不出依据就交给主持人决定。',
364
+ choices: [...candidates.map(name => ({ value: name, description: `先请 ${name}` })), { value: HOST_CHOICE, description: `交给主持人 ${host} 决定首位` }] }] : [
365
+ { type: 'choice', name: 'next', instructions: '一轮发言刚结束。对照讨论目标、主持简报与当前焦点判断:下一轮是否还有明确的内容可以让大家互相回应、追问或补充?', choices: [
366
+ { value: 'continue', description: '继续下一轮:最近发言里有未回应的分歧、追问或可展开的点,或简报规定的流程还没走完' },
367
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或最近发言在重复、偏离目标、卡住,需要主持人引导` }] },
368
+ { type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?按简报分工或接龙顺序判断;没有规定时选被其他专家点名追问、需要回应的人;没有明确依据时交主持人安排。',
369
+ choices: [...candidates.map(name => ({ value: name, description: `先请 ${name}:简报规定下一轮由其开头,或最近发言点名追问了该成员` })),
370
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:没有明确规定首位,也没有点名追问;仅仅有未决分歧不足以确定先请谁` }] }];
279
371
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
280
372
  const judged = judged_of(state, questions, answers);
281
373
  const choice = answers.next?.choice;
@@ -1,7 +1,7 @@
1
1
  /** Smart scheduling and limit checks for a discussion hosted inside a free room. */
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { api, peer_round_question } from './discussion_runner.mjs';
4
- import { DISCUSSION_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_WIDE_CONFIDENCE, decide_interjection, decide_next_speaker, decide_round } from './scheduler.mjs';
4
+ import { DEFAULT_SCHEDULER_MODE, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_WIDE_CONFIDENCE, decide_interjection, decide_next_speaker, decide_round, discussion_prompt_version } from './scheduler.mjs';
5
5
 
6
6
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
7
7
  /** How many of the latest plans describe "the host's recent arrangement" to the scheduler. */
@@ -164,8 +164,10 @@ export class SessionDriver {
164
164
  if (!message) return null;
165
165
  const spoken = turns.filter(turn => turn.round === roster.round && turn.status === 'answered').map(turn => turn.expert);
166
166
  const experts = roster.experts.filter(item => item.registered).map(item => item.name);
167
+ // Who the plan will ask next, in order, so a question to one of them can wait for that turn.
168
+ const upcoming = turns.filter(turn => ['open', 'queued'].includes(turn.status)).sort((left, right) => Number(left.id || 0) - Number(right.id || 0)).map(turn => turn.expert);
167
169
  const result = await decide_interjection(this.config.api_home, this.config.scheduler,
168
- { ...base, message, experts, spoken }, this.fetch);
170
+ { ...base, message, experts, spoken, upcoming }, this.fetch);
169
171
  if (result.action === 'note') {
170
172
  // Continuing needs a plan to continue; with nothing open or queued the discussion would stall without the host.
171
173
  if (turns.some(turn => ['open', 'queued'].includes(turn.status))) return { ...result, action: 'continue', kind: 'interjection', after_message_id: message.id };
@@ -199,7 +201,7 @@ export class SessionDriver {
199
201
  confidence: plan.confidence ?? null, applied, provider: this.config.scheduler.provider,
200
202
  after_message_id, detail: plan.detail, input: plan.input,
201
203
  context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE,
202
- prompt: DISCUSSION_PROMPT_VERSION, package: PACKAGE_VERSION } });
204
+ mode: this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE, prompt: discussion_prompt_version(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE), package: PACKAGE_VERSION } });
203
205
  } catch (error) { this.log(`智能调度决策记录未保存:${error.message}`); }
204
206
  }
205
207
  }
package/web/app.js CHANGED
@@ -2115,7 +2115,7 @@ async function load_launch_settings() {
2115
2115
  state.launch.saved_profiles = JSON.parse(JSON.stringify(settings.participants || {}));
2116
2116
  Object.assign(state.launch, { available: true, profiles: settings.participants, projects: settings.projects || [], api_providers: settings.api_providers || {}, api_keys: settings.api_keys || {},
2117
2117
  presets: settings.presets || [], builtin_presets: settings.builtin_presets || [], description_templates: settings.description_templates || {},
2118
- scheduler_providers: settings.scheduler_providers || {}, error: "",
2118
+ scheduler_providers: settings.scheduler_providers || {}, scheduler_modes: settings.scheduler_modes || {}, scheduler_default_mode: settings.scheduler_default_mode || "", error: "",
2119
2119
  notice: relay_machine ? `经电脑「${relay_machine.name}」执行本机操作` : local_connection ? "已连接本机服务,讨论保存在当前网站" : "" });
2120
2120
  Object.values(state.launch.profiles).forEach(fill_api_key);
2121
2121
  if (local_connection) pair_local_machine();
@@ -2420,8 +2420,8 @@ function launch_options() {
2420
2420
 
2421
2421
  /** A new room also carries the optional smart scheduling choice; only a complete, enabled choice is sent. */
2422
2422
  function creation_launch_options() {
2423
- const { enabled, provider, model, api_key_ref } = state.launch.scheduler;
2424
- return { ...launch_options(), ...(enabled && provider && model && api_key_ref ? { scheduler: { provider, model, api_key_ref } } : {}) };
2423
+ const { enabled, provider, model, api_key_ref, mode } = state.launch.scheduler;
2424
+ return { ...launch_options(), ...(enabled && provider && model && api_key_ref ? { scheduler: { provider, model, api_key_ref, ...(mode ? { mode } : {}) } } : {}) };
2425
2425
  }
2426
2426
 
2427
2427
  /** Choose whether the new discussion's host writes named follow-ups for each expert between rounds. */
@@ -2452,7 +2452,7 @@ function default_scheduler() {
2452
2452
  const provider = Object.keys(providers).find((name) => state.launch.api_keys?.[providers[name].base_url]);
2453
2453
  if (!provider) return {};
2454
2454
  const { base_url, models } = providers[provider];
2455
- return { enabled: true, provider, model: models[0] || "", api_key_ref: state.launch.api_keys[base_url] };
2455
+ return { enabled: true, provider, model: models[0] || "", api_key_ref: state.launch.api_keys[base_url], mode: state.launch.scheduler_default_mode || "" };
2456
2456
  }
2457
2457
 
2458
2458
  /** Change the smart scheduling draft; a new provider starts from its preset model and any key saved for its root. */