ai_co_discuss 0.3.5 → 0.3.7

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/en/runner.md CHANGED
@@ -67,7 +67,7 @@ Choose a provider and enter a key under **Smart Scheduling** (智能调度) when
67
67
 
68
68
  ## Permissions
69
69
 
70
- Project file access is set per member and is read-only by default. Agents can only run the `codiscuss` commands that read and write rooms. How strictly this is enforced depends on the tool: Codex uses its native sandbox, `agy` uses dedicated project permission rules, and permissions you have already granted in Claude Code still apply.
70
+ Permissions are set per automatically started CLI member and default to read-only. The four levels are read-only, writable, automatic approval, and unrestricted; unsupported levels are disabled (`agy` does not support automatic approval in the background). Read-only pre-approves room-related `codiscuss` commands and answer drafts; writable allows project edits and commands. Automatic approval delegates decisions to the tool, while unrestricted skips permission prompts. Hover over a level for its exact scope. Codex uses its native sandbox, `agy` uses dedicated project rules, and existing Claude Code grants still apply. Manually started members keep their session's own permissions.
71
71
 
72
72
  ## Local Service
73
73
 
package/docs/runner.md CHANGED
@@ -69,7 +69,7 @@ API 成员可以担任专家或主持人,只有房间读写工具,不访问
69
69
 
70
70
  ## 权限
71
71
 
72
- 项目文件权限按成员设置,默认只读。AI 只能运行读写房间的 `codiscuss` 命令。限制强度取决于各软件:Codex 使用原生沙箱,agy 使用独立的项目权限规则,Claude Code 中你已有的授权仍然生效。
72
+ 项目权限按程序启动的 CLI 成员设置,默认只读;可选只读、可写、自动、全放开,软件不支持的档位灰显(agy 后台不支持自动)。只读档预先放行读写房间的 `codiscuss` 命令和答案草稿,可写档允许修改项目、运行命令;自动档由软件审核操作,全放开跳过权限确认。具体范围见档位的悬停说明。Codex 使用原生沙箱,agy 使用独立项目规则,Claude Code 中你已有的授权仍然生效;手动启动成员的权限沿用原会话设置。
73
73
 
74
74
  ## 常驻讨论服务
75
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -256,7 +256,7 @@ function agy_project_grants(config, task, resources, user_home) {
256
256
  throw new Error('agy 草稿目录不能位于只读项目、运行目录、技能或 .gemini 配置目录中,未交付讨论任务');
257
257
  }
258
258
  const write_rule = directory => `write_file(${resolve(directory).replaceAll('\\', '/')})`;
259
- // 全放开 also runs commands outside agy's sandbox and allows MCP tools; run data stays protected at every level.
259
+ // 全放开 also allows commands outside agy's sandbox and MCP tools; deny rules are still written, but bypass may override them.
260
260
  const full = config.project_access === 'full';
