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.
- package/.agents/skills/discuss-host/references/free.md +1 -1
- package/.agents/skills/discuss-host/references/workflow-draft.md +13 -4
- package/docs/runner.md +147 -145
- package/docs/workflow.md +1 -1
- package/package.json +1 -1
- package/runner/discussion_launch.mjs +78 -9
- package/runner/discussion_service.mjs +45 -7
- package/runner/expert_prompts.mjs +5 -2
- package/runner/free_runner.mjs +1 -0
- package/runner/tts.mjs +19 -8
- package/web/app.js +4287 -4261
- package/web/modern.css +1 -1
- package/web/modern.js +9883 -9643
|
@@ -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;
|
|
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 {
|
|
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
|
-
/**
|
|
76
|
-
|
|
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.
|
package/runner/free_runner.mjs
CHANGED
|
@@ -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
|
-
|
|
24
|
-
const
|
|
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
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
/**
|