ai_co_discuss 0.2.87 → 0.2.89
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-host/SKILL.md +37 -131
- package/.agents/skills/discuss-host/references/dev-flow.md +10 -0
- package/.agents/skills/discuss-host/references/discussion.md +107 -0
- package/.agents/skills/discuss-host/references/free.md +22 -0
- package/.agents/skills/discuss-host/references/manual.md +6 -6
- package/.agents/skills/discuss-participant/SKILL.md +21 -53
- package/.agents/skills/discuss-participant/references/discussion.md +41 -0
- package/.agents/skills/discuss-participant/references/free.md +7 -13
- package/.agents/skills/discuss-participant/references/manual.md +1 -1
- package/.agents/skills/discuss-participant/references/writing.md +1 -3
- package/backend/free_room.py +104 -10
- package/backend/runtime.py +45 -0
- package/backend/schema.sql +833 -808
- package/backend/server.py +2355 -2342
- package/docs/en/workflow.md +2 -1
- package/docs/runner.md +2 -2
- package/docs/workflow.md +4 -3
- package/package.json +1 -1
- package/runner/api_participant.mjs +11 -8
- package/runner/codiscuss.mjs +1 -0
- package/runner/discuss.py +4 -0
- package/runner/discussion_adapters.mjs +111 -2
- package/runner/discussion_launch.mjs +1125 -1083
- package/runner/discussion_runner.mjs +18 -4
- package/runner/discussion_service.mjs +81 -4
- package/runner/expert_prompts.mjs +14 -0
- package/runner/free_runner.mjs +469 -463
- package/web/app.js +3692 -3570
- package/web/modern.css +1 -1
- package/web/modern.js +1595 -749
|
@@ -7,7 +7,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
8
|
import { parseArgs } from 'node:util';
|
|
9
9
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
10
|
-
import { agy_command_rule, atomic_json, deliver_task, remove_agy_projects, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
|
|
10
|
+
import { agy_command_rule, atomic_json, deliver_task, remove_agy_projects, SegmentLog, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
|
|
11
11
|
import { read_api_key } from './discussion_api.mjs';
|
|
12
12
|
import { with_expert_prompt } from './expert_prompts.mjs';
|
|
13
13
|
import { decide_interjection, decide_next_speaker, decide_round, validate_scheduler } from './scheduler.mjs';
|
|
@@ -25,6 +25,8 @@ function windows_rg() {
|
|
|
25
25
|
const AUTO_ENTER_ADAPTERS = new Set(['codex_cli', 'claude', 'antigravity_cli']);
|
|
26
26
|
const DELIVERY_RETRY_DELAYS_MS = [2000, 4000];
|
|
27
27
|
const RENEW_MS = 30_000;
|
|
28
|
+
/** Recorded phase segments are reported quietly, waking no page, so they may go every tick. */
|
|
29
|
+
const PROGRESS_REPORT_MS = 2_000;
|
|
28
30
|
const ADAPTER_NAMES = ['codex', 'claude', 'antigravity', 'codex_cli', 'antigravity_cli', 'api'];
|
|
29
31
|
export const CODEX_ADAPTERS = ['codex', 'codex_cli'];
|
|
30
32
|
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -213,8 +215,9 @@ export function task_files(config, event, token, binding, { suggestions = [], en
|
|
|
213
215
|
const access_path = join(config.directory, `${id}.access.json`);
|
|
214
216
|
const task_path = join(config.directory, `${id}.task.json`);
|
|
215
217
|
atomic_json(access_path, { site: config.site, room: config.room, token, session_id: binding.session_id || '' });
|
|
216
|
-
|
|
217
|
-
|
|
218
|
+
// Each skill's core plus its discussion-room page, so the task needs no detour through the skills' scenario tables.
|
|
219
|
+
const skills = [...['discuss-participant/SKILL.md', 'discuss-participant/references/discussion.md'],
|
|
220
|
+
...event.role === 'host' ? ['discuss-host/SKILL.md', 'discuss-host/references/discussion.md'] : []].map(file => join(config.project, '.agents/skills', file));
|
|
218
221
|
const adapter = config.participants[event.participant];
|
|
219
222
|
const auto_enter = entered || AUTO_ENTER_ADAPTERS.has(adapter.adapter);
|
|
220
223
|
const bound_access = process_access && AUTO_ENTER_ADAPTERS.has(adapter.adapter);
|
|
@@ -257,6 +260,7 @@ export class DiscussionRunner {
|
|
|
257
260
|
this.seen = new Set();
|
|
258
261
|
this.blocked = new Set();
|
|
259
262
|
this.fetch = fetch_impl;
|
|
263
|
+
this.segments = new SegmentLog();
|
|
260
264
|
}
|
|
261
265
|
/** Append one timestamped stage to the event's trace file when CODISCUSS_TRACE=1; tracing must never affect delivery. */
|
|
262
266
|
trace(id, stage, detail = {}) {
|
|
@@ -290,6 +294,7 @@ export class DiscussionRunner {
|
|
|
290
294
|
try {
|
|
291
295
|
const task = task_files(this.config, event, row.token, binding, { suggestions, entered: pre_entered, process_access: true });
|
|
292
296
|
invoked = true;
|
|
297
|
+
this.segments.start(row.id);
|
|
293
298
|
const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
|
|
294
299
|
api_context: { site: this.config.site, room: this.config.room, token: row.token, home: this.config.api_home,
|
|
295
300
|
event_id: row.id, role: event.role, turn_id: event.turn_id, scheduler_suggestions: suggestions, entered: pre_entered },
|
|
@@ -300,7 +305,8 @@ export class DiscussionRunner {
|
|
|
300
305
|
if (AUTO_ENTER_ADAPTERS.has(adapter.adapter) && !entered) { entered = true; this.enter(row, session); }
|
|
301
306
|
},
|
|
302
307
|
on_process: child => this.track(event.participant, child),
|
|
303
|
-
on_output: (path, chunk) => appendFileSync(path, chunk, { mode: 0o600 }),
|
|
308
|
+
on_output: (path, chunk) => { appendFileSync(path, chunk, { mode: 0o600 }); this.segments.mark(row.id); },
|
|
309
|
+
on_activity: (phase, tool) => this.segments.mark(row.id, phase, tool),
|
|
304
310
|
on_failure: error => { try { this.store.record(row.id, { error }); } catch { /* Store may already be closed at shutdown. */ } },
|
|
305
311
|
on_exit: detail => this.exited.set(row.id, detail || ''),
|
|
306
312
|
});
|
|
@@ -479,6 +485,7 @@ export class DiscussionRunner {
|
|
|
479
485
|
async reconcile(row, event) {
|
|
480
486
|
if (['completed', 'cancelled', 'failed'].includes(event.status)) {
|
|
481
487
|
this.store.record(row.id, { status: event.status });
|
|
488
|
+
await this.report_segments(row.id, true);
|
|
482
489
|
return;
|
|
483
490
|
}
|
|
484
491
|
// No app call occurs before dispatching is persisted. Reconcile a reserve response loss safely.
|
|
@@ -520,10 +527,17 @@ export class DiscussionRunner {
|
|
|
520
527
|
}
|
|
521
528
|
this.store.record(row.id, { status: 'started', last_renew: Date.now(), error: '' });
|
|
522
529
|
}
|
|
530
|
+
if (this.segments.due(row.id, PROGRESS_REPORT_MS)) await this.report_segments(row.id);
|
|
531
|
+
}
|
|
532
|
+
/** Send the execution's recorded phase segments; `final` sends the rest once it has ended. */
|
|
533
|
+
report_segments(id, final = false) {
|
|
534
|
+
// Discussion executions keep no progress notes, so a report always carries segments.
|
|
535
|
+
return this.segments.report(id, final, ({ segments }) => api(this.config, `/events/${id}`, { action: 'segments', segments }));
|
|
523
536
|
}
|
|
524
537
|
/** Preserve the cause on the website and revoke work before asking for intervention. */
|
|
525
538
|
async fail(row, reason) {
|
|
526
539
|
this.log(`执行 #${row.id} 失败:${reason}`);
|
|
540
|
+
await this.report_segments(row.id, true);
|
|
527
541
|
try { await api(this.config, `/events/${row.id}`, { action: 'fail', reason }); }
|
|
528
542
|
catch (error) {
|
|
529
543
|
if (!/HTTP 409/.test(error.message)) throw error;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Resident local service that dispatches registered rooms to software or configured API participants. */
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { dirname, join, resolve } from 'node:path';
|
|
7
7
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -17,12 +17,18 @@ const REMOTE_SYNC_MS = 3000;
|
|
|
17
17
|
const START_WAIT_MS = 5000;
|
|
18
18
|
const STOP_WAIT_MS = 20_000;
|
|
19
19
|
const REGISTRY_WAIT_MS = 5000;
|
|
20
|
+
// Housekeeping: logs above the limit keep only their newest part; a launch folder no room uses is removed once it is this old.
|
|
21
|
+
const SWEEP_MS = 6 * 60 * 60 * 1000;
|
|
22
|
+
const LOG_LIMIT_BYTES = 5 * 1024 * 1024;
|
|
23
|
+
const LOG_KEEP_BYTES = 1024 * 1024;
|
|
24
|
+
const STALE_LAUNCH_MS = 7 * 24 * 60 * 60 * 1000;
|
|
25
|
+
const SERVICE_LOGS = ['service.log', join('local', 'website.log'), join('local', 'supervisor.log')];
|
|
20
26
|
const SCRIPT = fileURLToPath(import.meta.url);
|
|
21
27
|
const REPO_ROOT = dirname(dirname(SCRIPT));
|
|
22
28
|
const SHORTCUT_NAME = 'AI CoDiscuss 讨论服务.lnk';
|
|
23
29
|
const TICK_STATES = { undefined: 'running', waiting: 'paused', false: 'idle' };
|
|
24
30
|
const STATUS_LABELS = { running: '运行中', paused: '已暂停', idle: '等待启动', error: '异常' };
|
|
25
|
-
const USAGE = '用法: discussion_service.mjs start|stop|status|open|run|list|add|remove|shortcut|autostart [--config 私有配置.json] [on|off]';
|
|
31
|
+
const USAGE = '用法: discussion_service.mjs start|stop|status|open|run|list|add|remove|clean|shortcut|autostart [--config 私有配置.json] [on|off]';
|
|
26
32
|
export const CONNECTION_FIELDS = ['executable', 'ipc_directory'];
|
|
27
33
|
|
|
28
34
|
/** Read one atomic configuration version for editing and worker acknowledgement. */
|
|
@@ -351,10 +357,13 @@ export class DiscussionService {
|
|
|
351
357
|
await worker.tick();
|
|
352
358
|
if (!worker.removed) continue;
|
|
353
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;
|
|
354
361
|
await worker.close(false);
|
|
355
362
|
this.workers.delete(path);
|
|
356
363
|
try { unregister_room(this.home, path); } catch { /* Already removed by another command. */ }
|
|
357
|
-
|
|
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}: 房间已在网站删除,已撤销登记并删除本机文件`);
|
|
358
367
|
}
|
|
359
368
|
atomic_json(join(this.home, 'status.json'), { pid: process.pid, updated_at: Math.floor(Date.now() / 1000),
|
|
360
369
|
rooms: [...this.workers.values()].map(worker => worker.state) });
|
|
@@ -366,6 +375,64 @@ export class DiscussionService {
|
|
|
366
375
|
}
|
|
367
376
|
}
|
|
368
377
|
|
|
378
|
+
/** Delete one room's local files: its answer drafts, and its launch folder when the service created it under launches/. */
|
|
379
|
+
export function remove_room_files(home, config_path, data_directory) {
|
|
380
|
+
const folder = dirname(resolve(config_path));
|
|
381
|
+
for (const directory of [data_directory, folder, join(folder, 'runtime')].filter(Boolean)) {
|
|
382
|
+
rmSync(service_drafts(home, directory), { recursive: true, force: true });
|
|
383
|
+
}
|
|
384
|
+
if (dirname(folder) === resolve(home, 'launches')) rmSync(folder, { recursive: true, force: true });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Keep only the newest part of a log that grew past the limit; writers append, so they continue at its new end. */
|
|
388
|
+
function trim_log(path) {
|
|
389
|
+
if (!existsSync(path) || statSync(path).size <= LOG_LIMIT_BYTES) return false;
|
|
390
|
+
const size = statSync(path).size, tail = Buffer.alloc(LOG_KEEP_BYTES);
|
|
391
|
+
const handle = openSync(path, 'r');
|
|
392
|
+
try { readSync(handle, tail, 0, LOG_KEEP_BYTES, size - LOG_KEEP_BYTES); } finally { closeSync(handle); }
|
|
393
|
+
// Start at a whole line so the kept part does not open mid-entry.
|
|
394
|
+
writeFileSync(path, tail.subarray(tail.indexOf(10) + 1));
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The newest change time of a launch folder's own records, which a pending launch still updates. */
|
|
399
|
+
function launch_touched(folder) {
|
|
400
|
+
return Math.max(...[folder, join(folder, 'launch.json'), join(folder, 'config.json')].filter(existsSync).map(path => statSync(path).mtimeMs));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Remove what no registered room uses: launch folders left by rooms deleted while the service was off, or launches
|
|
405
|
+
* never confirmed, once they are a week old and no process holds them; answer drafts whose folder is gone; and the
|
|
406
|
+
* oversized part of the service's logs. Registered rooms, settings and the local website's database are never touched.
|
|
407
|
+
*/
|
|
408
|
+
export function sweep_service_home(home, now = Date.now()) {
|
|
409
|
+
const registered = read_registry(home).map(path => dirname(resolve(path)));
|
|
410
|
+
const launches = join(home, 'launches'), removed = { launches: [], drafts: [], logs: [] };
|
|
411
|
+
for (const name of existsSync(launches) ? readdirSync(launches) : []) {
|
|
412
|
+
const folder = join(launches, name);
|
|
413
|
+
if (registered.includes(folder) || service_pid(folder) || service_pid(join(folder, 'runtime')) || now - launch_touched(folder) < STALE_LAUNCH_MS) continue;
|
|
414
|
+
try { remove_room_files(home, join(folder, 'config.json')); removed.launches.push(name); } catch { /* Still held; the next sweep retries. */ }
|
|
415
|
+
}
|
|
416
|
+
// A draft folder is keyed by its room's data directory; one matching no remaining folder or registered room is orphaned.
|
|
417
|
+
const live = new Set();
|
|
418
|
+
for (const path of read_registry(home)) {
|
|
419
|
+
const folder = dirname(resolve(path));
|
|
420
|
+
for (const directory of [folder, join(folder, 'runtime')]) live.add(service_drafts(home, directory));
|
|
421
|
+
try { live.add(service_drafts(home, validate_config(JSON.parse(readFileSync(path, 'utf8')), folder).directory)); } catch { /* Covered by the folder keys. */ }
|
|
422
|
+
}
|
|
423
|
+
for (const name of existsSync(launches) ? readdirSync(launches) : []) {
|
|
424
|
+
for (const directory of [join(launches, name), join(launches, name, 'runtime')]) live.add(service_drafts(home, directory));
|
|
425
|
+
}
|
|
426
|
+
const drafts = join(home, 'drafts');
|
|
427
|
+
for (const name of existsSync(drafts) ? readdirSync(drafts) : []) {
|
|
428
|
+
if (live.has(join(drafts, name))) continue;
|
|
429
|
+
rmSync(join(drafts, name), { recursive: true, force: true });
|
|
430
|
+
removed.drafts.push(name);
|
|
431
|
+
}
|
|
432
|
+
for (const log of SERVICE_LOGS) if (trim_log(join(home, log))) removed.logs.push(log);
|
|
433
|
+
return removed;
|
|
434
|
+
}
|
|
435
|
+
|
|
369
436
|
/** Return the live service PID, or 0 when no service owns the home lock. */
|
|
370
437
|
export function service_pid(home) {
|
|
371
438
|
const path = join(home, 'runner.lock');
|
|
@@ -405,9 +472,18 @@ export async function run_service(home, output = () => {}) {
|
|
|
405
472
|
updated_at: Math.floor(Date.now() / 1000), rooms: [] });
|
|
406
473
|
// Loaded lazily: the relay runs launches, and the launch module itself depends on this one.
|
|
407
474
|
const { sync_remote_launches } = await import('./remote_launch.mjs');
|
|
408
|
-
let synced_at = 0;
|
|
475
|
+
let synced_at = 0, swept_at = 0;
|
|
409
476
|
while (!stopped && !existsSync(stop_path)) {
|
|
410
477
|
try { await service.tick(); } catch (error) { log_line(`服务检查失败:${error.message}`); }
|
|
478
|
+
if (Date.now() - swept_at >= SWEEP_MS) {
|
|
479
|
+
swept_at = Date.now();
|
|
480
|
+
try {
|
|
481
|
+
const removed = sweep_service_home(home);
|
|
482
|
+
if (removed.launches.length || removed.drafts.length || removed.logs.length) {
|
|
483
|
+
log_line(`清理本机文件:启动目录 ${removed.launches.length} 个,草稿 ${removed.drafts.length} 个,日志 ${removed.logs.join('、') || '无'}`);
|
|
484
|
+
}
|
|
485
|
+
} catch (error) { log_line(`清理本机文件失败:${error.message}`); }
|
|
486
|
+
}
|
|
411
487
|
if (Date.now() - synced_at >= REMOTE_SYNC_MS) {
|
|
412
488
|
synced_at = Date.now();
|
|
413
489
|
try { await sync_remote_launches(home, { log: log_line }); } catch (error) { log_line(`远程启动检查失败:${error.message}`); }
|
|
@@ -531,6 +607,7 @@ export async function main(args = process.argv.slice(2), print = value => proces
|
|
|
531
607
|
case 'list': return print({ home, configs: read_registry(home) });
|
|
532
608
|
case 'add': { const config = register_room(home, require_config()); return print({ added: config.room, site: config.site }); }
|
|
533
609
|
case 'remove': unregister_room(home, require_config()); return print({ removed: resolve(values.config) });
|
|
610
|
+
case 'clean': return print(sweep_service_home(home));
|
|
534
611
|
case 'shortcut': return print({ shortcut: write_shortcut('Desktop', 'open') });
|
|
535
612
|
case 'autostart':
|
|
536
613
|
if (option === 'on') return print({ autostart: write_shortcut('Startup', 'start') });
|
|
@@ -13,6 +13,20 @@ export const EXPERT_PROMPT_PRESETS = {
|
|
|
13
13
|
api: '你读不到本地文件,相关内容以其他成员贴出的为准,不凭文件名猜测。缺信息时在回答里写明缺什么。',
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Expert presets: a named instance with its description, prompt, project access and optionally model and effort,
|
|
18
|
+
* added in one click. The user's own presets live on this computer (`expert-presets.json` in the service home);
|
|
19
|
+
* one with a built-in's name replaces it.
|
|
20
|
+
*/
|
|
21
|
+
export const BUILTIN_EXPERT_PRESETS = [
|
|
22
|
+
{ name: 'Codex-评审', builtin: true, project_access: 'read',
|
|
23
|
+
description: '专门做代码评审:对照计划文档检查改动,只提问题不改代码。',
|
|
24
|
+
system_prompt: '你是本房间的评审。先读主持人给出的计划文档与改动范围(执行报告列有改动文件,可用 git diff 查看),检查:是否符合计划与约定的接口、有无正确性问题、有无越出分工的改动、测试是否覆盖关键行为。按严重程度列出问题,每条附文件与行号和理由;没有问题就明确说通过。只评审,不修改文件。' },
|
|
25
|
+
{ name: 'Gemini-文档', builtin: true, project_access: 'write',
|
|
26
|
+
description: '专门整理文档:按改动同步说明,只改文档不改代码。',
|
|
27
|
+
system_prompt: '你负责整理文档。先读计划文档与本次改动,按仓库现有文档的分工同步说明,只改文档文件,不改代码;不确定的事实标为待核实,不自行补写。多步任务中,在关键转折处用一句话写出进展再继续。' },
|
|
28
|
+
];
|
|
29
|
+
|
|
16
30
|
/** The prompt this member receives: its own text when set, otherwise its software's preset. */
|
|
17
31
|
export function expert_prompt(profile = {}) {
|
|
18
32
|
const own = Object.hasOwn(profile, 'system_prompt');
|