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.
@@ -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
- const skills = [join(config.project, '.agents/skills/discuss-participant/SKILL.md')];
217
- if (event.role === 'host') skills.push(join(config.project, '.agents/skills/discuss-host/SKILL.md'));
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
- this.log(`${worker.state.site || ''}/${worker.state.room || path}: 房间已在网站删除,已撤销登记`);
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');