ai_co_discuss 0.3.41 → 0.3.43

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.
@@ -37,6 +37,8 @@ const SWEEP_MS = 6 * 60 * 60 * 1000;
37
37
  const LOG_LIMIT_BYTES = 5 * 1024 * 1024;
38
38
  const LOG_KEEP_BYTES = 1024 * 1024;
39
39
  const STALE_LAUNCH_MS = 7 * 24 * 60 * 60 * 1000;
40
+ // One execution's task, credential and software log, e.g. `free-12.task.json`, `claude-free-12.log`; `event-` is the older mode.
41
+ const EXECUTION_FILE = /^(?:[a-z_]+-)?(?:free|event)-(\d+)\.(?:task\.json|access\.json|log)$/;
40
42
  const SERVICE_LOGS = ['service.log', join('local', 'website.log'), join('local', 'supervisor.log')];
41
43
  const SCRIPT = fileURLToPath(import.meta.url);
42
44
  const REPO_ROOT = dirname(dirname(SCRIPT));
@@ -567,6 +569,35 @@ function trim_log(path) {
567
569
  return true;
568
570
  }
569
571
 
572
+ /** The execution each member's delivery record still names; its files may be in use however old they are. */
573
+ function current_executions(directory) {
574
+ const path = join(directory, 'runtime.sqlite3');
575
+ if (!existsSync(path)) return new Set();
576
+ const db = new DatabaseSync(path, { readOnly: true });
577
+ try {
578
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='free_deliveries'").get()) return new Set();
579
+ return new Set(db.prepare('SELECT execution_id FROM free_deliveries').all().map(row => row.execution_id));
580
+ } catch { return null; } finally { db.close(); }
581
+ }
582
+
583
+ /** Remove a room's per-execution files once they are a week old and no member's delivery still names the execution. */
584
+ function sweep_execution_files(directory, now) {
585
+ if (!existsSync(directory)) return 0;
586
+ const current = current_executions(directory);
587
+ // An unreadable delivery table gives no proof which executions ended, so nothing is removed.
588
+ if (!current) return 0;
589
+ let removed = 0;
590
+ for (const name of readdirSync(directory)) {
591
+ const id = EXECUTION_FILE.exec(name)?.[1];
592
+ if (!id || current.has(Number(id))) continue;
593
+ const path = join(directory, name);
594
+ if (now - statSync(path).mtimeMs < STALE_LAUNCH_MS) continue;
595
+ rmSync(path, { force: true });
596
+ removed += 1;
597
+ }
598
+ return removed;
599
+ }
600
+
570
601
  /** The newest change time of a launch folder's own records, which a pending launch still updates. */