261
261
  return { allow: [agy_rule(), write_rule(task.draft_directory),
262
262
  ...(config.project_access === 'read' ? [] : [...projects.map(write_rule), 'command(*)']), ...(full ? ['unsandboxed_command(*)'] : []),
@@ -727,7 +727,10 @@ function cli_invocation(config, binding, task, user_home) {
727
727
  const project = config.session_id ? null : prepare_agy_project(config, binding, task, user_home);
728
728
  const args = ['--output-format', 'stream-json', '--print-timeout', '0', '--add-dir', task.directory,
729
729
  '--add-dir', task.draft_directory, '--add-dir', task.skills_directory];
730
- args.push('--mode', 'accept-edits', ...(project ? ['--project', project.project_id] : []));
730
+ // Headless agy refuses `--mode auto` ("not available for this account", 1.4.10–1.4.11) though its interactive window
731
+ // offers it; 全放开 uses the flag its own denial message suggests on top of the open project grants.
732
+ args.push('--mode', 'accept-edits', ...(config.project_access === 'full' ? ['--dangerously-skip-permissions'] : []),
733
+ ...(project ? ['--project', project.project_id] : []));
731
734
  if (binding.session_id) args.push('--conversation', binding.session_id);
732
735
  if (config.model) args.push('--model', config.model);
733
736
  if (config.effort) args.push('--effort', config.effort);
@@ -3,7 +3,7 @@ import { spawn } from 'node:child_process';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
- import { dirname, isAbsolute, join, resolve } from 'node:path';
6
+ import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path';
7
7
  import { DatabaseSync } from 'node:sqlite';
8
8
  import { setTimeout as sleep } from 'node:timers/promises';
9
9
  import { pathToFileURL } from 'node:url';
@@ -210,7 +210,9 @@ export function pick_directory({ platform = process.platform, spawn_process = sp
210
210
  child.once('error', () => reject(new Error('无法打开目录选择窗口,请直接输入完整路径')));
211
211
  child.once('close', code => {
212
212
  const path = output.trim();
213
- resolve_result(code === 0 && isAbsolute(path) ? { path } : { cancelled: true });
213
+ // Judge the path by the picker's platform, not the host's: a Windows path is not absolute on POSIX.
214
+ const absolute = platform === 'win32' ? win32.isAbsolute(path) : posix.isAbsolute(path);
215
+ resolve_result(code === 0 && absolute ? { path } : { cancelled: true });
214
216
  });
215
217
  });
216
218
  }
@@ -9,7 +9,7 @@ import { parseArgs } from 'node:util';
9
9
  import { atomic_json } from './discussion_adapters.mjs';
10
10
  import { launch_settings, save_launch_settings } from './discussion_launch.mjs';
11
11
  import { assert_shared_home, service_home } from './discussion_service.mjs';
12
- import { install_skills } from './discussion_skills.mjs';
12
+ import { SKILLS, install_skills, remove_legacy_skills } from './discussion_skills.mjs';
13
13
  import { ask, checkbox_prompt, intro, outro, pad, short_path, spinner, step, styled, text_width, tone } from './discussion_tui.mjs';
14
14
  export { checkbox_prompt } from './discussion_tui.mjs';
15
15
 
@@ -22,10 +22,13 @@ const ATTENTION_EXIT_CODE = 2;
22
22
  const AGY_SETTINGS = ['.gemini', 'antigravity-cli', 'settings.json'];
23
23
  const AGY_RULE_RECORD = 'agy-permission.json';
24
24
  const AGY_SETTINGS_BACKUP = 'agy-settings.backup.json';
25
+ const SKILL_RECORD = 'skills-manifest.json';
26
+ const AGY_SKILL_CONFIG = ['.gemini', 'config', 'skills.json'];
25
27
  const CLIENTS = {
26
28
  codex: { name: 'Codex', commands: ['codex'], adapter: 'codex_cli', skills: ['.agents', 'skills'] },
27
29
  claude: { name: 'Claude', commands: ['claude'], adapter: 'claude', skills: ['.claude', 'skills'] },
28
- antigravity: { name: 'Gemini', commands: ['agy'], adapter: 'antigravity_cli', skills: ['.gemini', 'antigravity-cli', 'skills'] },
30
+ antigravity: { name: 'Gemini', commands: ['agy'], adapter: 'antigravity_cli', skills: ['.gemini', 'config', 'skills'],
31
+ legacy_skills: ['.gemini', 'antigravity-cli', 'skills'] },
29
32
  cursor: { name: 'Grok(Cursor CLI)', commands: ['agent', 'cursor-agent'], skills: ['.cursor', 'skills'] },
30
33
  };
31
34
  const OPTIONS = { yes: { type: 'boolean' }, clients: { type: 'string' }, python: { type: 'string' },
@@ -222,16 +225,58 @@ export function remove_legacy_agy_rules(home, user_home) {
222
225
  return { client: 'Gemini', status: next.length !== allow.length ? 'removed' : 'unchanged', path };
223
226
  }
224
227
 
228
+ /** Follow a linked skill directory so clients sharing one folder install a single copy. */
229
+ function real_directory(path) {
230
+ try { return realpathSync.native(path); } catch { return resolve(path); }
231
+ }
232
+
233
+ /** True when an entry's include_only/exclude patterns let agy load every discussion skill; invalid patterns count as filtering. */
234
+ function entry_loads_skills(entry) {
235
+ const matches = (patterns, name) => patterns.some(pattern => {
236
+ try { return new RegExp(`^(?:${pattern})$`).test(name); } catch { return true; }
237
+ });
238
+ const include = Array.isArray(entry.include_only) ? entry.include_only : null;
239
+ const exclude = Array.isArray(entry.exclude) ? entry.exclude : [];
240
+ return SKILLS.every(name => (!include || matches(include, name)) && !matches(exclude, name));
241
+ }
242
+
243
+ /** Absolute directories agy loads the discussion skills from through its global skills.json; agy leaves ~/ unexpanded on Windows. */
244
+ function agy_skill_entries(user_home) {
245
+ const path = join(user_home, ...AGY_SKILL_CONFIG);
246
+ let config;
247
+ try { config = JSON.parse(readFileSync(path, 'utf8')); } catch { return []; }
248
+ return (Array.isArray(config?.entries) ? config.entries : [])
249
+ .filter(entry => typeof entry?.path === 'string' && entry_loads_skills(entry)).map(entry => entry.path)
250
+ .map(value => process.platform !== 'win32' && value.startsWith('~/') ? join(user_home, value.slice(2)) : value)
251
+ .filter(value => isAbsolute(value)).map(real_directory);
252
+ }
253
+
254
+ /** Pick the directory each client really reads; agy reuses a skills.json entry instead of its own folder. */
255
+ function skill_targets(selected, user_home) {
256
+ const own = new Map(selected.map(client => [client.key, real_directory(join(user_home, ...client.skills))]));
257
+ const entries = agy_skill_entries(user_home);
258
+ return selected.map(client => {
259
+ const directory = own.get(client.key);
260
+ if (client.key !== 'antigravity' || !entries.length) return { client, directory, own: directory };
261
+ const others = selected.filter(item => item.key !== client.key).map(item => own.get(item.key));
262
+ return { client, directory: entries.find(entry => others.includes(entry)) || entries[0], own: directory };
263
+ });
264
+ }
265
+
225
266
  /** Save the selected effective profiles and copy complete skills without overwriting edits. */
226
267
  async function configure_clients(home, selected, options, existing) {
227
268
  const participants = { ...existing };
228
269
  const skills = [], profiles = [];
229
270
  const user_home = resolve(options['skills-home'] || homedir());
230
- for (const client of selected) {
271
+ const record = join(home, SKILL_RECORD), installed = new Map();
272
+ for (const { client, directory, own } of skill_targets(selected, user_home)) {
231
273
  const profile = await choose_profile(client, participants[client.name], options);
232
274
  profiles.push(profile);
233
275
  if (profile.status === 'configured') participants[client.name] = { ...participants[client.name], ...profile.effective };
234
- skills.push({ client: client.name, results: install_skills(join(user_home, ...client.skills)) });
276
+ if (!installed.has(directory)) installed.set(directory, install_skills(directory, record));
277
+ const retired = [client.legacy_skills && real_directory(join(user_home, ...client.legacy_skills)), own]
278
+ .filter(path => path && path !== directory);
279
+ skills.push({ client: client.name, results: installed.get(directory), legacy: retired.flatMap(path => remove_legacy_skills(path, record)) });
235
280
  }
236
281
  save_launch_settings(home, { participants });
237
282
  const legacy = remove_legacy_agy_rules(home, user_home);
@@ -273,6 +318,9 @@ function setup_issues(result) {
273
318
  for (const group of result.skills) for (const skill of group.results) {
274
319
  if (skill.status === 'conflict') issues.push(`${group.client} 技能冲突,已保留 ${skill.path};请核对并移走需替换的旧副本,再运行 setup。`);
275
320
  }
321
+ for (const group of result.skills) for (const skill of group.legacy) {
322
+ if (skill.status === 'kept') issues.push(`${group.client} 旧技能目录中的副本有改动,已保留 ${skill.path};软件会同时加载新旧两份,请核对后删除。`);
323
+ }
276
324
  for (const permission of result.permissions) {
277
325
  if (permission.status === 'invalid') issues.push(`${permission.client} 设置文件无法解析,未能移除旧版全局放行规则:${permission.path};修复后重新运行 setup,否则全自动讨论会拒绝交付给 agy。`);
278
326
  }
@@ -1,14 +1,14 @@
1
- /** Install complete skills without replacing an unmanaged or locally edited copy. */
1
+ /** Install complete skills without replacing an unmanaged or locally edited copy; hashes live in the service home. */
2
2
  import { createHash } from 'node:crypto';
3
3
  import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
4
- import { join, resolve } from 'node:path';
4
+ import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
7
  import { atomic_json } from './discussion_adapters.mjs';
8
8
 
9
9
  const SOURCE = fileURLToPath(new URL('../.agents/skills/', import.meta.url));
10
- const SKILLS = ['discuss-host', 'discuss-participant', 'discuss-report'];
11
- const MANIFEST = '.codiscuss-skills.json';
10
+ export const SKILLS = ['discuss-host', 'discuss-participant', 'discuss-report'];
11
+ const LEGACY_MANIFEST = '.codiscuss-skills.json';
12
12
 
13
13
  /** Hash names and contents, rejecting links instead of following them outside a skill. */
14
14
  function tree_hash(directory) {
@@ -41,15 +41,49 @@ function install_skill(root, name, previous) {
41
41
  return { name, status: previous ? 'updated' : 'installed', path: destination, hash };
42
42
  }
43
43
 
44
+ /** Read hashes recorded for one directory, folding in the copy older versions kept inside it. */
45
+ function recorded_hashes(record, root) {
46
+ const data = existsSync(record) ? JSON.parse(readFileSync(record, 'utf8')) : {};
47
+ const legacy = join(root, LEGACY_MANIFEST);
48
+ return { data, previous: { ...(existsSync(legacy) ? JSON.parse(readFileSync(legacy, 'utf8')) : {}), ...data[root] } };
49
+ }
50
+
51
+ /** Save the record outside skill directories and drop the in-directory file older versions wrote. */
52
+ function save_record(record, root, data) {
53
+ mkdirSync(dirname(record), { recursive: true });
54
+ atomic_json(record, data);
55
+ rmSync(join(root, LEGACY_MANIFEST), { force: true });
56
+ }
57
+
44
58
  /** Install all three skills together and remember only copies this installer manages. */
45
- export function install_skills(directory) {
59
+ export function install_skills(directory, record) {
46
60
  const root = resolve(directory);
47
61
  mkdirSync(root, { recursive: true });
48
- const manifest = join(root, MANIFEST);
49
- const previous = existsSync(manifest) ? JSON.parse(readFileSync(manifest, 'utf8')) : {};
62
+ const { data, previous } = recorded_hashes(record, root);
50
63
  const results = SKILLS.map(name => install_skill(root, name, previous[name]));
51
64
  const managed = { ...previous };
52
65
  for (const result of results) if (result.hash) managed[result.name] = result.hash;
53
- atomic_json(manifest, managed);
66
+ save_record(record, root, { ...data, [root]: managed });
54
67
  return results.map(({ hash, ...result }) => result);
55
68
  }
69
+
70
+ /** Remove unmodified copies a previous install left in a retired directory; edited copies stay. */
71
+ export function remove_legacy_skills(directory, record) {
72
+ const root = resolve(directory);
73
+ if (!existsSync(root)) return [];
74
+ const { data, previous } = recorded_hashes(record, root);
75
+ if (!Object.keys(previous).length) return [];
76
+ const results = SKILLS.filter(name => previous[name] && existsSync(join(root, name))).map(name => {
77
+ const path = join(root, name);
78
+ let current;
79
+ try { current = tree_hash(path); } catch { return { name, status: 'kept', path }; }
80
+ if (current !== previous[name]) return { name, status: 'kept', path };
81
+ rmSync(path, { recursive: true });
82
+ return { name, status: 'removed', path };
83
+ });
84
+ const kept = Object.fromEntries(results.filter(result => result.status === 'kept').map(result => [result.name, previous[result.name]]));
85
+ const { [root]: _, ...rest } = data;
86
+ save_record(record, root, Object.keys(kept).length ? { ...rest, [root]: kept } : rest);
87
+ if (!readdirSync(root).length) rmSync(root, { recursive: true });
88
+ return results;
89
+ }
@@ -58,17 +58,23 @@ const ACCESS_HINTS = {
58
58
  codex_cli: { read: '只读沙箱', write: '项目可写的沙箱,命令在沙箱内运行、可联网',
59
59
  auto: '沙箱外的操作由模型申请提权,Codex 自动审核代替人工批准', full: '无沙箱、无审批(--dangerously-bypass-approvals-and-sandbox)' },
60
60
  antigravity_cli: { read: '只放行网站命令;碰到未放行的命令会直接结束本轮', write: '可写项目,命令全部放行(在 agy 沙箱内)',
61
- full: '再放开沙箱外命令与 MCP 工具' },
61
+ full: '跳过全部权限确认(--dangerously-skip-permissions),并放开沙箱外命令与 MCP 工具' },
62
62
  };
63
63
 
64
- /** agy has no automatic mode headless ("not available for this account", tested 1.4.10), so it offers no 自动. */
64
+ /** agy offers 自动 only in its interactive window; headless `--mode auto` is "not available for this account" (1.4.10–1.4.11). */
65
65
  export function access_levels(adapter) {
66
66
  return adapter === 'antigravity_cli' ? PROJECT_ACCESS.filter(level => level !== 'auto') : PROJECT_ACCESS;
67
67
  }
68
68
 
69
- /** The levels a software supports, labelled for the page, with what each does there on hover. */
69
+ // Why a level a software lacks is shown but disabled, so every CLI card lines up the same four levels.
70
+ const UNSUPPORTED_HINTS = { antigravity_cli: { auto: 'agy 只在交互窗口提供自动模式,后台运行(-p)不支持' } };
71
+
72
+ /** All four levels labelled for the page, with what each does in this software on hover; ones it lacks are disabled. */
70
73
  export function access_options(adapter) {
71
- return access_levels(adapter).map(value => ({ value, label: ACCESS_LABELS[value], hint: ACCESS_HINTS[adapter]?.[value] || '' }));
74
+ const levels = access_levels(adapter);
75
+ return PROJECT_ACCESS.map(value => levels.includes(value)
76
+ ? { value, label: ACCESS_LABELS[value], hint: ACCESS_HINTS[adapter]?.[value] || '' }
77
+ : { value, label: ACCESS_LABELS[value], hint: UNSUPPORTED_HINTS[adapter]?.[value] || '该软件不支持此档位', disabled: true });
72
78
  }
73
79
 
74
80
  /** The page's name for a level; `own` reads as decided by the member's own session. */
@@ -12,7 +12,9 @@ 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 = 6;
15
+ export const ROUTE_PROMPT_VERSION = 7;
16
+ /** The same for the three discussion questions (interjection, next speaker, next round). */
17
+ export const DISCUSSION_PROMPT_VERSION = 2;
16
18
  const HOST_CHOICE = '__host__';
17
19
  const NOTE_CHOICE = '__note__';
18
20
  const ALL_CHOICE = '__all__';
@@ -101,6 +103,11 @@ function judged_of(state, questions, answers) {
101
103
  return { detail: detail_of(questions, answers), input: { state, questions } };
102
104
  }
103
105
 
106
+ /** Who has and has not spoken this round, so a decision knows who the plan still owes a turn. */
107
+ function round_progress(spoken, waiting) {
108
+ return `本轮已发言:${spoken.join('、') || '无'};本轮尚未发言:${waiting.join('、') || '无'}。`;
109
+ }
110
+
104
111
  /** Say which option the provider leaned to when it was not sure enough, so the transcript shows how close it was. */
105
112
  function unsure_label(lean) {
106
113
  return lean ? `倾向${lean},把握不足,交给主持人` : '拿不准,交给主持人';
@@ -152,7 +159,7 @@ export async function route_free_message(home, scheduler, { title, goal, context
152
159
  { value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要看到别人的回答;只在要求简单明确、每人独立回应一次就算完成时选,如“大家打个招呼”“各自报一下在用的模型”' },
153
160
  { value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答' }];
154
161
  const questions = [{ type: 'choice', name: 'handler', choices,
155
- instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字,或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)符合“全体”选项的条件时选全体;只出现“大家”“都”“各自”“一起”等字眼不够;4)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;5)只是确认、致谢或旁注选无需回应;6)对象不明确,或要讨论、互相回应、分工、排先后时选主持人,由主持人安排。' }];
162
+ instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字,或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;同时提到几位成员时,选被直接称呼、要求其回应或做事的那位(如“A 你让 B 去改”是在对 A 说);2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)符合“全体”选项的条件时选全体;只出现“大家”“都”“各自”“一起”等字眼不够;4)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;5)只是确认、致谢或旁注选无需回应;6)对象不明确,或要讨论、互相回应、分工、排先后时选主持人,由主持人安排。' }];
156
163
  const roster = [`${host.name}(主持人)`, ...members.map(member => member.name)].join('、');
157
164
  const state = scheduler_state(title, context, message, goal, '', { roster }, ROUTE_CONTEXT_MESSAGES);
158
165
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
@@ -187,12 +194,13 @@ export async function decide_group_order(home, scheduler, { title, goal, context
187
194
  * Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
188
195
  * Anything but a confident note or ask is returned as host.
189
196
  */
190
- export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, ...extra }, fetch_impl) {
197
+ export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, spoken = [], ...extra }, fetch_impl) {
198
+ // Only experts still owed a turn this round can be asked: asking one who has spoken would open a new round.
191
199
  const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言:留言是向该专家(或其负责的内容)提问、质疑或补充要求` })),
192
- { value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,或同时涉及多位专家` },
193
- { value: NOTE_CHOICE, description: '仅补充信息:留言只是旁注、信息或鼓励,不要求任何人回应,按当前安排继续' }];
200
+ { value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,同时涉及多位专家,或针对的是不在选项里的人(如本轮已发言的专家、主持人自己)` },
201
+ { value: NOTE_CHOICE, description: '仅补充信息:留言补充背景、约束或偏好,或只是旁注、鼓励,没有要求某人立即回应;后续发言的专家都能看到它,按当前安排继续' }];
194
202
  const questions = [{ type: 'choice', name: 'handling', instructions: '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。', choices }];
195
- const state = scheduler_state(title, context, message, goal, user_request, extra);
203
+ const state = scheduler_state(title, context, message, goal, user_request, { ...extra, progress: round_progress(spoken, experts) });
196
204
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
197
205
  const judged = judged_of(state, questions, answers);
198
206
  const choice = answers.handling?.choice;
@@ -210,27 +218,19 @@ export async function decide_interjection(home, scheduler, { title, goal, user_r
210
218
  */
211
219
  export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates, spoken = [], ...extra }, fetch_impl) {
212
220
  const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
213
- const progress = `本轮已发言:${spoken.join('、') || '无'};本轮尚未发言:${candidates.join('、')}。`;
214
- const state = [topic_lines(title, goal, user_request, { ...extra, progress }), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
215
- const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言:简报的分工或接龙顺序轮到 ${name},或刚才的发言点名追问了 ${name}` })),
216
- { value: HOST_CHOICE, description: `交给主持人 ${host}:没有明确该谁发言,或需要主持人重新安排` }];
217
- // Having not spoken yet is no reason to speak: the host's plan may have ended on purpose, so the scheduler acts only on an explicit obligation.
221
+ const state = [topic_lines(title, goal, user_request, { ...extra, progress: round_progress(spoken, candidates) }), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
222
+ // 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.
223
+ const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言:简报的分工或流程规定轮到 ${name},或最近的发言点名追问了 ${name}` })),
224
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:简报和最近发言都没有明确规定下一位(仅仅是本轮还没发言不算依据),或需要主持人重新安排` }];
218
225
  const questions = [{ type: 'choice', name: 'speaker',
219
- instructions: '主持人预排的安排已用完或被取消。对照主持简报的分工、当前焦点和主持人最近的安排,判断下一位应请谁发言。', choices },
220
- { type: 'predicate', name: 'obligation',
221
- instructions: `是否存在明确依据要求 ${candidates.join('、')} 之一接着发言:简报分工或流程规定轮到其人,或最近发言点名追问了其人?仅仅是“本轮还没发言”不算依据。` }];
226
+ instructions: '主持人预排的安排已用完或被取消,主持人可能有意只安排到这里。对照主持简报、当前焦点和主持人最近的安排,判断下一位应请谁发言。', choices }];
222
227
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
223
228
  const judged = judged_of(state, questions, answers);
