ai_co_discuss 0.2.97 → 0.2.99
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/.agents/skills/discuss-participant/SKILL.md +1 -1
- package/.agents/skills/discuss-participant/references/free.md +1 -1
- package/backend/admin.py +1 -1
- package/backend/free_room.py +185 -4
- package/backend/room_import.py +2 -0
- package/backend/schema.sql +32 -1
- package/backend/server.py +37 -9
- package/docs/deployment.md +2 -2
- package/docs/en/deployment.md +2 -2
- package/docs/en/workflow.md +98 -96
- package/docs/workflow.md +98 -96
- package/package.json +1 -1
- package/runner/admin_account.mjs +10 -14
- package/runner/codex_app_client.mjs +13 -0
- package/runner/codiscuss.mjs +2 -2
- package/runner/discussion_adapters.mjs +205 -26
- package/runner/discussion_launch.mjs +136 -15
- package/runner/discussion_models.mjs +89 -1
- package/runner/discussion_runner.mjs +16 -13
- package/runner/discussion_service.mjs +98 -19
- package/runner/expert_prompts.mjs +85 -1
- package/runner/free_runner.mjs +38 -20
- package/runner/scheduler.mjs +4 -4
- package/runner/session_cut.mjs +167 -0
- package/web/app.js +234 -32
- package/web/markdown.css +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +2926 -2517
|
@@ -13,6 +13,10 @@ import { DiscussionRunner, DiscussionStore, acquire_lock, api, load_config, paus
|
|
|
13
13
|
import { FreeRoomRunner } from './free_runner.mjs';
|
|
14
14
|
|
|
15
15
|
const POLL_MS = 2000;
|
|
16
|
+
// A hosted site is polled less often than a local one: every request spends its database quota.
|
|
17
|
+
const REMOTE_POLL_MS = 10_000;
|
|
18
|
+
const UPDATE_RETRY_MS = 5000;
|
|
19
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
16
20
|
const REMOTE_SYNC_MS = 3000;
|
|
17
21
|
const START_WAIT_MS = 5000;
|
|
18
22
|
const STOP_WAIT_MS = 20_000;
|
|
@@ -274,7 +278,7 @@ class RoomWorker {
|
|
|
274
278
|
}
|
|
275
279
|
this.state.config_version = snapshot.version;
|
|
276
280
|
}
|
|
277
|
-
/** A manual Claude or
|
|
281
|
+
/** A manual Claude, Codex or agy expert that has registered and whose CLI reported its session is handed to the service, which resumes that session from the next turn on. */
|
|
278
282
|
async adopt_manual_members() {
|
|
279
283
|
// A discussion runner lists members itself; a free room's registered manual members come from the room view.
|
|
280
284
|
const members = this.config.mode === 'free' ? (await api(this.config, '')).free?.members || [] : this.runner.members || [];
|
|
@@ -344,32 +348,103 @@ export class DiscussionService {
|
|
|
344
348
|
this.create_runner = create_runner;
|
|
345
349
|
this.log = log;
|
|
346
350
|
this.workers = new Map();
|
|
351
|
+
// How often each room is checked without a push: a local site costs nothing, a hosted one spends its database quota.
|
|
352
|
+
this.intervals = { local: POLL_MS, remote: REMOTE_POLL_MS };
|
|
353
|
+
// The resident service turns this on so a site's update stream checks its rooms at once; tests leave it off.
|
|
354
|
+
this.push = false;
|
|
355
|
+
this.streams = new Map();
|
|
356
|
+
this.closed = false;
|
|
347
357
|
}
|
|
348
|
-
/**
|
|
349
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Reload the registry, close removed rooms, then tick the rooms that are due. `wait: true` ticks every idle room and
|
|
360
|
+
* waits for them; `wait: false` returns at once, so a slow remote site never delays the other rooms.
|
|
361
|
+
*/
|
|
362
|
+
async tick({ wait = true } = {}) {
|
|
350
363
|
const configs = read_registry(this.home);
|
|
351
364
|
for (const [path, worker] of this.workers) {
|
|
352
|
-
if (!configs.includes(path)) { await worker.close(true); this.workers.delete(path); }
|
|
365
|
+
if (!configs.includes(path)) { await worker.busy; await worker.close(true); this.workers.delete(path); }
|
|
353
366
|
}
|
|
354
367
|
for (const path of configs) {
|
|
355
368
|
if (!this.workers.has(path)) this.workers.set(path, new RoomWorker(path, this.create_runner, this.log, this.home));
|
|
356
369
|
const worker = this.workers.get(path);
|
|
357
|
-
|
|
358
|
-
if (!worker.removed) continue;
|
|
359
|
-
// The room no longer exists on its website, so there is nothing to pause; drop the registration once.
|
|
360
|
-
const directory = worker.config?.directory;
|
|
361
|
-
await worker.close(false);
|
|
362
|
-
this.workers.delete(path);
|
|
363
|
-
try { unregister_room(this.home, path); } catch { /* Already removed by another command. */ }
|
|
364
|
-
// Its runtime, task files and answer drafts serve no one now; a file still held is left to the periodic sweep.
|
|
365
|
-
try { remove_room_files(this.home, path, directory); } catch (error) { this.log(`${worker.state.room || path}: 本机文件未能删除,稍后清理:${error.message}`); }
|
|
366
|
-
this.log(`${worker.state.site || ''}/${worker.state.room || path}: 房间已在网站删除,已撤销登记并删除本机文件`);
|
|
370
|
+
if (!worker.busy && (wait || Date.now() >= (worker.next_at ?? 0))) this.start(path, worker);
|
|
367
371
|
}
|
|
372
|
+
if (this.push) for (const site of new Set([...this.workers.values()].map(worker => worker.state.site).filter(Boolean))) this.watch(site);
|
|
373
|
+
if (wait) await Promise.all([...this.workers.values()].map(worker => worker.busy));
|
|
374
|
+
this.write_status();
|
|
375
|
+
}
|
|
376
|
+
/** Start one room's tick; a room asked again while busy ticks once more right after. */
|
|
377
|
+
start(path, worker) {
|
|
378
|
+
if (this.closed) return;
|
|
379
|
+
if (worker.busy) { worker.woken = true; return; }
|
|
380
|
+
// Nothing awaits this tick, so its failure is logged here instead of crashing the service.
|
|
381
|
+
worker.busy = this.tick_worker(path, worker).catch(error => this.log(`${worker.state.room || path}: 检查失败:${error.message}`))
|
|
382
|
+
.finally(() => {
|
|
383
|
+
// Counted from the end of the tick, once the room's site is known; a slow site does not poll back to back.
|
|
384
|
+
const local = !worker.state.site || LOOPBACK.has(new URL(worker.state.site).hostname);
|
|
385
|
+
worker.next_at = Date.now() + (local ? this.intervals.local : this.intervals.remote);
|
|
386
|
+
worker.busy = null;
|
|
387
|
+
if (worker.woken && this.workers.get(path) === worker) { worker.woken = false; this.start(path, worker); }
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
/** Check every room of a site now, as its website reported a change. */
|
|
391
|
+
wake(site) {
|
|
392
|
+
for (const [path, worker] of this.workers) if (worker.state.site === site) this.start(path, worker);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Follow a site's `/api/updates` stream and wake its rooms on each change, so a message reaches the AI within
|
|
396
|
+
* milliseconds instead of the next poll. A site without push (204) is left to polling; a dropped stream reconnects.
|
|
397
|
+
*/
|
|
398
|
+
watch(site) {
|
|
399
|
+
if (this.streams.has(site) || this.closed) return;
|
|
400
|
+
const controller = new AbortController();
|
|
401
|
+
const done = (async () => {
|
|
402
|
+
while (!controller.signal.aborted) {
|
|
403
|
+
try {
|
|
404
|
+
const response = await fetch(`${site}/api/updates`, { signal: controller.signal, headers: { accept: 'text/event-stream' } });
|
|
405
|
+
if (response.status === 204) return;
|
|
406
|
+
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
|
|
407
|
+
const decoder = new TextDecoder();
|
|
408
|
+
let buffer = '';
|
|
409
|
+
for await (const chunk of response.body) {
|
|
410
|
+
buffer += decoder.decode(chunk, { stream: true }).replaceAll('\r\n', '\n');
|
|
411
|
+
for (let end = buffer.indexOf('\n\n'); end >= 0; end = buffer.indexOf('\n\n')) {
|
|
412
|
+
// `ready` comes on every (re)connection, so changes missed while disconnected are picked up too.
|
|
413
|
+
if (/^event: (change|ready)$/m.test(buffer.slice(0, end))) this.wake(site);
|
|
414
|
+
buffer = buffer.slice(end + 2);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
} catch { /* Reconnect below; polling covers the gap. */ }
|
|
418
|
+
await sleep(UPDATE_RETRY_MS, undefined, { signal: controller.signal }).catch(() => {});
|
|
419
|
+
}
|
|
420
|
+
})();
|
|
421
|
+
this.streams.set(site, { controller, done });
|
|
422
|
+
}
|
|
423
|
+
/** Publish every room's latest state for `codiscuss status` and the website's service check. */
|
|
424
|
+
write_status() {
|
|
368
425
|
atomic_json(join(this.home, 'status.json'), { pid: process.pid, updated_at: Math.floor(Date.now() / 1000),
|
|
369
426
|
rooms: [...this.workers.values()].map(worker => worker.state) });
|
|
370
427
|
}
|
|
371
|
-
/**
|
|
428
|
+
/** Tick one room; a room deleted on its website is unregistered here. */
|
|
429
|
+
async tick_worker(path, worker) {
|
|
430
|
+
await worker.tick();
|
|
431
|
+
if (!worker.removed) return this.write_status();
|
|
432
|
+
// The room no longer exists on its website, so there is nothing to pause; drop the registration once.
|
|
433
|
+
const directory = worker.config?.directory;
|
|
434
|
+
await worker.close(false);
|
|
435
|
+
this.workers.delete(path);
|
|
436
|
+
try { unregister_room(this.home, path); } catch { /* Already removed by another command. */ }
|
|
437
|
+
// Its runtime, task files and answer drafts serve no one now; a file still held is left to the periodic sweep.
|
|
438
|
+
try { remove_room_files(this.home, path, directory); } catch (error) { this.log(`${worker.state.room || path}: 本机文件未能删除,稍后清理:${error.message}`); }
|
|
439
|
+
this.log(`${worker.state.site || ''}/${worker.state.room || path}: 房间已在网站删除,已撤销登记并删除本机文件`);
|
|
440
|
+
}
|
|
441
|
+
/** Pause running rooms and release every lock and child process, once their current ticks finish. */
|
|
372
442
|
async close() {
|
|
443
|
+
this.closed = true;
|
|
444
|
+
for (const stream of this.streams.values()) stream.controller.abort();
|
|
445
|
+
await Promise.all([...this.streams.values()].map(stream => stream.done));
|
|
446
|
+
this.streams.clear();
|
|
447
|
+
await Promise.all([...this.workers.values()].map(worker => worker.busy));
|
|
373
448
|
for (const worker of this.workers.values()) await worker.close(true);
|
|
374
449
|
this.workers.clear();
|
|
375
450
|
}
|
|
@@ -460,9 +535,10 @@ export async function run_service(home, output = () => {}) {
|
|
|
460
535
|
output(line);
|
|
461
536
|
};
|
|
462
537
|
const service = new DiscussionService(home, undefined, log_line);
|
|
538
|
+
service.push = true;
|
|
463
539
|
const stop_path = join(home, 'stop.request');
|
|
464
540
|
if (existsSync(stop_path)) unlinkSync(stop_path);
|
|
465
|
-
let stopped = false;
|
|
541
|
+
let stopped = false, syncing = null;
|
|
466
542
|
const stop = () => { stopped = true; };
|
|
467
543
|
process.once('SIGINT', stop);
|
|
468
544
|
process.once('SIGTERM', stop);
|
|
@@ -474,7 +550,7 @@ export async function run_service(home, output = () => {}) {
|
|
|
474
550
|
const { sync_remote_launches } = await import('./remote_launch.mjs');
|
|
475
551
|
let synced_at = 0, swept_at = 0;
|
|
476
552
|
while (!stopped && !existsSync(stop_path)) {
|
|
477
|
-
try { await service.tick(); } catch (error) { log_line(`服务检查失败:${error.message}`); }
|
|
553
|
+
try { await service.tick({ wait: false }); } catch (error) { log_line(`服务检查失败:${error.message}`); }
|
|
478
554
|
if (Date.now() - swept_at >= SWEEP_MS) {
|
|
479
555
|
swept_at = Date.now();
|
|
480
556
|
try {
|
|
@@ -484,13 +560,16 @@ export async function run_service(home, output = () => {}) {
|
|
|
484
560
|
}
|
|
485
561
|
} catch (error) { log_line(`清理本机文件失败:${error.message}`); }
|
|
486
562
|
}
|
|
487
|
-
|
|
563
|
+
// Runs beside the room ticks: a slow remote site must not hold up local rooms.
|
|
564
|
+
if (!syncing && Date.now() - synced_at >= REMOTE_SYNC_MS) {
|
|
488
565
|
synced_at = Date.now();
|
|
489
|
-
|
|
566
|
+
syncing = sync_remote_launches(home, { log: log_line }).catch(error => log_line(`远程启动检查失败:${error.message}`))
|
|
567
|
+
.finally(() => { syncing = null; });
|
|
490
568
|
}
|
|
491
569
|
await sleep(POLL_MS);
|
|
492
570
|
}
|
|
493
571
|
} finally {
|
|
572
|
+
await syncing;
|
|
494
573
|
await service.close();
|
|
495
574
|
if (existsSync(stop_path)) unlinkSync(stop_path);
|
|
496
575
|
log_line('讨论服务已停止');
|
|
@@ -33,8 +33,92 @@ export function expert_prompt(profile = {}) {
|
|
|
33
33
|
return String((own ? profile.system_prompt : EXPERT_PROMPT_PRESETS[profile.adapter]) || '').trim();
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** The line that introduces the member's own prompt after the fixed task text; the prompt dialog shows the same line. */
|
|
37
|
+
export const EXPERT_PROMPT_HEADING = '用户为你单独设置的提示(补充做法与偏好,不改变权限与技能规则):';
|
|
38
|
+
|
|
36
39
|
/** Append the member's prompt to a task prompt, marked as the user's own guidance for this member. */
|
|
37
40
|
export function with_expert_prompt(prompt, profile) {
|
|
38
41
|
const extra = expert_prompt(profile);
|
|
39
|
-
return extra ? `${prompt}\n\n
|
|
42
|
+
return extra ? `${prompt}\n\n${EXPERT_PROMPT_HEADING}\n${extra}` : prompt;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* The fixed task text the runner adds for a member woken on this computer. It lives here, free of Node modules, so the
|
|
47
|
+
* expert prompt dialog shows the same text the runner sends, with placeholders for the values known only at delivery.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Project access of a member woken on this computer: 只读, 可写 (project files and any command), 自动 (the software's own
|
|
52
|
+
* automatic approval) and 完全放开 (no approval or sandbox). A manual member the service took over is `own`.
|
|
53
|
+
*/
|
|
54
|
+
export const PROJECT_ACCESS = ['read', 'write', 'auto', 'full'];
|
|
55
|
+
|
|
56
|
+
const ACCESS_LABELS = { read: '只读', write: '可写', auto: '自动', full: '完全放开' };
|
|
57
|
+
|
|
58
|
+
/** What each level does in each software, measured headless (Claude Code 2.1.312, Codex CLI 0.163, agy 1.4.10). */
|
|
59
|
+
const ACCESS_HINTS = {
|
|
60
|
+
claude: { read: '只放行网站命令与草稿,不改项目文件、不跑其他命令', write: '可改项目文件,Bash / PowerShell 命令全部放行(无沙箱)',
|
|
61
|
+
auto: 'Claude auto 模式:由分类器判断,实测项目外写入和联网都会放行', full: 'bypassPermissions:跳过全部权限检查' },
|
|
62
|
+
codex_cli: { read: '只读沙箱', write: '项目可写的沙箱,命令在沙箱内运行、可联网',
|
|
63
|
+
auto: '沙箱外的操作由模型申请提权,Codex 自动审核代替人工批准', full: '无沙箱、无审批(--dangerously-bypass-approvals-and-sandbox)' },
|
|
64
|
+
antigravity_cli: { read: '只放行网站命令;碰到未放行的命令会直接结束本轮', write: '可写项目,命令全部放行(在 agy 沙箱内)',
|
|
65
|
+
full: '再放开沙箱外命令与 MCP 工具' },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** agy has no automatic mode headless ("not available for this account", tested 1.4.10), so it offers no 自动. */
|
|
69
|
+
export function access_levels(adapter) {
|
|
70
|
+
return adapter === 'antigravity_cli' ? PROJECT_ACCESS.filter(level => level !== 'auto') : PROJECT_ACCESS;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The levels a software supports, labelled for the page, with what each does there on hover. */
|
|
74
|
+
export function access_options(adapter) {
|
|
75
|
+
return access_levels(adapter).map(value => ({ value, label: ACCESS_LABELS[value], hint: ACCESS_HINTS[adapter]?.[value] || '' }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The page's name for a level; `own` reads as decided by the member's own session. */
|
|
79
|
+
export function access_label(level) { return level === 'own' ? '自行决定' : ACCESS_LABELS[level] || '只读'; }
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether a member may run commands beyond the codiscuss subcommands: every level but 只读, and 可写 only with a project.
|
|
83
|
+
* A taken-over manual member (`own`) keeps its own session's permissions, so the room's limits do not apply.
|
|
84
|
+
*/
|
|
85
|
+
export function runs_commands(config) {
|
|
86
|
+
const access = config.project_access;
|
|
87
|
+
return ['own', 'auto', 'full'].includes(access) || (access === 'write' && Boolean(config.cwd || config.workdir));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The task text's rule for project files and commands; `purpose` says what the member works for (按请求 / 按议题需要). */
|
|
91
|
+
export function project_rule(profile, purpose) {
|
|
92
|
+
if (profile.project_access === 'own') return '项目文件与命令权限沿用你自己会话的软件设置。';
|
|
93
|
+
if (profile.project_access === 'auto') return `由软件自动审批:可${purpose}修改项目文件并运行所需命令;被沙箱或审批拦下时按软件方式申请放行(Codex 申请提权),仍被拒绝就在回答中说明。`;
|
|
94
|
+
if (profile.project_access === 'full') return `已跳过软件审批与沙箱,可${purpose}修改文件并运行所需命令。`;
|
|
95
|
+
if (!profile.cwd) return '未指定项目目录。';
|
|
96
|
+
return profile.project_access === 'write' ? `用户允许${purpose}修改 cwd 中的项目文件并运行所需命令。` : 'cwd 中的项目文件只读,不修改。';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A read-only agy project allows only codiscuss commands and headless agy ends the turn on a denied one, so every such agy task states it; no member prompt can drop it. */
|
|
100
|
+
export function agy_command_rule(adapter, cli, config = {}) {
|
|
101
|
+
return adapter === 'antigravity_cli' && !runs_commands(config) ? `无人值守下 run_command 只能运行 ${cli} 的 subcommands 子命令,其他命令会被自动拒绝并使会话直接结束;列目录、搜索或读取文件用 list_dir、find_by_name、grep_search、view_file。` : '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Windows PowerShell mis-decodes UTF-8 files; the runner adds this where the member's commands can run rg. */
|
|
105
|
+
export const READ_RULE = '读取含中文的技能或文本文件时用 rg --encoding utf-8 . 文件路径,不用 Get-Content(Windows PowerShell 会按系统编码读出乱码,需要反复重试)。';
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A free-room task's fixed text: `instructions` saved in the task file and `prompt` sent with each wake-up, followed by
|
|
109
|
+
* the member's own prompt. `discussion` is a direct question asked while a discussion runs in the room.
|
|
110
|
+
*/
|
|
111
|
+
export function free_task_text({ name, role, discussion, profile, cli, room, messages, read_after, execution_id, task_path, read_rule = '', inbox = false }) {
|
|
112
|
+
// A task posted into a member's own open window has no process environment of ours, so it names the credential file.
|
|
113
|
+
const bound_access = !inbox && ['claude', 'codex_cli', 'antigravity_cli'].includes(profile.adapter);
|
|
114
|
+
const access_arg = bound_access ? '' : ' --access-file 凭据路径';
|
|
115
|
+
const access_rule = bound_access ? '本次凭据文件已通过进程环境 DISCUSS_ACCESS_FILE 绑定,codiscuss 自动读取,命令无需 --access-file。' : '';
|
|
116
|
+
const files_rule = project_rule(profile, '按请求');
|
|
117
|
+
// The host routes unnamed messages and receives hand-backs; it may name members in its answer.
|
|
118
|
+
const host_rule = role === 'host' && !discussion
|
|
119
|
+
? `你是本房间的主持人:按任务 skills 中 discuss-host 的自由协作主持说明处理 route、handoff 与 all 请求。先用 ${cli} room view ${room} 查看成员与状态;派活在回答中加 --to 成员名(不能点名自己),只点名一位且还要它交回时加 --handoff。`
|
|
120
|
+
: '';
|
|
121
|
+
const instructions = `${agy_command_rule(profile.adapter, cli, profile)}${discussion ? '讨论房间:用户直接向你提问(' + messages + ')。这不是你的发言安排,不要领取发言权、不要安排他人,也不改变讨论进度,只回答这个提问。' : '自由协作房间:用户点名了你(' + messages + ')。'}${access_rule}先用 ${cli} message list ${room} --after ${read_after}${access_arg} 读取新消息,按时间顺序处理,后面的消息可能修正前面的,以最新指令为准。${host_rule}完成后用 ${cli} message post ${room} --as ${name} --kind chat --execution ${execution_id} --body-file 草稿 --submission-id 新UUID${access_arg} 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 --execution 改作普通发言。草稿只写入 draft_directory;${files_rule}不读取或打印凭据内容,不建定时任务。`;
|
|
122
|
+
const prompt = `用户在${discussion ? '讨论' : '自由协作'}房间点名了你(${name}),对应消息 ${messages}。请先读取任务文件:${task_path},按其中说明读取新消息、完成请求,并用 ${cli} message post 带 --execution ${execution_id} 提交一次回答后结束。${runs_commands(profile) ? '网站读写' : '命令'}只用任务内 subcommands 列出的 ${cli} 子命令;${files_rule}不读取凭据内容。${read_rule}`;
|
|
123
|
+
return { instructions, prompt };
|
|
40
124
|
}
|
package/runner/free_runner.mjs
CHANGED
|
@@ -3,9 +3,10 @@ import { spawnSync } from 'node:child_process';
|
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
4
|
import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
5
|
import { basename, join, resolve } from 'node:path';
|
|
6
|
-
import {
|
|
7
|
-
import { api, CODEX_ADAPTERS } from './discussion_runner.mjs';
|
|
8
|
-
import { with_expert_prompt } from './expert_prompts.mjs';
|
|
6
|
+
import { atomic_json, claude_inbox_ready, deliver_task, SegmentLog, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
|
|
7
|
+
import { api, CODEX_ADAPTERS, read_rule } from './discussion_runner.mjs';
|
|
8
|
+
import { free_task_text, with_expert_prompt } from './expert_prompts.mjs';
|
|
9
|
+
import { BRANCHABLE_ADAPTERS, branch_session, session_exists } from './session_cut.mjs';
|
|
9
10
|
import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, route_free_message } from './scheduler.mjs';
|
|
10
11
|
|
|
11
12
|
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
@@ -68,17 +69,11 @@ export function free_task_files(config, member, execution, binding) {
|
|
|
68
69
|
atomic_json(access_path, { site: config.site, room: config.room, token: config.members[member.name].token,
|
|
69
70
|
session_id: binding.session_id || '' });
|
|
70
71
|
const cli = process.platform === 'win32' && profile.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const access_rule = bound_access ? '本次凭据文件已通过进程环境 DISCUSS_ACCESS_FILE 绑定,codiscuss 自动读取,命令无需 --access-file。' : '';
|
|
74
|
-
const project_rule = !profile.cwd ? '未指定项目目录。'
|
|
75
|
-
: profile.project_access === 'write' ? '用户允许按请求修改 cwd 中的项目文件。' : 'cwd 中的项目文件只读,不修改。';
|
|
72
|
+
const inbox = claude_inbox_ready(profile, binding);
|
|
73
|
+
const bound_access = !inbox && ['claude', 'codex_cli', 'antigravity_cli'].includes(profile.adapter);
|
|
76
74
|
const messages = execution.requests.map(item => `#${item.message_id}`).join('、');
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const host_rule = member.role === 'host' && !discussion
|
|
80
|
-
? `你是本房间的主持人:按任务 skills 中 discuss-host 的自由协作主持说明处理 route、handoff 与 all 请求。先用 ${cli} room view ${config.room} 查看成员与状态;派活在回答中加 --to 成员名(不能点名自己),只点名一位且还要它交回时加 --handoff。`
|
|
81
|
-
: '';
|
|
75
|
+
const { instructions, prompt } = free_task_text({ name: member.name, role: member.role, discussion: config.mode !== 'free', profile, cli,
|
|
76
|
+
room: config.room, messages, read_after: execution.read_until, execution_id: execution.id, task_path, read_rule: read_rule(profile.adapter), inbox });
|
|
82
77
|
atomic_json(task_path, { site: config.site, room: config.room, mode: 'free', member: member.name,
|
|
83
78
|
execution_id: execution.id, request_messages: execution.requests.map(item => item.message_id),
|
|
84
79
|
request_purposes: execution.requests.map(item => item.purpose || 'ask'), role: member.role || 'member',
|
|
@@ -87,11 +82,9 @@ export function free_task_files(config, member, execution, binding) {
|
|
|
87
82
|
skills: [...['discuss-participant/SKILL.md', 'discuss-participant/references/free.md'],
|
|
88
83
|
...member.role === 'host' ? ['discuss-host/SKILL.md', 'discuss-host/references/free.md'] : []].map(file => join(config.project, '.agents/skills', file)),
|
|
89
84
|
cli, subcommands: TASK_SUBCOMMANDS,
|
|
90
|
-
...(bound_access ? { access_env: 'DISCUSS_ACCESS_FILE' } : { access_file: access_path }),
|
|
91
|
-
instructions: `${agy_command_rule(profile.adapter, cli)}${discussion ? '讨论房间:用户直接向你提问(' + messages + ')。这不是你的发言安排,不要领取发言权、不要安排他人,也不改变讨论进度,只回答这个提问。' : '自由协作房间:用户点名了你(' + messages + ')。'}${access_rule}先用 ${cli} message list ${config.room} --after ${execution.read_until}${access_arg} 读取新消息,按时间顺序处理,后面的消息可能修正前面的,以最新指令为准。${host_rule}完成后用 ${cli} message post ${config.room} --as ${member.name} --kind chat --execution ${execution.id} --body-file 草稿 --submission-id 新UUID${access_arg} 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 --execution 改作普通发言。草稿只写入 draft_directory;${project_rule}不读取或打印凭据内容,不建定时任务。` });
|
|
92
|
-
const prompt = `用户在${discussion ? '讨论' : '自由协作'}房间点名了你(${member.name}),对应消息 ${messages}。请先读取任务文件:${task_path},按其中说明读取新消息、完成请求,并用 ${cli} message post 带 --execution ${execution.id} 提交一次回答后结束。命令只用任务内 subcommands 列出的 ${cli} 子命令;${project_rule}不读取凭据内容。`;
|
|
85
|
+
...(bound_access ? { access_env: 'DISCUSS_ACCESS_FILE' } : { access_file: access_path }), instructions });
|
|
93
86
|
return { id, participant: member.name, directory: config.directory, draft_directory,
|
|
94
|
-
skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt: with_expert_prompt(prompt, profile) };
|
|
87
|
+
skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt: with_expert_prompt(prompt, profile), inbox };
|
|
95
88
|
}
|
|
96
89
|
|
|
97
90
|
const ADAPTER_LABELS = { codex: 'Codex App', codex_cli: 'Codex CLI', claude: 'Claude Code', antigravity: 'Antigravity App',
|
|
@@ -100,8 +93,8 @@ const ADAPTER_LABELS = { codex: 'Codex App', codex_cli: 'Codex CLI', claude: 'Cl
|
|
|
100
93
|
/** What a scheduler can know about a member without a role field: its software, project access and the messages it is on. */
|
|
101
94
|
function member_description(name, profile, work) {
|
|
102
95
|
const project = profile.project_name || (profile.cwd ? basename(profile.cwd) : '');
|
|
103
|
-
const access = profile.adapter === 'api' ? '' : !profile.cwd ? ',未指定项目目录'
|
|
104
|
-
: profile.project_access === '
|
|
96
|
+
const access = profile.adapter === 'api' ? '' : profile.project_access === 'own' ? ',权限沿用其会话设置' : !profile.cwd ? ',未指定项目目录'
|
|
97
|
+
: profile.project_access === 'read' ? `,项目 ${project} 只读` : `,可修改项目 ${project} 的文件并运行命令`;
|
|
105
98
|
return `${name}(${ADAPTER_LABELS[profile.adapter] || profile.adapter}${access}${work})`;
|
|
106
99
|
}
|
|
107
100
|
|
|
@@ -346,12 +339,15 @@ export class FreeRoomRunner {
|
|
|
346
339
|
const before = workspace_snapshot(directory);
|
|
347
340
|
if (before) this.snapshots.set(execution.id, { member: member.name, directory, before });
|
|
348
341
|
const binding = this.store.binding(member.name);
|
|
349
|
-
|
|
342
|
+
// A member whose session a rewind restarted is bound to '' and must not fall back to the session it was invited with.
|
|
343
|
+
if (binding.session_id === undefined && adapter.session_id) binding.session_id = adapter.session_id;
|
|
350
344
|
let invoked = false;
|
|
351
345
|
try {
|
|
346
|
+
const warning = await this.cut_rewound(member.name, adapter, binding, execution);
|
|
352
347
|
const task = free_task_files(this.config, member, execution, binding);
|
|
353
348
|
invoked = true;
|
|
354
349
|
this.segments.start(execution.id);
|
|
350
|
+
if (warning) this.note(execution.id, warning);
|
|
355
351
|
const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
|
|
356
352
|
api_context: { site: this.config.site, room: this.config.room, token: this.config.members[member.name].token,
|
|
357
353
|
home: this.config.api_home, mode: 'free', role: member.role || 'member', execution_id: execution.id,
|
|
@@ -364,6 +360,8 @@ export class FreeRoomRunner {
|
|
|
364
360
|
// A stopped process may report after the next execution started; only its own execution takes the result.
|
|
365
361
|
on_failure: error => { try { if (this.row(member.name)?.execution_id === execution.id) this.save(member.name, { error }); } catch { /* Store may already be closed at shutdown. */ } },
|
|
366
362
|
on_exit: denial => this.exited.set(execution.id, denial || ''),
|
|
363
|
+
// Shown beside the answer or failure so the owner can allow them; a lost report only hides the list.
|
|
364
|
+
on_denials: denials => api(this.config, `/executions/${execution.id}`, { action: 'denials', denials }).catch(() => {}),
|
|
367
365
|
});
|
|
368
366
|
if (result.session_id) this.store.bind(member.name, result.session_id);
|
|
369
367
|
this.save(member.name, { status: result.status === 'sidecar_pending' ? 'sidecar_pending' : 'running', attempts: 0 });
|
|
@@ -376,6 +374,26 @@ export class FreeRoomRunner {
|
|
|
376
374
|
this.save(member.name, { status: 'idle', attempts, error: error.message });
|
|
377
375
|
}
|
|
378
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* Before a member works again after a rewind, branch its session before the executions the rewind took back.
|
|
379
|
+
* A Claude fork happens on its next turn, so it stays pending until that session exists. Returns a warning when the
|
|
380
|
+
* session could not be cut and the member continues in the old one.
|
|
381
|
+
*/
|
|
382
|
+
async cut_rewound(name, adapter, binding, execution) {
|
|
383
|
+
const key = `session-fork:${name}`;
|
|
384
|
+
const pending = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
|
|
385
|
+
if (pending && !session_exists(adapter.adapter, binding.session_id)) { binding.fork = JSON.parse(pending); return ''; }
|
|
386
|
+
if (pending) this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
|
|
387
|
+
if (!binding.session_id || !execution.rewound_executions?.length || !BRANCHABLE_ADAPTERS.includes(adapter.adapter)) return '';
|
|
388
|
+
try {
|
|
389
|
+
const cut = await branch_session(adapter, binding.session_id, execution.rewound_executions);
|
|
390
|
+
if (!cut) return '';
|
|
391
|
+
if (cut.fork) this.store.db.prepare('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, JSON.stringify(cut.fork));
|
|
392
|
+
this.store.bind(name, cut.session_id);
|
|
393
|
+
Object.assign(binding, cut);
|
|
394
|
+
return '';
|
|
395
|
+
} catch (error) { return `会话未能回退(${error.message}),本次沿用原会话,它可能仍记得已回退的内容`; }
|
|
396
|
+
}
|
|
379
397
|
/** Follow the website's verdict; stop only this member's processes when its execution ends. */
|
|
380
398
|
async reconcile(member, row, active, minutes) {
|
|
381
399
|
if (row.status === 'reserving') return this.reserve(member, row);
|
package/runner/scheduler.mjs
CHANGED
|
@@ -12,7 +12,7 @@ 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 = 6;
|
|
16
16
|
const HOST_CHOICE = '__host__';
|
|
17
17
|
const NOTE_CHOICE = '__note__';
|
|
18
18
|
const ALL_CHOICE = '__all__';
|
|
@@ -148,11 +148,11 @@ export function certainty(answer) {
|
|
|
148
148
|
*/
|
|
149
149
|
export async function route_free_message(home, scheduler, { title, goal, context, message, host, members }, fetch_impl) {
|
|
150
150
|
const choices = [...members.map(member => ({ value: member.name, description: member.description })),
|
|
151
|
-
{ value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人(如单独一句“hi
|
|
152
|
-
{ value: ALL_CHOICE, description: '
|
|
151
|
+
{ value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人(如单独一句“hi”“你好”这类问候、闲聊,由主持人代表房间回应),或诉求模糊、复杂,需要主持人判断怎么安排` },
|
|
152
|
+
{ value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要看到别人的回答;只在要求简单明确、每人独立回应一次就算完成时选,如“大家打个招呼”“各自报一下在用的模型”' },
|
|
153
153
|
{ value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答' }];
|
|
154
154
|
const questions = [{ type: 'choice', name: 'handler', choices,
|
|
155
|
-
instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字,或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3
|
|
155
|
+
instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字,或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)符合“全体”选项的条件时选全体;只出现“大家”“都”“各自”“一起”等字眼不够;4)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;5)只是确认、致谢或旁注选无需回应;6)对象不明确,或要讨论、互相回应、分工、排先后时选主持人,由主持人安排。' }];
|
|
156
156
|
const roster = [`${host.name}(主持人)`, ...members.map(member => member.name)].join('、');
|
|
157
157
|
const state = scheduler_state(title, context, message, goal, '', { roster }, ROUTE_CONTEXT_MESSAGES);
|
|
158
158
|
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branch a member's software session so a rewound or forked room continues from an earlier point. The original
|
|
3
|
+
* session is never changed: Claude forks through its own CLI on the next turn (--fork-session), Codex through its app
|
|
4
|
+
* server's thread/fork, and agy by copying its conversation database. Each execution's prompt names its task file
|
|
5
|
+
* (`free-<id>.task.json`), which marks where that execution entered the session.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { createInterface } from 'node:readline';
|
|
13
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
14
|
+
|
|
15
|
+
const FORK_TIMEOUT_MS = 60_000;
|
|
16
|
+
// Software whose session this machine can branch; the website lists taken-back executions only for these members.
|
|
17
|
+
export const BRANCHABLE_ADAPTERS = ['claude', 'codex', 'codex_cli', 'antigravity_cli'];
|
|
18
|
+
|
|
19
|
+
/** The task file name an execution's prompt carries. */
|
|
20
|
+
export const execution_marker = id => `free-${id}.task.json`;
|
|
21
|
+
|
|
22
|
+
/** Where each software keeps one session, or '' when this machine has none by that ID. */
|
|
23
|
+
function session_path(adapter, session_id, env, home) {
|
|
24
|
+
if (adapter === 'claude') {
|
|
25
|
+
const projects = join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects');
|
|
26
|
+
if (!existsSync(projects)) return '';
|
|
27
|
+
return readdirSync(projects).map(dir => join(projects, dir, `${session_id}.jsonl`)).find(existsSync) || '';
|
|
28
|
+
}
|
|
29
|
+
if (adapter === 'codex_cli') {
|
|
30
|
+
const sessions = join(env.CODEX_HOME || join(home, '.codex'), 'sessions');
|
|
31
|
+
if (!existsSync(sessions)) return '';
|
|
32
|
+
const found = readdirSync(sessions, { recursive: true }).map(String).find(path => path.endsWith(`-${session_id}.jsonl`));
|
|
33
|
+
return found ? join(sessions, found) : '';
|
|
34
|
+
}
|
|
35
|
+
const file = join(home, '.gemini', 'antigravity-cli', 'conversations', `${session_id}.db`);
|
|
36
|
+
return existsSync(file) ? file : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether a session exists on this machine, e.g. to tell if a pending Claude fork already happened. */
|
|
40
|
+
export function session_exists(adapter, session_id, { env = process.env, home = homedir() } = {}) {
|
|
41
|
+
return Boolean(session_id && session_path(adapter, session_id, env, home));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** JSON lines of a session log; lines the software wrote in another shape are skipped. */
|
|
45
|
+
function log_entries(file) {
|
|
46
|
+
return readFileSync(file, 'utf8').split('\n').flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The text of a prompt as the software stored it: a string, or text parts (tool results carry no prompt). */
|
|
50
|
+
function prompt_text(content) {
|
|
51
|
+
if (typeof content === 'string') return content;
|
|
52
|
+
return Array.isArray(content) ? content.filter(part => ['text', 'input_text'].includes(part?.type)).map(part => part.text || '').join('\n') : '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const marked = (text, markers) => markers.some(marker => text.includes(marker));
|
|
56
|
+
|
|
57
|
+
/** Claude: fork on the next turn at the last answer before the first marked prompt; a cut before any answer starts anew. */
|
|
58
|
+
function claude_branch(session_id, markers, always, env, home) {
|
|
59
|
+
const file = session_path('claude', session_id, env, home);
|
|
60
|
+
if (!file) throw new Error(`本机找不到 Claude 会话 ${session_id}`);
|
|
61
|
+
const entries = log_entries(file);
|
|
62
|
+
const index = entries.findIndex(entry => entry.type === 'user' && marked(prompt_text(entry.message?.content), markers));
|
|
63
|
+
if (index < 0) return always ? { session_id: randomUUID(), fork: { from: session_id, at: '' } } : null;
|
|
64
|
+
const at = entries.slice(0, index).findLast(entry => entry.type === 'assistant' && entry.uuid)?.uuid;
|
|
65
|
+
return at ? { session_id: randomUUID(), fork: { from: session_id, at } } : { session_id: '' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Codex turns in order, and the index of the first turn whose user prompt carries a marker (-1 when none does). */
|
|
69
|
+
function codex_turns(file, markers) {
|
|
70
|
+
const turns = [];
|
|
71
|
+
let cut = -1;
|
|
72
|
+
for (const entry of log_entries(file)) {
|
|
73
|
+
const payload = entry.payload || {};
|
|
74
|
+
if (entry.type === 'event_msg' && payload.type === 'task_started' && payload.turn_id) turns.push(payload.turn_id);
|
|
75
|
+
else if (cut < 0 && turns.length && entry.type === 'response_item' && payload.type === 'message' && payload.role === 'user'
|
|
76
|
+
&& marked(prompt_text(payload.content), markers)) cut = turns.length - 1;
|
|
77
|
+
}
|
|
78
|
+
return { turns, cut };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Ask Codex's app server to fork a thread, through `last_turn` when given; returns the new thread's ID. */
|
|
82
|
+
function codex_fork(profile, thread_id, last_turn, spawn_process) {
|
|
83
|
+
return new Promise((resolve_fork, reject) => {
|
|
84
|
+
const child = spawn_process(profile.executable, ['app-server'], { windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
85
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
86
|
+
let settled = false;
|
|
87
|
+
const finish = (error, value) => {
|
|
88
|
+
if (settled) return;
|
|
89
|
+
settled = true;
|
|
90
|
+
clearTimeout(timer); lines.close(); child.stdin.destroy(); child.kill();
|
|
91
|
+
if (error) reject(error); else resolve_fork(value);
|
|
92
|
+
};
|
|
93
|
+
const timer = setTimeout(() => finish(new Error('Codex 分叉会话超时')), FORK_TIMEOUT_MS);
|
|
94
|
+
const send = value => child.stdin.write(`${JSON.stringify(value)}\n`);
|
|
95
|
+
child.once('error', error => finish(error));
|
|
96
|
+
child.once('close', () => finish(new Error('Codex app-server 未完成分叉就退出了')));
|
|
97
|
+
child.stdin.on('error', error => finish(error));
|
|
98
|
+
child.stderr.resume();
|
|
99
|
+
lines.on('line', line => {
|
|
100
|
+
let reply;
|
|
101
|
+
try { reply = JSON.parse(line); } catch { return; }
|
|
102
|
+
if (reply.error && [1, 2].includes(reply.id)) return finish(new Error(`Codex 分叉会话失败:${reply.error.message}`));
|
|
103
|
+
if (reply.id === 1) {
|
|
104
|
+
send({ method: 'initialized', params: {} });
|
|
105
|
+
send({ id: 2, method: 'thread/fork', params: { threadId: thread_id, excludeTurns: true, ...(last_turn ? { lastTurnId: last_turn } : {}) } });
|
|
106
|
+
} else if (reply.id === 2) {
|
|
107
|
+
const id = reply.result?.thread?.id;
|
|
108
|
+
finish(id ? null : new Error('Codex 分叉会话没有返回新会话 ID'), id);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'codiscuss', version: '0.1.0' } } });
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Codex: fork through the turn before the first marked one; a cut at the first turn starts anew. */
|
|
116
|
+
async function codex_branch(profile, session_id, markers, always, env, home, spawn_process) {
|
|
117
|
+
const file = session_path('codex_cli', session_id, env, home);
|
|
118
|
+
if (!file) throw new Error(`本机找不到 Codex 会话 ${session_id}`);
|
|
119
|
+
const { turns, cut } = codex_turns(file, markers);
|
|
120
|
+
if (cut < 0 && !always) return null;
|
|
121
|
+
if (cut === 0) return { session_id: '' };
|
|
122
|
+
return { session_id: await codex_fork(profile, session_id, cut > 0 ? turns[cut - 1] : '', spawn_process) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** agy: copy the conversation database under a new ID, without the steps from the first marked one on. */
|
|
126
|
+
function agy_branch(session_id, markers, always, home) {
|
|
127
|
+
const file = session_path('antigravity_cli', session_id, process.env, home);
|
|
128
|
+
if (!file) throw new Error(`本机找不到 agy 会话 ${session_id}`);
|
|
129
|
+
const encoded = markers.map(marker => Buffer.from(marker, 'utf8'));
|
|
130
|
+
const source = new DatabaseSync(file, { readOnly: true });
|
|
131
|
+
const target = join(file, '..', `${randomUUID()}.db`);
|
|
132
|
+
let cut;
|
|
133
|
+
try {
|
|
134
|
+
// The prompt is stored inside the step's protobuf payload, as UTF-8 text.
|
|
135
|
+
cut = source.prepare('SELECT idx,step_payload FROM steps ORDER BY idx').all()
|
|
136
|
+
.find(row => row.step_payload && encoded.some(marker => Buffer.from(row.step_payload).includes(marker)))?.idx;
|
|
137
|
+
if (cut === undefined && !always) return null;
|
|
138
|
+
if (cut === 0) return { session_id: '' };
|
|
139
|
+
source.exec(`VACUUM INTO '${target.replaceAll("'", "''")}'`);
|
|
140
|
+
} finally { source.close(); }
|
|
141
|
+
const id = target.slice(-39, -3);
|
|
142
|
+
const copy = new DatabaseSync(target);
|
|
143
|
+
try {
|
|
144
|
+
if (cut !== undefined) copy.prepare('DELETE FROM steps WHERE idx>=?').run(cut);
|
|
145
|
+
copy.prepare('UPDATE trajectory_meta SET cascade_id=?').run(id);
|
|
146
|
+
} finally { copy.close(); }
|
|
147
|
+
return { session_id: id };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Branch a session before the first of some taken-back executions it contains, leaving the original untouched.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} profile The member's local software profile (`adapter`, `executable`).
|
|
154
|
+
* @param {string} session_id The session the member is bound to.
|
|
155
|
+
* @param {number[]} executions Execution IDs whose turns the branch must not contain.
|
|
156
|
+
* @param {object} options `always` branches even when none of them is in the session (a forked room needs its own copy).
|
|
157
|
+
* @returns {Promise<null|{session_id: string, fork?: {from: string, at: string}}>} null when nothing needs cutting;
|
|
158
|
+
* an empty session_id starts a new conversation; `fork` asks Claude's next turn to fork into session_id.
|
|
159
|
+
*/
|
|
160
|
+
export async function branch_session(profile, session_id, executions, { always = false, env = process.env, home = homedir(), spawn_process = spawn } = {}) {
|
|
161
|
+
const markers = executions.map(execution_marker);
|
|
162
|
+
if (profile.adapter === 'claude') return claude_branch(session_id, markers, always, env, home);
|
|
163
|
+
// A Codex App chat is stored like a CLI one; the App loads the fork when the first message is sent to it.
|
|
164
|
+
if (['codex', 'codex_cli'].includes(profile.adapter)) return codex_branch(profile, session_id, markers, always, env, home, spawn_process);
|
|
165
|
+
if (profile.adapter === 'antigravity_cli') return agy_branch(session_id, markers, always, home);
|
|
166
|
+
throw new Error(`${profile.adapter} 的会话不能由本机截断`);
|
|
167
|
+
}
|