ai_co_discuss 0.3.46 → 0.3.49

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.
Files changed (43) hide show
  1. package/.agents/skills/discuss-host/SKILL.md +1 -0
  2. package/.agents/skills/discuss-host/references/discussion.md +113 -113
  3. package/.agents/skills/discuss-host/references/free.md +36 -34
  4. package/.agents/skills/discuss-host/references/goal.md +35 -0
  5. package/.agents/skills/discuss-participant/SKILL.md +1 -0
  6. package/.agents/skills/workflow-creator/SKILL.md +2 -2
  7. package/README.md +3 -1
  8. package/backend/admin.py +104 -35
  9. package/backend/free_room.py +2170 -2136
  10. package/backend/goal_room.py +358 -0
  11. package/backend/mode_room.py +105 -0
  12. package/backend/schema.sql +1593 -1510
  13. package/backend/server.py +2636 -2555
  14. package/backend/session_room.py +1180 -1148
  15. package/docs/THIRD_PARTY_NOTICES.md +1293 -1264
  16. package/docs/deployment.md +5 -1
  17. package/docs/en/workflow.md +15 -1
  18. package/docs/runner.md +28 -7
  19. package/docs/workflow.md +15 -1
  20. package/package.json +3 -2
  21. package/runner/adapters/api.mjs +333 -313
  22. package/runner/adapters/claude.mjs +12 -16
  23. package/runner/admin_account.mjs +12 -19
  24. package/runner/device_auth.mjs +369 -0
  25. package/runner/discuss.py +1208 -1125
  26. package/runner/discussion_launch.mjs +107 -12
  27. package/runner/discussion_service.mjs +25 -1
  28. package/runner/expert_prompts.mjs +132 -132
  29. package/runner/free_runner.mjs +982 -910
  30. package/runner/pinyin_table.mjs +406 -0
  31. package/runner/project_files.mjs +52 -3
  32. package/runner/relay_channel.mjs +51 -7
  33. package/runner/remote_launch.mjs +221 -235
  34. package/runner/remote_policy.mjs +156 -0
  35. package/runner/scheduler.mjs +459 -429
  36. package/runner/stt.mjs +2 -2
  37. package/runner/tts.mjs +74 -113
  38. package/runner/tts_providers.mjs +205 -0
  39. package/runner/webauthn.mjs +155 -0
  40. package/runner/workflows.mjs +371 -348
  41. package/web/app.js +355 -51
  42. package/web/modern.css +1 -1
  43. package/web/modern.js +5764 -3816