224
229
  const choice = answers.speaker?.choice;
225
230
  const confidence = certainty(answers.speaker);
226
- const obligation = answers.obligation?.probability;
227
- if (candidates.includes(choice) && confidence !== null && confidence >= ROUTE_CONFIDENCE && obligation >= ROUTE_CONFIDENCE) {
228
- return { action: 'next', expert: choice, confidence, ...judged, label: `本轮继续,请 ${choice} 接着发言` };
229
- }
230
- if (typeof obligation === 'number' && obligation < ROUTE_CONFIDENCE) {
231
- return { action: 'host', confidence, ...judged, label: `有明确依据的概率 ${obligation.toFixed(2)},未达 ${ROUTE_CONFIDENCE.toFixed(2)},交给主持人安排` };
232
- }
233
- return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && confidence >= ROUTE_CONFIDENCE ? '建议由主持人调整安排,交给主持人' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
231
+ const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
232
+ if (sure && candidates.includes(choice)) return { action: 'next', expert: choice, confidence, ...judged, label: `本轮继续,请 ${choice} 接着发言` };
233
+ return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && sure ? '没有明确该谁发言,交给主持人安排' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
234
234
  }
235
235
 
236
236
  /**
@@ -239,7 +239,8 @@ export async function decide_next_speaker(home, scheduler, { title, goal, user_r
239
239
  */