571
602
  function launch_touched(folder) {
572
603
  return Math.max(...[folder, join(folder, 'launch.json'), join(folder, 'config.json')].filter(existsSync).map(path => statSync(path).mtimeMs));
@@ -574,23 +605,30 @@ function launch_touched(folder) {
574
605
 
575
606
  /**
576
607
  * Remove what no registered room uses: launch folders left by rooms deleted while the service was off, or launches
577
- * never confirmed, once they are a week old and no process holds them; answer drafts whose folder is gone; and the
578
- * oversized part of the service's logs. Registered rooms, settings and the local website's database are never touched.
608
+ * never confirmed, once they are a week old and no process holds them; answer drafts whose folder is gone; registered
609
+ * rooms' task, credential and log files of executions that ended a week ago; and the oversized part of the service's logs. Registered rooms, settings and the local website's database are never touched.
579
610
  */
580
611
  export function sweep_service_home(home, now = Date.now()) {
581
612
  const registered = read_registry(home).map(path => dirname(resolve(path)));
582
- const launches = join(home, 'launches'), removed = { launches: [], drafts: [], logs: [] };
613
+ const launches = join(home, 'launches'), removed = { launches: [], drafts: [], logs: [], execution_files: 0 };
583
614
  for (const name of existsSync(launches) ? readdirSync(launches) : []) {
584
615
  const folder = join(launches, name);
585
616
  if (registered.includes(folder) || service_pid(folder) || service_pid(join(folder, 'runtime')) || now - launch_touched(folder) < STALE_LAUNCH_MS) continue;
586
617
  try { remove_room_files(home, join(folder, 'config.json')); removed.launches.push(name); } catch { /* Still held; the next sweep retries. */ }
587
618
  }
588
619
  // A draft folder is keyed by its room's data directory; one matching no remaining folder or registered room is orphaned.
589
- const live = new Set();
620
+ const live = new Set(), runtimes = new Set();
590
621
  for (const path of read_registry(home)) {
591
622
  const folder = dirname(resolve(path));
592
623
  for (const directory of [folder, join(folder, 'runtime')]) live.add(service_drafts(home, directory));
593
- try { live.add(service_drafts(home, validate_config(JSON.parse(readFileSync(path, 'utf8')), folder).directory)); } catch { /* Covered by the folder keys. */ }
624
+ try {
625
+ const { directory } = validate_config(JSON.parse(readFileSync(path, 'utf8')), folder);
626
+ live.add(service_drafts(home, directory));
627
+ runtimes.add(resolve(directory));
628
+ } catch { /* Covered by the folder keys; an unreadable configuration keeps its execution files. */ }
629
+ }
630
+ for (const directory of runtimes) {
631
+ try { removed.execution_files += sweep_execution_files(directory, now); } catch { /* Still held; the next sweep retries. */ }
594
632
  }
595
633
  for (const name of existsSync(launches) ? readdirSync(launches) : []) {
596
634
  for (const directory of [join(launches, name), join(launches, name, 'runtime')]) live.add(service_drafts(home, directory));
@@ -669,8 +707,8 @@ export async function run_service(home, output = () => {}) {
669
707
  swept_at = Date.now();
670
708
  try {
671
709
  const removed = sweep_service_home(home);
672
- if (removed.launches.length || removed.drafts.length || removed.logs.length) {
673
- log_line(`清理本机文件:启动目录 ${removed.launches.length} 个,草稿 ${removed.drafts.length} 个,日志 ${removed.logs.join('、') || '无'}`);
710
+ if (removed.launches.length || removed.drafts.length || removed.logs.length || removed.execution_files) {
711
+ log_line(`清理本机文件:启动目录 ${removed.launches.length} 个,草稿 ${removed.drafts.length} 个,执行文件 ${removed.execution_files} 个,日志 ${removed.logs.join('、') || '无'}`);
674
712
  }
675
713
  } catch (error) { log_line(`清理本机文件失败:${error.message}`); }
676
714
  }
@@ -72,8 +72,11 @@ export function access_options(adapter) {
72
72
  /** The page's name for a level; `own` reads as decided by the member's own session. */
73
73
  export function access_label(level) { return level === 'own' ? '自行决定' : ACCESS_LABELS[level] || '只读'; }
74
74
 
75
- /** Website subcommands an automatic task may run; service, launch, run, api and setup stay unapproved. */
76
- export const TASK_SUBCOMMANDS = ['room', 'message', 'turn', 'state', 'review', 'execution', 'health', 'schema'];
75
+ /**
76
+ * Subcommands an automatic task may run; service, run, api, setup and the rest of launch stay unapproved.
77
+ * `launch workflow-adopt` checks itself that the caller is the host of a room whose owner allowed it.
78
+ */
79
+ export const TASK_SUBCOMMANDS = ['room', 'message', 'turn', 'state', 'review', 'execution', 'health', 'schema', 'launch workflow-adopt'];
77
80
 
78
81
  /**
79
82
  * Whether a member may run commands beyond the codiscuss subcommands: every level but 只读, and 可写 only with a project.
@@ -124,6 +124,7 @@ export function free_task_files(config, member, execution, binding, { session =
124
124
  read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
125
125
  skills: [...skills.map(file => join(config.project, '.agents/skills', file)), ...(host_workflow ? [host_workflow.skill_path] : [])],
126
126
  ...(workflow_field ? { workflow: workflow_field } : {}),
127
+ ...(member.role === 'host' && config.host_workflows ? { host_workflows: true } : {}),
127
128
  cli, subcommands: TASK_SUBCOMMANDS,
128
129
  ...(bound_access ? { access_env: 'DISCUSS_ACCESS_FILE' } : { access_file: access_path }), instructions });
129
130
  return { id, participant: member.name, directory: config.directory, draft_directory,
package/runner/tts.mjs CHANGED
@@ -20,8 +20,9 @@ const SPEAKER_PATTERN = /^[\w.-]{1,128}$/;
20
20
  const MAX_SILENCE_MS = 3000;
21
21
  // Synthesized paragraphs are kept on this computer, so hearing the same text again is instant and not billed again.
22
22
  const CACHE_DIR = 'tts-cache';
23
- const CACHE_LIMIT = 400;
24
- const CACHE_PRUNE = 100;
23
+ // The cache is bounded by total size; past it, the least recently heard entries go until it is back under the target.
24
+ const CACHE_LIMIT_BYTES = 50 * 1024 * 1024;
25
+ const CACHE_KEEP_BYTES = 40 * 1024 * 1024;
25
26
  const MAX_MEMBER_VOICES = 50;
26
27
  // A default tone instruction (context_texts) so reading sounds told rather than recited; users may change or clear it.
27
28
  export const TTS_DEFAULT_CONTEXT = '请用自然放松的语气朗读,像给朋友讲故事一样,有轻重缓急,句子之间自然停顿。';
@@ -63,7 +64,7 @@ const STATUS_MESSAGES = {
63
64
  export function tts_settings(home) {
64
65
  const path = join(home, SETTINGS_FILE);
65
66
  const saved = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
66
- return { ...TTS_DEFAULTS, member_voices: {}, ...saved, voices: TTS_VOICES, key_base_url: TTS_BASE_URL };
67
+ return { ...TTS_DEFAULTS, member_voices: {}, file_voice: '', ...saved, voices: TTS_VOICES, key_base_url: TTS_BASE_URL };
67
68
  }
68
69
 
69
70
  /** Validate and save the read-aloud settings the page edits. */
@@ -83,7 +84,10 @@ export function save_tts_settings(home, input) {
83
84
  if (String(name).length > 40 || !SPEAKER_PATTERN.test(voice)) throw new Error('成员音色无效,请重新选择');
84
85
  member_voices[name] = voice;
85
86
  }
86
- const settings = { speaker, speech_rate, context, member_voices, ...(api_key_ref ? { api_key_ref } : {}) };
87
+ // The voice for reading project files; empty uses the default voice.
88
+ const file_voice = String(input.file_voice ?? '').trim();
89
+ if (file_voice && !SPEAKER_PATTERN.test(file_voice)) throw new Error('文件音色无效,请重新选择');
90
+ const settings = { speaker, speech_rate, context, member_voices, file_voice, ...(api_key_ref ? { api_key_ref } : {}) };
87
91
  mkdirSync(home, { recursive: true, mode: 0o700 });
88
92
  atomic_json(join(home, SETTINGS_FILE), settings);
89
93
  return tts_settings(home);
@@ -125,10 +129,17 @@ function cache_read(path) {
125
129
  function cache_write(directory, path, value) {
126
130
  mkdirSync(directory, { recursive: true, mode: 0o700 });
127
131
  atomic_json(path, value);
128
- const names = readdirSync(directory).filter(name => name.endsWith('.json'));
129
- if (names.length <= CACHE_LIMIT) return;
130
- const aged = names.map(name => ({ name, time: statSync(join(directory, name)).mtimeMs })).sort((a, b) => a.time - b.time);
131
- for (const { name } of aged.slice(0, CACHE_PRUNE)) rmSync(join(directory, name), { force: true });
132
+ const entries = readdirSync(directory).filter(name => name.endsWith('.json')).map(name => {
133
+ const { size, mtimeMs } = statSync(join(directory, name));
134
+ return { name, size, mtimeMs };
135
+ });
136
+ let total = entries.reduce((sum, entry) => sum + entry.size, 0);
137
+ if (total <= CACHE_LIMIT_BYTES) return;
138
+ for (const entry of entries.sort((a, b) => a.mtimeMs - b.mtimeMs)) {
139
+ if (total <= CACHE_KEEP_BYTES) break;
140
+ rmSync(join(directory, entry.name), { force: true });
141
+ total -= entry.size;
142
+ }
132
143
  }
133
144
 
134
145
  /**