ai_co_discuss 0.3.6 → 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/package.json +1 -1
- package/runner/discussion_setup.mjs +52 -4
- package/runner/discussion_skills.mjs +42 -8
- package/runner/scheduler.mjs +27 -25
- package/runner/session_driver.mjs +37 -12
package/package.json
CHANGED
|
@@ -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', '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/runner/scheduler.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
214
|
-
|
|
215
|
-
const choices = [...candidates.map(name => ({ value: name, description: `请 ${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: '
|
|
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
|
|
227
|
-
if (candidates.includes(choice)
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
|
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
|
-
|
|
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 -
|
|
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
|
|
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
|
}
|