ai_co_discuss 0.3.39 → 0.3.40

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.
@@ -27,9 +27,9 @@ const RELAY_CIPHER_INFO = 'codiscuss-relay-v1';
27
27
  const LAUNCH_SCRIPT = fileURLToPath(new URL('./discussion_launch.mjs', import.meta.url));
28
28
  // What a connected browser may ask the local bridge for, minus the folder dialog nobody would see and pairing itself.
29
29
  const RELAYED_ACTIONS = new Set(['settings', 'launch', 'room-models', 'room-member', 'room-member-access', 'room-member-allow', 'room-member-adapter',
30
- 'room-member-model', 'room-profiles', 'room-files', 'room-restore', 'room-fork', 'models', 'api-key', 'api-check', 'scheduler-check', 'tts-settings', 'tts', 'stt', 'projects', 'presets', 'admin-account']);
30
+ 'room-member-model', 'room-profiles', 'room-files', 'room-restore', 'room-fork', 'models', 'api-key', 'api-check', 'scheduler-check', 'tts-settings', 'tts', 'stt', 'projects', 'presets', 'workflows', 'room-workflow', 'admin-account']);
31
31
  export const ROOM_ACTIONS = new Set(['launch', 'room-models', 'room-member', 'room-member-access', 'room-member-allow', 'room-member-adapter',
32
- 'room-member-model', 'room-profiles', 'room-files', 'room-restore', 'room-fork']);
32
+ 'room-member-model', 'room-profiles', 'room-files', 'room-restore', 'room-fork', 'room-workflow', 'workflows']);
33
33
 
34
34
  /** Read every site this computer is paired with. */
35
35
  export function machine_links(home) {
@@ -174,6 +174,8 @@ export function relayed_command(site, action, payload) {
174
174
  const { access_file, ...rest } = payload;
175
175
  input = { ...rest, site, entry: 'web' };
176
176
  }
177
+ // Choosing a folder belongs to the computer's own screen; another device imports by pasting.
178
+ if (action === 'workflows' && payload.folder) throw new Error('其他设备请用粘贴导入工作流');
177
179
  if (action === 'admin-account') input = { site, username: payload.username, password: payload.password };
178
180
  return { command: action === 'launch' ? 'start' : action, input, browser: action === 'launch' };
179
181
  }