@@ -1,348 +1,371 @@
1
- /**
2
- * Room workflows: a host's SKILL.md plus a set of agent cards, kept as a shareable folder in the service home.
3
- * The program only stores, previews and loads them; the host follows SKILL.md on its own judgment.
4
- */
5
- import { createHash, randomUUID } from 'node:crypto';
6
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
- import { join, resolve } from 'node:path';
8
- import { DatabaseSync } from 'node:sqlite';
9
-
10
- // A workflow ID follows the Agent Skills naming rule: lowercase letters and digits joined by single hyphens.
11
- export const WORKFLOW_ID_PATTERN = /^(?=.{1,40}$)[a-z0-9]+(?:-[a-z0-9]+)*$/;
12
- // Characters of a SKILL.md hash the host compares to know whether its earlier reading is still current.
13
- const REVISION_LENGTH = 12;
14
- // A card ID becomes the member name's suffix (`platform-card`), which has no hyphen of its own.
15
- export const CARD_ID_PATTERN = /^[a-z0-9_]{1,16}$/;
16
- export const MAX_WORKFLOW_SKILL = 20000;
17
- export const MAX_WORKFLOW_CARDS = 30;
18
- const MAX_WORKFLOW_LABEL = 32;
19
- const MAX_WORKFLOW_DESCRIPTION = 500;
20
- const MAX_DRAFT_PARTS = 20;
21
- const WORKFLOW_LOCK_WAIT_MS = 5000;
22
- const BACKUP_DIRECTORY = /^\.old-([a-z0-9_-]{1,40})-[0-9a-f-]{36}$/;
23
- // A card file is shaped like an agent definition: `name` is the card ID (its file name) and `platform` the software.
24
- const CARD_FIELDS = ['name', 'label', 'description', 'project_access', 'model', 'effort'];
25
- const CARD_FILE_FIELDS = ['name', 'platform', 'label', 'description', 'project_access', 'model', 'effort'];
26
- const PART_LINE = /^<!-- workflow-part: (\d+)\/(\d+) id: ([a-z0-9_-]{1,40}) -->$/;
27
- const FILE_LINE = /^<!-- workflow-file: (\S+) -->$/;
28
- const CARD_PATH = /^agents\/([A-Za-z0-9_]{1,16})\.md$/;
29
-
30
- /** The folder holding every workflow of this computer. */
31
- export function workflows_home(home) { return join(home, 'workflows'); }
32
-
33
- /** A workflow's folder, built from a checked ID and never from a caller's path. */
34
- export function workflow_directory(home, id) {
35
- if (!WORKFLOW_ID_PATTERN.test(id || '')) throw new Error('工作流 ID 只能用小写英文和数字,可用单个连字符连接(不在首尾),1—40 位');
36
- return join(workflows_home(home), id);
37
- }
38
-
39
- /** Split single-line `key: value` frontmatter from a Markdown body. */
40
- export function parse_markdown(text, label) {
41
- const lines = String(text).replace(/^/, '').replace(/\r\n/g, '\n').split('\n');
42
- if (lines[0] !== '---') throw new Error(`${label} 须以 --- 开头的头信息开始`);
43
- const end = lines.indexOf('---', 1);
44
- if (end < 0) throw new Error(`${label} 的头信息缺少结尾 ---`);
45
- const fields = {};
46
- for (const line of lines.slice(1, end)) {
47
- if (!line.trim()) continue;
48
- const colon = line.indexOf(':');
49
- if (colon < 1) throw new Error(`${label} 的头信息只支持单行 key: value:${line}`);
50
- // Quotes around a value are YAML's, not part of it.
51
- fields[line.slice(0, colon).trim()] = line.slice(colon + 1).trim().replace(/^(["'])(.*)\1$/, '$2');
52
- }
53
- return { fields, body: lines.slice(end + 1).join('\n').trim() };
54
- }
55
-
56
- /** Write fields back as single-line frontmatter; empty fields are left out. */
57
- export function serialize_markdown(fields, body) {
58
- const header = Object.entries(fields).filter(([, value]) => value !== undefined && value !== '').map(([key, value]) => `${key}: ${String(value ?? '').replace(/\s*\n\s*/g, ' ')}`.trimEnd());
59
- return `---\n${header.join('\n')}\n---\n${body ? `${body.trim()}\n` : ''}`;
60
- }
61
-
62
- /** Turn one parsed card file into the card shape the page edits. */
63
- function card_from_markdown(card, text) {
64
- const { fields, body } = parse_markdown(text, `agents/${card}.md`);
65
- const unknown = Object.keys(fields).filter(key => !CARD_FILE_FIELDS.includes(key));
66
- if (unknown.length) throw new Error(`agents/${card}.md 含不支持的字段:${unknown.join('、')}`);
67
- const value = { card };
68
- for (const key of CARD_FIELDS) if (fields[key]) value[key] = fields[key];
69
- // Older cards wrote the member name `platform-card` as `name` and had no `platform`.
70
- if (fields.platform) {
71
- if (fields.name && fields.name !== card) throw new Error(`agents/${card}.md 的 name 须与文件名相同:${fields.name}`);
72
- value.name = `${fields.platform}-${card}`;
73
- }
74
- if (body) value.system_prompt = body;
75
- return value;
76
- }
77
-
78
- /** Turn SKILL.md and card files into one workflow object, unchecked. */
79
- function workflow_from_files(skill_text, cards) {
80
- const { fields, body } = parse_markdown(skill_text, 'SKILL.md');
81
- return { id: fields.name || '', label: fields.label || '', description: fields.description || '', skill: body,
82
- agents: Object.entries(cards).map(([card, text]) => card_from_markdown(card, text)) };
83
- }
84
-
85
- /**
86
- * Check a whole workflow. `validate_card` is the expert preset check, shared so a card and a preset obey one rule.
87
- * Returns a normalized copy with only known fields.
88
- */
89
- export function validate_workflow(input, validate_card) {
90
- if (!input || typeof input !== 'object') throw new Error('工作流须为对象');
91
- const { id } = input;
92
- workflow_directory('.', id);
93
- const label = String(input.label || '').trim(), description = String(input.description || '').trim(), skill = String(input.skill || '').trim();
94
- if (label.length > MAX_WORKFLOW_LABEL) throw new Error(`工作流显示名最多 ${MAX_WORKFLOW_LABEL} 字`);
95
- if (description.length > MAX_WORKFLOW_DESCRIPTION || /\n/.test(description)) throw new Error(`工作流描述最多 ${MAX_WORKFLOW_DESCRIPTION} 字,且不能换行`);
96
- if (!skill) throw new Error('SKILL.md 正文不能为空');
97
- if (skill.length > MAX_WORKFLOW_SKILL) throw new Error(`SKILL.md 正文最多 ${MAX_WORKFLOW_SKILL} 字`);
98
- const agents = input.agents || [];
99
- if (!Array.isArray(agents) || agents.length > MAX_WORKFLOW_CARDS) throw new Error(`卡片须为列表,最多 ${MAX_WORKFLOW_CARDS} 张`);
100
- const cards = new Set(), names = new Set();
101
- const normalized = agents.map(agent => {
102
- // requested_access and needs_platform are an import preview's notes for the page, not card fields.
103
- const { card, requested_access: ignored_request, needs_platform: ignored_platform, ...preset } = agent || {};
104
- if (!CARD_ID_PATTERN.test(card || '')) throw new Error(`卡片 ID 只能用小写英文、数字和下划线,1—16 位:${card || '(空)'}`);
105
- if (cards.has(card.toLowerCase())) throw new Error(`卡片 ID 重复(不区分大小写):${card}`);
106
- cards.add(card.toLowerCase());
107
- // Empty fields are dropped; unknown ones stay so the preset check refuses them.
108
- const kept = Object.fromEntries(Object.entries(preset).filter(([, value]) => value !== undefined && value !== ''));
109
- try { validate_card(kept); } catch (error) { throw new Error(`卡片 ${card}:${error.message}`); }
110
- if (!kept.name?.endsWith(`-${card}`)) throw new Error(`卡片 ${card} 的成员名须为“平台-${card}”:${kept.name || '(空)'}`);
111
- if (names.has(kept.name)) throw new Error(`同一工作流内卡片名称重复:${kept.name}`);
112
- names.add(kept.name);
113
- if ((kept.description || '').includes('\n')) throw new Error(`${kept.name} 的描述不能换行`);
114
- return { card, ...kept };
115
- });
116
- return { id, label, description, skill, agents: normalized };
117
- }
118
-
119
- /** Refuse links anywhere in a folder, so a shared workflow cannot point outside itself. */
120
- function assert_no_links(directory) {
121
- if (lstatSync(directory).isSymbolicLink()) throw new Error('工作流目录不能是符号链接');
122
- for (const name of readdirSync(directory)) {
123
- const path = join(directory, name);
124
- const stat = lstatSync(path);
125
- if (stat.isSymbolicLink()) throw new Error(`工作流目录包含符号链接:${name}`);
126
- if (stat.isDirectory()) assert_no_links(path);
127
- }
128
- }
129
-
130
- /** Recover an interrupted replacement before any reader can observe a missing workflow. Called under the library lock. */
131
- function recover_workflows(home) {
132
- const root = workflows_home(home);
133
- for (const name of readdirSync(root)) {
134
- const match = BACKUP_DIRECTORY.exec(name);
135
- if (!match) continue;
136
- const backup = join(root, name), directory = workflow_directory(home, match[1]);
137
- assert_no_links(backup);
138
- if (existsSync(directory)) rmSync(backup, { recursive: true, force: true });
139
- else renameSync(backup, directory);
140
- }
141
- }
142
-
143
- /** Serialize publication, recovery and reads across processes; SQLite releases the lock even if its owner exits. */
144
- function with_workflow_lock(home, action) {
145
- mkdirSync(workflows_home(home), { recursive: true });
146
- const lock = new DatabaseSync(join(home, 'workflows-lock.sqlite3'));
147
- try {
148
- lock.exec(`PRAGMA busy_timeout=${WORKFLOW_LOCK_WAIT_MS}; BEGIN IMMEDIATE`);
149
- recover_workflows(home);
150
- return action();
151
- } finally { lock.close(); }
152
- }
153
-
154
- /** Read a workflow folder (the library's own or one being imported) without checking its content. */
155
- export function read_workflow_folder(directory) {
156
- if (!existsSync(join(directory, 'SKILL.md'))) throw new Error('文件夹里没有 SKILL.md');
157
- assert_no_links(directory);
158
- const agents = join(directory, 'agents');
159
- const cards = {};
160
- const ignored = readdirSync(directory).filter(name => !['SKILL.md', 'agents'].includes(name));
161
- if (existsSync(agents)) {
162
- for (const name of readdirSync(agents).sort()) {
163
- const match = CARD_PATH.exec(`agents/${name}`);
164
- if (match) cards[match[1]] = readFileSync(join(agents, name), 'utf8');
165
- else ignored.push(`agents/${name}`);
166
- }
167
- }
168
- return { workflow: workflow_from_files(readFileSync(join(directory, 'SKILL.md'), 'utf8'), cards), ignored };
169
- }
170
-
171
- /** Summaries for the library list; a broken folder is listed with its error instead of hiding it. */
172
- export function list_workflows(home) {
173
- return with_workflow_lock(home, () => readdirSync(workflows_home(home)).filter(name => WORKFLOW_ID_PATTERN.test(name)).sort().map(id => {
174
- try {
175
- const workflow = read_saved_workflow(home, id);
176
- return { id, label: workflow.label || id, description: workflow.description, cards: workflow.agents.length };
177
- } catch (error) { return { id, label: id, description: '', cards: 0, error: error.message }; }
178
- }));
179
- }
180
-
181
- /** Read a library entry while its caller holds the publication lock. */
182
- function read_saved_workflow(home, id) {
183
- const directory = workflow_directory(home, id);
184
- if (!existsSync(directory)) throw new Error(`工作流不存在:${id}`);
185
- const { workflow } = read_workflow_folder(directory);
186
- return { ...workflow, id, directory };
187
- }
188
-
189
- /** One workflow in full, as the editor shows it. */
190
- export function get_workflow(home, id) {
191
- return with_workflow_lock(home, () => read_saved_workflow(home, id));
192
- }
193
-
194
- /** Write a checked workflow as files, publishing a complete directory with crash recovery. */
195
- export function save_workflow(home, input, validate_card) {
196
- const workflow = validate_workflow(input, validate_card);
197
- return with_workflow_lock(home, () => {
198
- const directory = workflow_directory(home, workflow.id);
199
- if (existsSync(directory)) assert_no_links(directory);
200
- const staging = join(workflows_home(home), `.tmp-${workflow.id}-${randomUUID()}`);
201
- mkdirSync(join(staging, 'agents'), { recursive: true });
202
- writeFileSync(join(staging, 'SKILL.md'), serialize_markdown({ name: workflow.id, label: workflow.label, description: workflow.description }, workflow.skill));
203
- for (const { card, system_prompt, name, ...fields } of workflow.agents) {
204
- const platform = name.slice(0, name.length - card.length - 1);
205
- writeFileSync(join(staging, 'agents', `${card}.md`), serialize_markdown({ name: card, platform,
206
- ...Object.fromEntries(CARD_FIELDS.slice(1).map(key => [key, fields[key]])) }, system_prompt));
207
- }
208
- const old = existsSync(directory) ? join(workflows_home(home), `.old-${workflow.id}-${randomUUID()}`) : '';
209
- if (old) renameSync(directory, old);
210
- try { renameSync(staging, directory); }
211
- catch (error) { if (old) renameSync(old, directory); throw error; }
212
- if (old) rmSync(old, { recursive: true, force: true });
213
- return read_saved_workflow(home, workflow.id);
214
- });
215
- }
216
-
217
- /** Remove one workflow folder. */
218
- export function delete_workflow(home, id) {
219
- return with_workflow_lock(home, () => {
220
- const directory = workflow_directory(home, id);
221
- if (!existsSync(directory)) throw new Error(`工作流不存在:${id}`);
222
- assert_no_links(directory);
223
- rmSync(directory, { recursive: true, force: true });
224
- return { deleted: id };
225
- });
226
- }
227
-
228
- /** Lines of a pasted part, without its header; the header gives order, total and workflow. */
229
- function draft_part(text) {
230
- const lines = String(text).replace(/\r\n/g, '\n').split('\n');
231
- const headers = lines.map(line => PART_LINE.exec(line)).filter(Boolean);
232
- if (headers.length !== 1) throw new Error('每段草稿须有且只有一行 <!-- workflow-part: 序号/总数 id: 工作流ID -->');
233
- const [, index, total, id] = headers[0];
234
- return { index: Number(index), total: Number(total), id, lines: lines.filter(line => !PART_LINE.test(line)) };
235
- }
236
-
237
- /**
238
- * Parse pasted host drafts (one message each) into SKILL.md and card texts.
239
- * Separators count only as whole, unindented lines; text before the first file is ignored.
240
- */
241
- export function parse_workflow_draft(parts) {
242
- if (!Array.isArray(parts) || !parts.length || parts.length > MAX_DRAFT_PARTS) throw new Error(`草稿须为 1—${MAX_DRAFT_PARTS} 段文本`);
243
- const pieces = parts.map(draft_part).sort((a, b) => a.index - b.index);
244
- const { total, id } = pieces[0];
245
- if (pieces.some(piece => piece.total !== total || piece.id !== id)) throw new Error('各段草稿的总数或工作流 ID 不一致');
246
- const missing = Array.from({ length: total }, (_, index) => index + 1).filter(index => !pieces.some(piece => piece.index === index));
247
- if (missing.length) throw new Error(`还缺第 ${missing.join('、')} 段`);
248
- if (pieces.length !== total) throw new Error('草稿有重复的段');
249
- const files = new Map();
250
- let current = null;
251
- for (const line of pieces.flatMap(piece => piece.lines)) {
252
- const marker = FILE_LINE.exec(line);
253
- if (!marker) { current?.push(line); continue; }
254
- const path = marker[1];
255
- if (path !== 'SKILL.md' && !CARD_PATH.test(path)) throw new Error(`草稿只能包含 SKILL.md 和 agents/卡片ID.md:${path}`);
256
- if (files.has(path)) throw new Error(`草稿里 ${path} 出现了两次`);
257
- current = [];
258
- files.set(path, current);
259
- }
260
- if (!files.size) throw new Error('草稿里没有任何 <!-- workflow-file: 路径 --> 分隔行');
261
- if (!files.has('SKILL.md')) throw new Error('草稿里缺少 SKILL.md');
262
- const text = path => files.get(path).join('\n').replace(/\s+$/, '');
263
- const cards = Object.fromEntries([...files.keys()].filter(path => path !== 'SKILL.md').map(path => [CARD_PATH.exec(path)[1], text(path)]));
264
- const workflow = workflow_from_files(text('SKILL.md'), cards);
265
- if (workflow.id !== id) throw new Error(`SKILL.md 的 name(${workflow.id})与分段头的 id(${id})不一致`);
266
- return workflow;
267
- }
268
-
269
- /** Order of project access by what it allows; an imported card asking for more than reading starts at reading. */
270
- const READ_ONLY = 'read';
271
-
272
- /**
273
- * Parse an import (a folder on this computer or pasted drafts) without saving it.
274
- * Imported cards asking for project writes start read-only and carry `requested_access` for the page to offer.
275
- */
276
- export function preview_workflow(input, validate_card, known_platform) {
277
- let source;
278
- if (typeof input.folder === 'string' && input.folder) source = read_workflow_folder(resolve(input.folder));
279
- else source = { workflow: parse_workflow_draft(input.parts), ignored: [] };
280
- const workflow = validate_workflow(source.workflow, validate_card);
281
- const agents = workflow.agents.map(agent => {
282
- const requested = agent.project_access && agent.project_access !== READ_ONLY ? agent.project_access : '';
283
- return { ...agent, ...(requested ? { project_access: READ_ONLY, requested_access: requested } : {}),
284
- ...(known_platform(agent.name) ? {} : { needs_platform: true }) };
285
- });
286
- return { ...workflow, agents, ignored: source.ignored };
287
- }
288
-
289
- /**
290
- * What a room's runner needs from its enabled workflow, read fresh for each execution.
291
- * Returns null when no workflow is enabled; an unreadable one returns its error so members fall back to room settings.
292
- */
293
- export function load_room_workflow(config) {
294
- const binding = config.workflow;
295
- if (!binding?.enabled || !config.api_home) return null;
296
- try {
297
- const workflow = get_workflow(config.api_home, binding.id);
298
- const { directory } = workflow;
299
- const cards = Object.fromEntries(workflow.agents.map(agent => [agent.card, agent]));
300
- return { id: binding.id, label: workflow.label || binding.id, description: workflow.description || '', directory, skill_path: join(directory, 'SKILL.md'), skill: workflow.skill,
301
- revision: createHash('sha256').update(workflow.skill).digest('hex').slice(0, REVISION_LENGTH),
302
- members: binding.members || {}, cards };
303
- } catch (error) { return { id: binding.id, error: error.message }; }
304
- }
305
-
306
- /** The member's profile for this execution: a workflow member's prompt comes from its card as it is now. */
307
- export function effective_profile(profile, member_name, workflow) {
308
- if (!profile || !workflow || workflow.error) return profile;
309
- const card = Object.entries(workflow.members).find(([, name]) => name === member_name)?.[0];
310
- const source = card && workflow.cards[card];
311
- if (!source) return profile;
312
- const { system_prompt: ignored, ...rest } = profile;
313
- // An empty card prompt leaves the software's own preset in place, as an unset member prompt does.
314
- return source.system_prompt ? { ...rest, system_prompt: source.system_prompt } : rest;
315
- }
316
-
317
- /** The host's view of the workflow: which card is which member now, whether present, and what it may do. */
318
- export function workflow_task_field(workflow, room_members, participants) {
319
- const members = Object.entries(workflow.members).map(([card, name]) => {
320
- const member = (room_members || []).find(item => item.name === name);
321
- if (!member || member.status === 'removed') return null;
322
- const source = workflow.cards[card] || {};
323
- return { card, member: member.label || name, label: source.label || '', description: source.description || '',
324
- status: member.status, project_access: participants?.[name]?.project_access || 'read' };
325
- }).filter(Boolean);
326
- // The host reads SKILL.md only when a request fits the description and its revision is one it has not read.
327
- return { id: workflow.id, label: workflow.label, description: workflow.description || '', revision: workflow.revision || '', members };
328
- }
329
-
330
- /**
331
- * Whether a bound workflow is in use: while at least one member it brought (other than the host) is here. Sending them
332
- * away or removing them ends it; a workflow that brought no one but the host lasts while it is bound.
333
- */
334
- export function workflow_in_use(workflow, room_members) {
335
- const host = (room_members || []).find(member => member.role === 'host')?.name;
336
- const brought = Object.values(workflow.members || {}).filter(name => name !== host);
337
- return !brought.length || (room_members || []).some(member => brought.includes(member.name) && member.status === 'active');
338
- }
339
-
340
- /** Write a room's workflow binding into its configuration object (the caller saves it). */
341
- export function bind_room_workflow(config, { id, enabled, members }) {
342
- workflow_directory('.', id);
343
- if (members && (typeof members !== 'object' || Array.isArray(members)
344
- || Object.entries(members).some(([card, name]) => !CARD_ID_PATTERN.test(card) || typeof name !== 'string' || !name))) {
345
- throw new Error('workflow.members 须为 {卡片ID: 成员名}');
346
- }
347
- return { ...config, workflow: { id, enabled: Boolean(enabled), members: { ...(config.workflow?.id === id ? config.workflow.members : {}), ...(members || {}) } } };
348
- }
1
+ /**
2
+ * Room workflows: a host's SKILL.md plus a set of agent cards, kept as a shareable folder in the service home.
3
+ * The program only stores, previews and loads them; the host follows SKILL.md on its own judgment.
4
+ */
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { join, resolve } from 'node:path';
8
+ import { DatabaseSync } from 'node:sqlite';
9
+
10
+ // A workflow ID follows the Agent Skills naming rule: lowercase letters and digits joined by single hyphens.
11
+ export const WORKFLOW_ID_PATTERN = /^(?=.{1,40}$)[a-z0-9]+(?:-[a-z0-9]+)*$/;
12
+ // Characters of a SKILL.md hash the host compares to know whether its earlier reading is still current.
13
+ const REVISION_LENGTH = 12;
14
+ // A card ID becomes the member name's suffix (`platform-card`), which has no hyphen of its own.
15
+ export const CARD_ID_PATTERN = /^[a-z0-9_]{1,16}$/;
16
+ export const MAX_WORKFLOW_SKILL = 20000;
17
+ export const MAX_WORKFLOW_CARDS = 30;
18
+ const MAX_WORKFLOW_LABEL = 32;
19
+ const MAX_WORKFLOW_DESCRIPTION = 500;
20
+ const MAX_DRAFT_PARTS = 20;
21
+ const WORKFLOW_LOCK_WAIT_MS = 5000;
22
+ const BACKUP_DIRECTORY = /^\.old-([a-z0-9_-]{1,40})-[0-9a-f-]{36}$/;
23
+ // A card file is shaped like an agent definition: `name` is the card ID (its file name) and `platform` the software.
24
+ const CARD_FIELDS = ['name', 'label', 'description', 'project_access', 'model', 'effort'];
25
+ const CARD_FILE_FIELDS = ['name', 'platform', 'label', 'description', 'project_access', 'model', 'effort'];
26
+ const PART_LINE = /^<!-- workflow-part: (\d+)\/(\d+) id: ([a-z0-9_-]{1,40}) -->$/;
27
+ const FILE_LINE = /^<!-- workflow-file: (\S+) -->$/;
28
+ const CARD_PATH = /^agents\/([A-Za-z0-9_]{1,16})\.md$/;
29
+
30
+ /** The folder holding every workflow of this computer. */
31
+ export function workflows_home(home) { return join(home, 'workflows'); }
32
+
33
+ /** A workflow's folder, built from a checked ID and never from a caller's path. */
34
+ // Built-in room modes own these "/" commands, so no workflow may take their names.
35
+ export const RESERVED_WORKFLOW_IDS = ['goal', 'discuss'];
36
+
37
+ export function workflow_directory(home, id) {
38
+ if (!WORKFLOW_ID_PATTERN.test(id || '')) throw new Error('工作流 ID 只能用小写英文和数字,可用单个连字符连接(不在首尾),1—40 位');
39
+ return join(workflows_home(home), id);
40
+ }
41
+
42
+ /** Split single-line `key: value` frontmatter from a Markdown body. */
43
+ export function parse_markdown(text, label) {
44
+ const lines = String(text).replace(/^/, '').replace(/\r\n/g, '\n').split('\n');
45
+ if (lines[0] !== '---') throw new Error(`${label} 须以 --- 开头的头信息开始`);
46
+ const end = lines.indexOf('---', 1);
47
+ if (end < 0) throw new Error(`${label} 的头信息缺少结尾 ---`);
48
+ const fields = {};
49
+ for (const line of lines.slice(1, end)) {
50
+ if (!line.trim()) continue;
51
+ const colon = line.indexOf(':');
52
+ if (colon < 1) throw new Error(`${label} 的头信息只支持单行 key: value:${line}`);
53
+ // Quotes around a value are YAML's, not part of it.
54
+ fields[line.slice(0, colon).trim()] = line.slice(colon + 1).trim().replace(/^(["'])(.*)\1$/, '$2');
55
+ }
56
+ return { fields, body: lines.slice(end + 1).join('\n').trim() };
57
+ }
58
+
59
+ /** Write fields back as single-line frontmatter; empty fields are left out. */
60
+ export function serialize_markdown(fields, body) {
61
+ const header = Object.entries(fields).filter(([, value]) => value !== undefined && value !== '').map(([key, value]) => `${key}: ${String(value ?? '').replace(/\s*\n\s*/g, ' ')}`.trimEnd());
62
+ return `---\n${header.join('\n')}\n---\n${body ? `${body.trim()}\n` : ''}`;
63
+ }
64
+
65
+ /** Turn one parsed card file into the card shape the page edits. */
66
+ function card_from_markdown(card, text) {
67
+ const { fields, body } = parse_markdown(text, `agents/${card}.md`);
68
+ const unknown = Object.keys(fields).filter(key => !CARD_FILE_FIELDS.includes(key));
69
+ if (unknown.length) throw new Error(`agents/${card}.md 含不支持的字段:${unknown.join('、')}`);
70
+ const value = { card };
71
+ for (const key of CARD_FIELDS) if (fields[key]) value[key] = fields[key];
72
+ // Older cards wrote the member name `platform-card` as `name` and had no `platform`.
73
+ if (fields.platform) {
74
+ if (fields.name && fields.name !== card) throw new Error(`agents/${card}.md 的 name 须与文件名相同:${fields.name}`);
75
+ value.name = `${fields.platform}-${card}`;
76
+ }
77
+ if (body) value.system_prompt = body;
78
+ return value;
79
+ }
80
+
81
+ /** Turn SKILL.md and card files into one workflow object, unchecked. */
82
+ function workflow_from_files(skill_text, cards) {
83
+ const { fields, body } = parse_markdown(skill_text, 'SKILL.md');
84
+ return { id: fields.name || '', label: fields.label || '', description: fields.description || '', skill: body,
85
+ agents: Object.entries(cards).map(([card, text]) => card_from_markdown(card, text)) };
86
+ }
87
+
88
+ /**
89
+ * Check a whole workflow. `validate_card` is the expert preset check, shared so a card and a preset obey one rule.
90
+ * Returns a normalized copy with only known fields.
91
+ */
92
+ export function validate_workflow(input, validate_card) {
93
+ if (!input || typeof input !== 'object') throw new Error('工作流须为对象');
94
+ const { id } = input;
95
+ workflow_directory('.', id);
96
+ // Checked only when saving: a workflow saved earlier under one of these names can still be read and deleted.
97
+ if (RESERVED_WORKFLOW_IDS.includes(id)) throw new Error(`工作流 ID 不能用 ${id}:它是内置模式 /${id} 的命令`);
98
+ const label = String(input.label || '').trim(), description = String(input.description || '').trim(), skill = String(input.skill || '').trim();
99
+ if (label.length > MAX_WORKFLOW_LABEL) throw new Error(`工作流显示名最多 ${MAX_WORKFLOW_LABEL} 字`);
100
+ if (description.length > MAX_WORKFLOW_DESCRIPTION || /\n/.test(description)) throw new Error(`工作流描述最多 ${MAX_WORKFLOW_DESCRIPTION} 字,且不能换行`);
101
+ if (!skill) throw new Error('SKILL.md 正文不能为空');
102
+ if (skill.length > MAX_WORKFLOW_SKILL) throw new Error(`SKILL.md 正文最多 ${MAX_WORKFLOW_SKILL} 字`);
103
+ const agents = input.agents || [];
104
+ if (!Array.isArray(agents) || agents.length > MAX_WORKFLOW_CARDS) throw new Error(`卡片须为列表,最多 ${MAX_WORKFLOW_CARDS} 张`);
105
+ const cards = new Set(), names = new Set();
106
+ const normalized = agents.map(agent => {
107
+ // requested_access and needs_platform are an import preview's notes for the page, not card fields.
108
+ const { card, requested_access: ignored_request, needs_platform: ignored_platform, ...preset } = agent || {};
109
+ if (!CARD_ID_PATTERN.test(card || '')) throw new Error(`卡片 ID 只能用小写英文、数字和下划线,1—16 位:${card || '(空)'}`);
110
+ if (cards.has(card.toLowerCase())) throw new Error(`卡片 ID 重复(不区分大小写):${card}`);
111
+ cards.add(card.toLowerCase());
112
+ // Empty fields are dropped; unknown ones stay so the preset check refuses them.
113
+ const kept = Object.fromEntries(Object.entries(preset).filter(([, value]) => value !== undefined && value !== ''));
114
+ try { validate_card(kept); } catch (error) { throw new Error(`卡片 ${card}:${error.message}`); }
115
+ if (!kept.name?.endsWith(`-${card}`)) throw new Error(`卡片 ${card} 的成员名须为“平台-${card}”:${kept.name || '(空)'}`);
116
+ if (names.has(kept.name)) throw new Error(`同一工作流内卡片名称重复:${kept.name}`);
117
+ names.add(kept.name);
118
+ if ((kept.description || '').includes('\n')) throw new Error(`${kept.name} 的描述不能换行`);
119
+ return { card, ...kept };
120
+ });
121
+ return { id, label, description, skill, agents: normalized };
122
+ }
123
+
124
+ /** Refuse links anywhere in a folder, so a shared workflow cannot point outside itself. */
125
+ function assert_no_links(directory) {
126
+ if (lstatSync(directory).isSymbolicLink()) throw new Error('工作流目录不能是符号链接');
127
+ for (const name of readdirSync(directory)) {
128
+ const path = join(directory, name);
129
+ const stat = lstatSync(path);
130
+ if (stat.isSymbolicLink()) throw new Error(`工作流目录包含符号链接:${name}`);
131
+ if (stat.isDirectory()) assert_no_links(path);
132
+ }
133
+ }
134
+
135
+ /** Recover an interrupted replacement before any reader can observe a missing workflow. Called under the library lock. */
136
+ function recover_workflows(home) {
137
+ const root = workflows_home(home);
138
+ for (const name of readdirSync(root)) {
139
+ const match = BACKUP_DIRECTORY.exec(name);
140
+ if (!match) continue;
141
+ const backup = join(root, name), directory = workflow_directory(home, match[1]);
142
+ assert_no_links(backup);
143
+ if (existsSync(directory)) rmSync(backup, { recursive: true, force: true });
144
+ else renameSync(backup, directory);
145
+ }
146
+ }
147
+
148
+ /** Serialize publication, recovery and reads across processes; SQLite releases the lock even if its owner exits. */
149
+ function with_workflow_lock(home, action) {
150
+ mkdirSync(workflows_home(home), { recursive: true });
151
+ const lock = new DatabaseSync(join(home, 'workflows-lock.sqlite3'));
152
+ try {
153
+ lock.exec(`PRAGMA busy_timeout=${WORKFLOW_LOCK_WAIT_MS}; BEGIN IMMEDIATE`);
154
+ recover_workflows(home);
155
+ return action();
156
+ } finally { lock.close(); }
157
+ }
158
+
159
+ /** Read a workflow folder (the library's own or one being imported) without checking its content. */
160
+ export function read_workflow_folder(directory) {
161
+ if (!existsSync(join(directory, 'SKILL.md'))) throw new Error('文件夹里没有 SKILL.md');
162
+ assert_no_links(directory);
163
+ const agents = join(directory, 'agents');
164
+ const cards = {};
165
+ const ignored = readdirSync(directory).filter(name => !['SKILL.md', 'agents'].includes(name));
166
+ if (existsSync(agents)) {
167
+ for (const name of readdirSync(agents).sort()) {
168
+ const match = CARD_PATH.exec(`agents/${name}`);
169
+ if (match) cards[match[1]] = readFileSync(join(agents, name), 'utf8');
170
+ else ignored.push(`agents/${name}`);
171
+ }
172
+ }
173
+ return { workflow: workflow_from_files(readFileSync(join(directory, 'SKILL.md'), 'utf8'), cards), ignored };
174
+ }
175
+
176
+ /**
177
+ * Read a workflow sent as files (a zip or folder the page opened): `{ "SKILL.md": text, "agents/<card>.md": text, other: "" }`,
178
+ * paths relative to the folder holding SKILL.md. Other paths are only named back as ignored.
179
+ */
180
+ export function read_workflow_files(files) {
181
+ if (!files || typeof files !== 'object' || Array.isArray(files)) throw new Error('工作流文件须为 { 路径: 文本 }');
182
+ if (typeof files['SKILL.md'] !== 'string') throw new Error('没有 SKILL.md');
183
+ const cards = {}, ignored = [];
184
+ for (const path of Object.keys(files).sort()) {
185
+ if (path === 'SKILL.md') continue;
186
+ const match = CARD_PATH.exec(path);
187
+ if (match && typeof files[path] === 'string') cards[match[1]] = files[path];
188
+ else ignored.push(path);
189
+ }
190
+ return { workflow: workflow_from_files(files['SKILL.md'], cards), ignored };
191
+ }
192
+
193
+ /** Summaries for the library list; a broken folder is listed with its error instead of hiding it. */
194
+ export function list_workflows(home) {
195
+ return with_workflow_lock(home, () => readdirSync(workflows_home(home)).filter(name => WORKFLOW_ID_PATTERN.test(name)).sort().map(id => {
196
+ try {
197
+ const workflow = read_saved_workflow(home, id);
198
+ return { id, label: workflow.label || id, description: workflow.description, cards: workflow.agents.length };
199
+ } catch (error) { return { id, label: id, description: '', cards: 0, error: error.message }; }
200
+ }));
201
+ }
202
+
203
+ /** Read a library entry while its caller holds the publication lock. */
204
+ function read_saved_workflow(home, id) {
205
+ const directory = workflow_directory(home, id);
206
+ if (!existsSync(directory)) throw new Error(`工作流不存在:${id}`);
207
+ const { workflow } = read_workflow_folder(directory);
208
+ return { ...workflow, id, directory };
209
+ }
210
+
211
+ /** One workflow in full, as the editor shows it. */
212
+ export function get_workflow(home, id) {
213
+ return with_workflow_lock(home, () => read_saved_workflow(home, id));
214
+ }
215
+
216
+ /** Write a checked workflow as files, publishing a complete directory with crash recovery. */
217
+ export function save_workflow(home, input, validate_card) {
218
+ const workflow = validate_workflow(input, validate_card);
219
+ return with_workflow_lock(home, () => {
220
+ const directory = workflow_directory(home, workflow.id);
221
+ if (existsSync(directory)) assert_no_links(directory);
222
+ const staging = join(workflows_home(home), `.tmp-${workflow.id}-${randomUUID()}`);
223
+ mkdirSync(join(staging, 'agents'), { recursive: true });
224
+ writeFileSync(join(staging, 'SKILL.md'), serialize_markdown({ name: workflow.id, label: workflow.label, description: workflow.description }, workflow.skill));
225
+ for (const { card, system_prompt, name, ...fields } of workflow.agents) {
226
+ const platform = name.slice(0, name.length - card.length - 1);
227
+ writeFileSync(join(staging, 'agents', `${card}.md`), serialize_markdown({ name: card, platform,
228
+ ...Object.fromEntries(CARD_FIELDS.slice(1).map(key => [key, fields[key]])) }, system_prompt));
229
+ }
230
+ const old = existsSync(directory) ? join(workflows_home(home), `.old-${workflow.id}-${randomUUID()}`) : '';
231
+ if (old) renameSync(directory, old);
232
+ try { renameSync(staging, directory); }
233
+ catch (error) { if (old) renameSync(old, directory); throw error; }
234
+ if (old) rmSync(old, { recursive: true, force: true });
235
+ return read_saved_workflow(home, workflow.id);
236
+ });
237
+ }
238
+
239
+ /** Remove one workflow folder. */
240
+ export function delete_workflow(home, id) {
241
+ return with_workflow_lock(home, () => {
242
+ const directory = workflow_directory(home, id);
243
+ if (!existsSync(directory)) throw new Error(`工作流不存在:${id}`);
244
+ assert_no_links(directory);
245
+ rmSync(directory, { recursive: true, force: true });
246
+ return { deleted: id };
247
+ });
248
+ }
249
+
250
+ /** Lines of a pasted part, without its header; the header gives order, total and workflow. */
251
+ function draft_part(text) {
252
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
253
+ const headers = lines.map(line => PART_LINE.exec(line)).filter(Boolean);
254
+ if (headers.length !== 1) throw new Error('每段草稿须有且只有一行 <!-- workflow-part: 序号/总数 id: 工作流ID -->');
255
+ const [, index, total, id] = headers[0];
256
+ return { index: Number(index), total: Number(total), id, lines: lines.filter(line => !PART_LINE.test(line)) };
257
+ }
258
+
259
+ /**
260
+ * Parse pasted host drafts (one message each) into SKILL.md and card texts.
261
+ * Separators count only as whole, unindented lines; text before the first file is ignored.
262
+ */
263
+ export function parse_workflow_draft(parts) {
264
+ if (!Array.isArray(parts) || !parts.length || parts.length > MAX_DRAFT_PARTS) throw new Error(`草稿须为 1—${MAX_DRAFT_PARTS} 段文本`);
265
+ const pieces = parts.map(draft_part).sort((a, b) => a.index - b.index);
266
+ const { total, id } = pieces[0];
267
+ if (pieces.some(piece => piece.total !== total || piece.id !== id)) throw new Error('各段草稿的总数或工作流 ID 不一致');
268
+ const missing = Array.from({ length: total }, (_, index) => index + 1).filter(index => !pieces.some(piece => piece.index === index));
269
+ if (missing.length) throw new Error(`还缺第 ${missing.join('、')} 段`);
270
+ if (pieces.length !== total) throw new Error('草稿有重复的段');
271
+ const files = new Map();
272
+ let current = null;
273
+ for (const line of pieces.flatMap(piece => piece.lines)) {
274
+ const marker = FILE_LINE.exec(line);
275
+ if (!marker) { current?.push(line); continue; }
276
+ const path = marker[1];
277
+ if (path !== 'SKILL.md' && !CARD_PATH.test(path)) throw new Error(`草稿只能包含 SKILL.md 和 agents/卡片ID.md:${path}`);
278
+ if (files.has(path)) throw new Error(`草稿里 ${path} 出现了两次`);
279
+ current = [];
280
+ files.set(path, current);
281
+ }
282
+ if (!files.size) throw new Error('草稿里没有任何 <!-- workflow-file: 路径 --> 分隔行');
283
+ if (!files.has('SKILL.md')) throw new Error('草稿里缺少 SKILL.md');
284
+ const text = path => files.get(path).join('\n').replace(/\s+$/, '');
285
+ const cards = Object.fromEntries([...files.keys()].filter(path => path !== 'SKILL.md').map(path => [CARD_PATH.exec(path)[1], text(path)]));
286
+ const workflow = workflow_from_files(text('SKILL.md'), cards);
287
+ if (workflow.id !== id) throw new Error(`SKILL.md 的 name(${workflow.id})与分段头的 id(${id})不一致`);
288
+ return workflow;
289
+ }
290
+
291
+ /** Order of project access by what it allows; an imported card asking for more than reading starts at reading. */
292
+ const READ_ONLY = 'read';
293
+
294
+ /**
295
+ * Parse an import (files sent by the page, a folder on this computer or a draft) without saving it.
296
+ * Imported cards asking for project writes start read-only and carry `requested_access` for the page to offer.
297
+ */
298
+ export function preview_workflow(input, validate_card, known_platform) {
299
+ let source;
300
+ if (input.files) source = read_workflow_files(input.files);
301
+ else if (typeof input.folder === 'string' && input.folder) source = read_workflow_folder(resolve(input.folder));
302
+ else source = { workflow: parse_workflow_draft(input.parts), ignored: [] };
303
+ const workflow = validate_workflow(source.workflow, validate_card);
304
+ const agents = workflow.agents.map(agent => {
305
+ const requested = agent.project_access && agent.project_access !== READ_ONLY ? agent.project_access : '';
306
+ return { ...agent, ...(requested ? { project_access: READ_ONLY, requested_access: requested } : {}),
307
+ ...(known_platform(agent.name) ? {} : { needs_platform: true }) };
308
+ });
309
+ return { ...workflow, agents, ignored: source.ignored };
310
+ }
311
+
312
+ /**
313
+ * What a room's runner needs from its enabled workflow, read fresh for each execution.
314
+ * Returns null when no workflow is enabled; an unreadable one returns its error so members fall back to room settings.
315
+ */
316
+ export function load_room_workflow(config) {
317
+ const binding = config.workflow;
318
+ if (!binding?.enabled || !config.api_home) return null;
319
+ try {
320
+ const workflow = get_workflow(config.api_home, binding.id);
321
+ const { directory } = workflow;
322
+ const cards = Object.fromEntries(workflow.agents.map(agent => [agent.card, agent]));
323
+ return { id: binding.id, label: workflow.label || binding.id, description: workflow.description || '', directory, skill_path: join(directory, 'SKILL.md'), skill: workflow.skill,
324
+ revision: createHash('sha256').update(workflow.skill).digest('hex').slice(0, REVISION_LENGTH),
325
+ members: binding.members || {}, cards };
326
+ } catch (error) { return { id: binding.id, error: error.message }; }
327
+ }
328
+
329
+ /** The member's profile for this execution: a workflow member's prompt comes from its card as it is now. */
330
+ export function effective_profile(profile, member_name, workflow) {
331
+ if (!profile || !workflow || workflow.error) return profile;
332
+ const card = Object.entries(workflow.members).find(([, name]) => name === member_name)?.[0];
333
+ const source = card && workflow.cards[card];
334
+ if (!source) return profile;
335
+ const { system_prompt: ignored, ...rest } = profile;
336
+ // An empty card prompt leaves the software's own preset in place, as an unset member prompt does.
337
+ return source.system_prompt ? { ...rest, system_prompt: source.system_prompt } : rest;
338
+ }
339
+
340
+ /** The host's view of the workflow: which card is which member now, whether present, and what it may do. */
341
+ export function workflow_task_field(workflow, room_members, participants) {
342
+ const members = Object.entries(workflow.members).map(([card, name]) => {
343
+ const member = (room_members || []).find(item => item.name === name);
344
+ if (!member || member.status === 'removed') return null;
345
+ const source = workflow.cards[card] || {};
346
+ return { card, member: member.label || name, label: source.label || '', description: source.description || '',
347
+ status: member.status, project_access: participants?.[name]?.project_access || 'read' };
348
+ }).filter(Boolean);
349
+ // The host reads SKILL.md only when a request fits the description and its revision is one it has not read.
350
+ return { id: workflow.id, label: workflow.label, description: workflow.description || '', revision: workflow.revision || '', members };
351
+ }
352
+
353
+ /**
354
+ * Whether a bound workflow is in use: while at least one member it brought (other than the host) is here. Sending them
355
+ * away or removing them ends it; a workflow that brought no one but the host lasts while it is bound.
356
+ */
357
+ export function workflow_in_use(workflow, room_members) {
358
+ const host = (room_members || []).find(member => member.role === 'host')?.name;
359
+ const brought = Object.values(workflow.members || {}).filter(name => name !== host);
360
+ return !brought.length || (room_members || []).some(member => brought.includes(member.name) && member.status === 'active');
361
+ }
362
+
363
+ /** Write a room's workflow binding into its configuration object (the caller saves it). */
364
+ export function bind_room_workflow(config, { id, enabled, members }) {
365
+ workflow_directory('.', id);
366
+ if (members && (typeof members !== 'object' || Array.isArray(members)
367
+ || Object.entries(members).some(([card, name]) => !CARD_ID_PATTERN.test(card) || typeof name !== 'string' || !name))) {
368
+ throw new Error('workflow.members 须为 {卡片ID: 成员名}');
369
+ }
370
+ return { ...config, workflow: { id, enabled: Boolean(enabled), members: { ...(config.workflow?.id === id ? config.workflow.members : {}), ...(members || {}) } } };
371
+ }