240
240
  export async function decide_round(home, scheduler, { title, goal, user_request, context, host, experts, last_speaker, ...extra }, fetch_impl) {
241
241
  const candidates = experts.filter(name => name !== last_speaker);
242
- const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
242
+ // Telling repetition or drift needs at least the last two rounds side by side.
243
+ const recent = context.slice(-ROUTE_CONTEXT_MESSAGES).map(excerpt);
243
244
  const state = [topic_lines(title, goal, user_request, extra), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
244
245
  const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。对照讨论目标、主持简报与当前焦点判断:下一轮是否还有明确的内容可以让大家互相回应、追问或补充?', choices: [
245
246
  { value: 'continue', description: '继续下一轮:最近发言里有未回应的分歧、追问或可展开的点,或简报规定的流程还没走完' },
@@ -250,8 +251,9 @@ export async function decide_round(home, scheduler, { title, goal, user_request,
250
251
  const judged = judged_of(state, questions, answers);
251
252
  const choice = answers.next?.choice;
252
253
  const confidence = certainty(answers.next);
253
- if (choice !== 'continue' || confidence === null || confidence < ROUTE_CONFIDENCE || !candidates.length) {
254
- const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
254
+ // A wrong "continue" costs a whole round of runs, so it needs the same certainty as waking everyone.
255
+ if (choice !== 'continue' || confidence === null || confidence < ROUTE_WIDE_CONFIDENCE || !candidates.length) {
256
+ const sure = confidence !== null && confidence >= (choice === 'continue' ? ROUTE_WIDE_CONFIDENCE : ROUTE_CONFIDENCE);
255
257
  return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && sure ? '建议收束或调整方向,交给主持人' : unsure_label(choice === 'continue' ? '继续下一轮' : choice === HOST_CHOICE ? '收束或调整方向' : '') };
256
258
  }
257
259
  const first = candidates.includes(answers.first?.choice) ? answers.first.choice : candidates[0];
@@ -1,9 +1,14 @@
1
1
  /** Smart scheduling and limit checks for a discussion hosted inside a free room. */
2
+ import { readFileSync } from 'node:fs';
2
3
  import { api, peer_round_question } from './discussion_runner.mjs';
3
- import { decide_interjection, decide_next_speaker, decide_round } from './scheduler.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
5
 
5
- const CONTEXT_MESSAGES = 12;
6
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
6
7
  const MAX_QUOTE_CHARS = 300;
8
+ /** How many of the latest plans describe "the host's recent arrangement" to the scheduler. */
9
+ const ARRANGEMENT_TURNS = 3;
10
+ const MAX_ARRANGEMENT_CHARS = 200;
11
+ const TURN_STATUS_LABELS = { answered: '已发言', open: '发言中', queued: '排队中', cancelled: '已取消', skipped: '已跳过' };
7
12
 
8
13
  /** Read every page of current-session discussion plans; the list endpoint is capped at 20 rows per page. */
9
14
  async function read_all_turns(config) {
@@ -49,8 +54,9 @@ export class SessionDriver {
49
54
  try {
50
55
  const [roster, turns] = await Promise.all([api(this.config, '/turns/roster'), read_all_turns(this.config)]);
51
56
  const open_turn = turns.some(turn => ['open', 'queued'].includes(turn.status));
57
+ // An interjection is judged when someone can still be asked or a plan is running that a side note can leave alone.
52
58
  const should_decide = request.cause_kind === 'user_message'
53
- ? roster.waiting.length > 0
59
+ ? roster.waiting.length > 0 || open_turn
54
60
  : !open_turn && (roster.waiting.length > 0 || !session.named_followup);
55
61
  if (!should_decide) return false;
56
62
  this.handled_requests.add(request.id);
@@ -60,7 +66,7 @@ export class SessionDriver {
60
66
  const suggestion = { message_id: request.source_message_id || latest_id, suggestion: plan.label,
61
67
  confidence: plan.confidence ?? null };
62
68
 
63
- if (plan.action !== 'schedule') {
69
+ if (plan.action !== 'schedule' && plan.action !== 'continue') {
64
70
  await this.record_decision(plan, false, suggestion.message_id);
65
71
  this.suggestions.set(request.id, [suggestion]);
66
72
  return false;
@@ -76,7 +82,9 @@ export class SessionDriver {
76
82
  if (latest_roster.revision !== roster.revision) throw new Error('讨论名单或安排已变化,交回主持人');
77
83
  const host_token = this.config.members?.[room.host]?.token;
78
84
  if (!host_token) throw new Error('缺少主持人成员凭据,交回主持人');
79
- await api(this.config, '/turns', { actor: room.host, turns: [plan.turn], roster: latest_roster.revision,
85
+ // A side note keeps the running plan: closing the host request is all that is needed.
86
+ if (plan.action === 'continue') await api(this.config, '/turns/continue', { actor: room.host, revision: latest_roster.revision }, host_token);
87
+ else await api(this.config, '/turns', { actor: room.host, turns: [plan.turn], roster: latest_roster.revision,
80
88
  submission_id: `session-driver-${session.id}-${request.id}` }, host_token);
81
89
  await this.record_decision(plan, true, suggestion.message_id);
82
90
  this.log(`智能调度 #${request.id}:${plan.label}`);
@@ -108,14 +116,15 @@ export class SessionDriver {
108
116
  /** Choose an eligible next task; manual and web members remain valid candidates from the roster. */
109
117
  async plan(session, roster, turns, request, room) {
110
118
  const latest_id = room.latest_id || 0;
111
- const page = await api(this.config, `/messages?after=${Math.max(0, latest_id - CONTEXT_MESSAGES)}`);
119
+ const page = await api(this.config, `/messages?after=${Math.max(0, latest_id - ROUTE_CONTEXT_MESSAGES)}`);
112
120
  const base = { title: room.title, goal: session.goal || room.goal, user_request: room.user_request,
113
- context: page.messages, host: room.host, brief: room.host_brief?.text, focus: room.focus, profiles: room.descriptions };
121
+ context: page.messages, host: room.host, brief: room.host_brief?.text, focus: room.focus, profiles: room.descriptions,
122
+ arrangement: arrangement_of(turns) };
114
123
  const last_answer = [...turns].filter(turn => turn.status === 'answered')
115
124
  .sort((left, right) => Number(left.answer_id || 0) - Number(right.answer_id || 0)).at(-1);
116
125
  const anchor = last_answer?.answer_id || request.source_message_id || latest_id;
117
126
 
118
- if (request.cause_kind === 'user_message') return this.plan_interjection(base, page.messages, session, roster, request);
127
+ if (request.cause_kind === 'user_message') return this.plan_interjection(base, page.messages, session, roster, request, turns);
119
128
  const open = turns.some(turn => ['open', 'queued'].includes(turn.status));
120
129
  if (open) return null;
121
130
  if (roster.waiting.length) return this.plan_speaker(base, session, roster, turns, anchor);
@@ -130,12 +139,18 @@ export class SessionDriver {
130
139
  }
131
140
 
132
141
  /** Decide whether an unnamed user message should be answered by an expert or returned to the host. */
133
- async plan_interjection(base, messages, session, roster, request) {
142
+ async plan_interjection(base, messages, session, roster, request, turns) {
134
143
  const message = messages.find(item => item.id === request.source_message_id)
135
144
  || messages.find(item => item.author === '用户' && item.id >= (session.held_since || 0));
136
- if (!message || !roster.waiting.length) return null;
145
+ if (!message) return null;
146
+ const spoken = turns.filter(turn => turn.round === roster.round && turn.status === 'answered').map(turn => turn.expert);
137
147
  const result = await decide_interjection(this.config.api_home, this.config.scheduler,
138
- { ...base, message, experts: roster.waiting }, this.fetch);
148
+ { ...base, message, experts: roster.waiting, spoken }, this.fetch);
149
+ if (result.action === 'note') {
150
+ // Continuing needs a plan to continue; with nothing open or queued the discussion would stall without the host.
151
+ if (turns.some(turn => ['open', 'queued'].includes(turn.status))) return { ...result, action: 'continue', kind: 'interjection', after_message_id: message.id };
152
+ return { ...result, action: 'host', kind: 'interjection', after_message_id: message.id, label: '仅补充信息,但当前没有进行中的安排,交给主持人' };
153
+ }
139
154
  if (result.action !== 'ask' || !roster.waiting.includes(result.expert)) {
140
155
  return { ...result, action: 'host', kind: 'interjection', after_message_id: message.id };
141
156
  }
@@ -165,11 +180,21 @@ export class SessionDriver {
165
180
  try {
166
181
  await api(this.config, '/scheduler-decisions', { kind: plan.kind, label: plan.label,
167
182
  confidence: plan.confidence ?? null, applied, provider: this.config.scheduler.provider,
168
- after_message_id, detail: plan.detail, input: plan.input });
183
+ after_message_id, detail: plan.detail, input: plan.input,
184
+ context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE,
185
+ prompt: DISCUSSION_PROMPT_VERSION, package: PACKAGE_VERSION } });
169
186
  } catch (error) { this.log(`智能调度决策记录未保存:${error.message}`); }
170
187
  }
171
188
  }
172
189
 
190
+ /** The latest plans with their state, as the host's recent arrangement the questions refer to. */
191
+ function arrangement_of(turns) {
192
+ return [...turns].sort((left, right) => Number(left.id || 0) - Number(right.id || 0)).slice(-ARRANGEMENT_TURNS).map(turn => {
193
+ const question = String(turn.question || '').replace(/\s+/g, ' ').trim();
194
+ return `${turn.expert}(第 ${turn.round} 轮,${TURN_STATUS_LABELS[turn.status] || turn.status}):${question.length > MAX_ARRANGEMENT_CHARS ? `${question.slice(0, MAX_ARRANGEMENT_CHARS)}…` : question}`;
195
+ }).join(';');
196
+ }
197
+
173
198
  function opening_question(phase) {
174
199
  return phase === 'initial' ? '请先独立阅读议题,给出你的开场观点。' : '请先阅读议题和已有回答,给出你的观点。';
175
200
  }
package/web/app.js CHANGED
@@ -2458,12 +2458,11 @@ function launch_access(name) {
2458
2458
  /** Classic select options; an empty value inherits the launch-wide setting. */
2459
2459
  const ACCESS_LABELS = { read: "只读", write: "可写", auto: "自动", full: "完全放开" };
2460
2460
 
2461
- // Mirrors access_options in runner/expert_prompts.mjs; agy has no headless 自动, and the shared setting stays 只读 / 可写.
2461
+ // Mirrors access_options in runner/expert_prompts.mjs; agy shows 自动 disabled (no headless auto), and the shared setting stays 只读 / 可写.
2462
2462
  function access_options(value = "", inherit = "", adapter = "") {
2463
- const levels = !inherit ? [["read", "只读"], ["write", "可写"]]
2464
- : [["read", "只读"], ["write", "可写"], ...(adapter === "antigravity_cli" ? [] : [["auto", "自动"]]), ["full", "完全放开"]];
2463
+ const levels = !inherit ? [["read", "只读"], ["write", "可写"]] : [["read", "只读"], ["write", "可写"], ["auto", "自动"], ["full", "全放开"]];
2465
2464
  const options = [...(inherit ? [["", inherit]] : []), ...levels];
2466
- return options.map(([key, label]) => `<option value="${key}" ${(value || (inherit ? "" : "read")) === key ? "selected" : ""}>${label}</option>`).join("");
2465
+ return options.map(([key, label]) => `<option value="${key}" ${(value || (inherit ? "" : "read")) === key ? "selected" : ""}${key === "auto" && adapter === "antigravity_cli" ? " disabled" : ""}>${label}</option>`).join("");
2467
2466
  }
2468
2467
 
2469
2468
  /** Refresh classic readbacks without replacing the input currently being edited. */