@@ -0,0 +1,321 @@
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 { 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
+ export const WORKFLOW_ID_PATTERN = /^[a-z0-9_-]{1,40}$/;
11
+ export const CARD_ID_PATTERN = /^[A-Za-z0-9_]{1,16}$/;
12
+ export const MAX_WORKFLOW_SKILL = 20000;
13
+ export const MAX_WORKFLOW_CARDS = 30;
14
+ const MAX_WORKFLOW_LABEL = 32;
15
+ const MAX_WORKFLOW_DESCRIPTION = 500;
16
+ const MAX_DRAFT_PARTS = 20;
17
+ const WORKFLOW_LOCK_WAIT_MS = 5000;
18
+ const BACKUP_DIRECTORY = /^\.old-([a-z0-9_-]{1,40})-[0-9a-f-]{36}$/;
19
+ const CARD_FIELDS = ['name', 'label', 'description', 'project_access', 'model', 'effort'];
20
+ const PART_LINE = /^<!-- workflow-part: (\d+)\/(\d+) id: ([a-z0-9_-]{1,40}) -->$/;
21
+ const FILE_LINE = /^<!-- workflow-file: (\S+) -->$/;
22
+ const CARD_PATH = /^agents\/([A-Za-z0-9_]{1,16})\.md$/;
23
+
24
+ /** The folder holding every workflow of this computer. */
25
+ export function workflows_home(home) { return join(home, 'workflows'); }
26
+
27
+ /** A workflow's folder, built from a checked ID and never from a caller's path. */
28
+ export function workflow_directory(home, id) {
29
+ if (!WORKFLOW_ID_PATTERN.test(id || '')) throw new Error('工作流 ID 只能用小写英文、数字、下划线和连字符,1—40 位');
30
+ return join(workflows_home(home), id);
31
+ }
32
+
33
+ /** Split single-line `key: value` frontmatter from a Markdown body. */
34
+ export function parse_markdown(text, label) {
35
+ const lines = String(text).replace(/^/, '').replace(/\r\n/g, '\n').split('\n');
36
+ if (lines[0] !== '---') throw new Error(`${label} 须以 --- 开头的头信息开始`);
37
+ const end = lines.indexOf('---', 1);
38
+ if (end < 0) throw new Error(`${label} 的头信息缺少结尾 ---`);
39
+ const fields = {};
40
+ for (const line of lines.slice(1, end)) {
41
+ if (!line.trim()) continue;
42
+ const colon = line.indexOf(':');
43
+ if (colon < 1) throw new Error(`${label} 的头信息只支持单行 key: value:${line}`);
44
+ fields[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
45
+ }
46
+ return { fields, body: lines.slice(end + 1).join('\n').trim() };
47
+ }
48
+
49
+ /** Write fields back as single-line frontmatter; empty values stay so the file shows every field. */
50
+ export function serialize_markdown(fields, body) {
51
+ const header = Object.entries(fields).map(([key, value]) => `${key}: ${String(value ?? '').replace(/\s*\n\s*/g, ' ')}`.trimEnd());
52
+ return `---\n${header.join('\n')}\n---\n${body ? `${body.trim()}\n` : ''}`;
53
+ }
54
+
55
+ /** Turn one parsed card file into the card shape the page edits. */
56
+ function card_from_markdown(card, text) {
57
+ const { fields, body } = parse_markdown(text, `agents/${card}.md`);
58
+ const unknown = Object.keys(fields).filter(key => !CARD_FIELDS.includes(key));
59
+ if (unknown.length) throw new Error(`agents/${card}.md 含不支持的字段:${unknown.join('、')}`);
60
+ const value = { card };
61
+ for (const key of CARD_FIELDS) if (fields[key]) value[key] = fields[key];
62
+ if (body) value.system_prompt = body;
63
+ return value;
64
+ }
65
+
66
+ /** Turn SKILL.md and card files into one workflow object, unchecked. */
67
+ function workflow_from_files(skill_text, cards) {
68
+ const { fields, body } = parse_markdown(skill_text, 'SKILL.md');
69
+ return { id: fields.name || '', label: fields.label || '', description: fields.description || '', skill: body,
70
+ agents: Object.entries(cards).map(([card, text]) => card_from_markdown(card, text)) };
71
+ }
72
+
73
+ /**
74
+ * Check a whole workflow. `validate_card` is the expert preset check, shared so a card and a preset obey one rule.
75
+ * Returns a normalized copy with only known fields.
76
+ */
77
+ export function validate_workflow(input, validate_card) {
78
+ if (!input || typeof input !== 'object') throw new Error('工作流须为对象');
79
+ const { id } = input;
80
+ workflow_directory('.', id);
81
+ const label = String(input.label || '').trim(), description = String(input.description || '').trim(), skill = String(input.skill || '').trim();
82
+ if (label.length > MAX_WORKFLOW_LABEL) throw new Error(`工作流显示名最多 ${MAX_WORKFLOW_LABEL} 字`);
83
+ if (description.length > MAX_WORKFLOW_DESCRIPTION || /\n/.test(description)) throw new Error(`工作流描述最多 ${MAX_WORKFLOW_DESCRIPTION} 字,且不能换行`);
84
+ if (!skill) throw new Error('SKILL.md 正文不能为空');
85
+ if (skill.length > MAX_WORKFLOW_SKILL) throw new Error(`SKILL.md 正文最多 ${MAX_WORKFLOW_SKILL} 字`);
86
+ const agents = input.agents || [];
87
+ if (!Array.isArray(agents) || agents.length > MAX_WORKFLOW_CARDS) throw new Error(`卡片须为列表,最多 ${MAX_WORKFLOW_CARDS} 张`);
88
+ const cards = new Set(), names = new Set();
89
+ const normalized = agents.map(agent => {
90
+ // requested_access and needs_platform are an import preview's notes for the page, not card fields.
91
+ const { card, requested_access: ignored_request, needs_platform: ignored_platform, ...preset } = agent || {};
92
+ if (!CARD_ID_PATTERN.test(card || '')) throw new Error(`卡片 ID 只能用英文、数字和下划线,1—16 位:${card || '(空)'}`);
93
+ if (cards.has(card.toLowerCase())) throw new Error(`卡片 ID 重复(不区分大小写):${card}`);
94
+ cards.add(card.toLowerCase());
95
+ // Empty fields are dropped; unknown ones stay so the preset check refuses them.
96
+ const kept = Object.fromEntries(Object.entries(preset).filter(([, value]) => value !== undefined && value !== ''));
97
+ validate_card(kept);
98
+ if (names.has(kept.name)) throw new Error(`同一工作流内卡片名称重复:${kept.name}`);
99
+ names.add(kept.name);
100
+ if ((kept.description || '').includes('\n')) throw new Error(`${kept.name} 的描述不能换行`);
101
+ return { card, ...kept };
102
+ });
103
+ return { id, label, description, skill, agents: normalized };
104
+ }
105
+
106
+ /** Refuse links anywhere in a folder, so a shared workflow cannot point outside itself. */
107
+ function assert_no_links(directory) {
108
+ if (lstatSync(directory).isSymbolicLink()) throw new Error('工作流目录不能是符号链接');
109
+ for (const name of readdirSync(directory)) {
110
+ const path = join(directory, name);
111
+ const stat = lstatSync(path);
112
+ if (stat.isSymbolicLink()) throw new Error(`工作流目录包含符号链接:${name}`);
113
+ if (stat.isDirectory()) assert_no_links(path);
114
+ }
115
+ }
116
+
117
+ /** Recover an interrupted replacement before any reader can observe a missing workflow. Called under the library lock. */
118
+ function recover_workflows(home) {
119
+ const root = workflows_home(home);
120
+ for (const name of readdirSync(root)) {
121
+ const match = BACKUP_DIRECTORY.exec(name);
122
+ if (!match) continue;
123
+ const backup = join(root, name), directory = workflow_directory(home, match[1]);
124
+ assert_no_links(backup);
125
+ if (existsSync(directory)) rmSync(backup, { recursive: true, force: true });
126
+ else renameSync(backup, directory);
127
+ }
128
+ }
129
+
130
+ /** Serialize publication, recovery and reads across processes; SQLite releases the lock even if its owner exits. */
131
+ function with_workflow_lock(home, action) {
132
+ mkdirSync(workflows_home(home), { recursive: true });
133
+ const lock = new DatabaseSync(join(home, 'workflows-lock.sqlite3'));
134
+ try {
135
+ lock.exec(`PRAGMA busy_timeout=${WORKFLOW_LOCK_WAIT_MS}; BEGIN IMMEDIATE`);
136
+ recover_workflows(home);
137
+ return action();
138
+ } finally { lock.close(); }
139
+ }
140
+
141
+ /** Read a workflow folder (the library's own or one being imported) without checking its content. */
142
+ export function read_workflow_folder(directory) {
143
+ if (!existsSync(join(directory, 'SKILL.md'))) throw new Error('文件夹里没有 SKILL.md');
144
+ assert_no_links(directory);
145
+ const agents = join(directory, 'agents');
146
+ const cards = {};
147
+ const ignored = readdirSync(directory).filter(name => !['SKILL.md', 'agents'].includes(name));
148
+ if (existsSync(agents)) {
149
+ for (const name of readdirSync(agents).sort()) {
150
+ const match = CARD_PATH.exec(`agents/${name}`);
151
+ if (match) cards[match[1]] = readFileSync(join(agents, name), 'utf8');
152
+ else ignored.push(`agents/${name}`);
153
+ }
154
+ }
155
+ return { workflow: workflow_from_files(readFileSync(join(directory, 'SKILL.md'), 'utf8'), cards), ignored };
156
+ }
157
+
158
+ /** Summaries for the library list; a broken folder is listed with its error instead of hiding it. */
159
+ export function list_workflows(home) {
160
+ return with_workflow_lock(home, () => readdirSync(workflows_home(home)).filter(name => WORKFLOW_ID_PATTERN.test(name)).sort().map(id => {
161
+ try {
162
+ const workflow = read_saved_workflow(home, id);
163
+ return { id, label: workflow.label || id, description: workflow.description, cards: workflow.agents.length };
164
+ } catch (error) { return { id, label: id, description: '', cards: 0, error: error.message }; }
165
+ }));
166
+ }
167
+
168
+ /** Read a library entry while its caller holds the publication lock. */
169
+ function read_saved_workflow(home, id) {
170
+ const directory = workflow_directory(home, id);
171
+ if (!existsSync(directory)) throw new Error(`工作流不存在:${id}`);
172
+ const { workflow } = read_workflow_folder(directory);
173
+ return { ...workflow, id, directory };
174
+ }
175
+
176
+ /** One workflow in full, as the editor shows it. */
177
+ export function get_workflow(home, id) {
178
+ return with_workflow_lock(home, () => read_saved_workflow(home, id));
179
+ }
180
+
181
+ /** Write a checked workflow as files, publishing a complete directory with crash recovery. */
182
+ export function save_workflow(home, input, validate_card) {
183
+ const workflow = validate_workflow(input, validate_card);
184
+ return with_workflow_lock(home, () => {
185
+ const directory = workflow_directory(home, workflow.id);
186
+ if (existsSync(directory)) assert_no_links(directory);
187
+ const staging = join(workflows_home(home), `.tmp-${workflow.id}-${randomUUID()}`);
188
+ mkdirSync(join(staging, 'agents'), { recursive: true });
189
+ writeFileSync(join(staging, 'SKILL.md'), serialize_markdown({ name: workflow.id, label: workflow.label, description: workflow.description }, workflow.skill));
190
+ for (const { card, system_prompt, ...fields } of workflow.agents) {
191
+ writeFileSync(join(staging, 'agents', `${card}.md`), serialize_markdown(Object.fromEntries(CARD_FIELDS.map(key => [key, fields[key] || ''])), system_prompt));
192
+ }
193
+ const old = existsSync(directory) ? join(workflows_home(home), `.old-${workflow.id}-${randomUUID()}`) : '';
194
+ if (old) renameSync(directory, old);
195
+ try { renameSync(staging, directory); }
196
+ catch (error) { if (old) renameSync(old, directory); throw error; }
197
+ if (old) rmSync(old, { recursive: true, force: true });
198
+ return read_saved_workflow(home, workflow.id);
199
+ });
200
+ }
201
+
202
+ /** Remove one workflow folder. */
203
+ export function delete_workflow(home, id) {
204
+ return with_workflow_lock(home, () => {
205
+ const directory = workflow_directory(home, id);
206
+ if (!existsSync(directory)) throw new Error(`工作流不存在:${id}`);
207
+ assert_no_links(directory);
208
+ rmSync(directory, { recursive: true, force: true });
209
+ return { deleted: id };
210
+ });
211
+ }
212
+
213
+ /** Lines of a pasted part, without its header; the header gives order, total and workflow. */
214
+ function draft_part(text) {
215
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
216
+ const headers = lines.map(line => PART_LINE.exec(line)).filter(Boolean);
217
+ if (headers.length !== 1) throw new Error('每段草稿须有且只有一行 <!-- workflow-part: 序号/总数 id: 工作流ID -->');
218
+ const [, index, total, id] = headers[0];
219
+ return { index: Number(index), total: Number(total), id, lines: lines.filter(line => !PART_LINE.test(line)) };
220
+ }
221
+
222
+ /**
223
+ * Parse pasted host drafts (one message each) into SKILL.md and card texts.
224
+ * Separators count only as whole, unindented lines; text before the first file is ignored.
225
+ */
226
+ export function parse_workflow_draft(parts) {
227
+ if (!Array.isArray(parts) || !parts.length || parts.length > MAX_DRAFT_PARTS) throw new Error(`草稿须为 1—${MAX_DRAFT_PARTS} 段文本`);
228
+ const pieces = parts.map(draft_part).sort((a, b) => a.index - b.index);
229
+ const { total, id } = pieces[0];
230
+ if (pieces.some(piece => piece.total !== total || piece.id !== id)) throw new Error('各段草稿的总数或工作流 ID 不一致');
231
+ const missing = Array.from({ length: total }, (_, index) => index + 1).filter(index => !pieces.some(piece => piece.index === index));
232
+ if (missing.length) throw new Error(`还缺第 ${missing.join('、')} 段`);
233
+ if (pieces.length !== total) throw new Error('草稿有重复的段');
234
+ const files = new Map();
235
+ let current = null;
236
+ for (const line of pieces.flatMap(piece => piece.lines)) {
237
+ const marker = FILE_LINE.exec(line);
238
+ if (!marker) { current?.push(line); continue; }
239
+ const path = marker[1];
240
+ if (path !== 'SKILL.md' && !CARD_PATH.test(path)) throw new Error(`草稿只能包含 SKILL.md 和 agents/卡片ID.md:${path}`);
241
+ if (files.has(path)) throw new Error(`草稿里 ${path} 出现了两次`);
242
+ current = [];
243
+ files.set(path, current);
244
+ }
245
+ if (!files.size) throw new Error('草稿里没有任何 <!-- workflow-file: 路径 --> 分隔行');
246
+ if (!files.has('SKILL.md')) throw new Error('草稿里缺少 SKILL.md');
247
+ const text = path => files.get(path).join('\n').replace(/\s+$/, '');
248
+ const cards = Object.fromEntries([...files.keys()].filter(path => path !== 'SKILL.md').map(path => [CARD_PATH.exec(path)[1], text(path)]));
249
+ const workflow = workflow_from_files(text('SKILL.md'), cards);
250
+ if (workflow.id !== id) throw new Error(`SKILL.md 的 name(${workflow.id})与分段头的 id(${id})不一致`);
251
+ return workflow;
252
+ }
253
+
254
+ /** Order of project access by what it allows; an imported card asking for more than reading starts at reading. */
255
+ const READ_ONLY = 'read';
256
+
257
+ /**
258
+ * Parse an import (a folder on this computer or pasted drafts) without saving it.
259
+ * Imported cards asking for project writes start read-only and carry `requested_access` for the page to offer.
260
+ */
261
+ export function preview_workflow(input, validate_card, known_platform) {
262
+ let source;
263
+ if (typeof input.folder === 'string' && input.folder) source = read_workflow_folder(resolve(input.folder));
264
+ else source = { workflow: parse_workflow_draft(input.parts), ignored: [] };
265
+ const workflow = validate_workflow(source.workflow, validate_card);
266
+ const agents = workflow.agents.map(agent => {
267
+ const requested = agent.project_access && agent.project_access !== READ_ONLY ? agent.project_access : '';
268
+ return { ...agent, ...(requested ? { project_access: READ_ONLY, requested_access: requested } : {}),
269
+ ...(known_platform(agent.name) ? {} : { needs_platform: true }) };
270
+ });
271
+ return { ...workflow, agents, ignored: source.ignored };
272
+ }
273
+
274
+ /**
275
+ * What a room's runner needs from its enabled workflow, read fresh for each execution.
276
+ * Returns null when no workflow is enabled; an unreadable one returns its error so members fall back to room settings.
277
+ */
278
+ export function load_room_workflow(config) {
279
+ const binding = config.workflow;
280
+ if (!binding?.enabled || !config.api_home) return null;
281
+ try {
282
+ const workflow = get_workflow(config.api_home, binding.id);
283
+ const { directory } = workflow;
284
+ const cards = Object.fromEntries(workflow.agents.map(agent => [agent.card, agent]));
285
+ return { id: binding.id, label: workflow.label || binding.id, directory, skill_path: join(directory, 'SKILL.md'), skill: workflow.skill,
286
+ members: binding.members || {}, cards };
287
+ } catch (error) { return { id: binding.id, error: error.message }; }
288
+ }
289
+
290
+ /** The member's profile for this execution: a workflow member's prompt comes from its card as it is now. */
291
+ export function effective_profile(profile, member_name, workflow) {
292
+ if (!profile || !workflow || workflow.error) return profile;
293
+ const card = Object.entries(workflow.members).find(([, name]) => name === member_name)?.[0];
294
+ const source = card && workflow.cards[card];
295
+ if (!source) return profile;
296
+ const { system_prompt: ignored, ...rest } = profile;
297
+ // An empty card prompt leaves the software's own preset in place, as an unset member prompt does.
298
+ return source.system_prompt ? { ...rest, system_prompt: source.system_prompt } : rest;
299
+ }
300
+
301
+ /** The host's view of the workflow: which card is which member now, whether present, and what it may do. */
302
+ export function workflow_task_field(workflow, room_members, participants) {
303
+ const members = Object.entries(workflow.members).map(([card, name]) => {
304
+ const member = (room_members || []).find(item => item.name === name);
305
+ if (!member || member.status === 'removed') return null;
306
+ const source = workflow.cards[card] || {};
307
+ return { card, member: member.label || name, label: source.label || '', description: source.description || '',
308
+ status: member.status, project_access: participants?.[name]?.project_access || 'read' };
309
+ }).filter(Boolean);
310
+ return { id: workflow.id, label: workflow.label, members };
311
+ }
312
+
313
+ /** Write a room's workflow binding into its configuration object (the caller saves it). */
314
+ export function bind_room_workflow(config, { id, enabled, members }) {
315
+ workflow_directory('.', id);
316
+ if (members && (typeof members !== 'object' || Array.isArray(members)
317
+ || Object.entries(members).some(([card, name]) => !CARD_ID_PATTERN.test(card) || typeof name !== 'string' || !name))) {
318
+ throw new Error('workflow.members 须为 {卡片ID: 成员名}');
319
+ }
320
+ return { ...config, workflow: { id, enabled: Boolean(enabled), members: { ...(config.workflow?.id === id ? config.workflow.members : {}), ...(members || {}) } } };
321
+ }