ai_co_discuss 0.3.67 → 0.3.68
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/workflow-creator/SKILL.md +3 -0
- package/package.json +1 -1
- package/runner/workflows.mjs +442 -377
- package/web/modern.css +1 -1
- package/web/modern.js +3054 -3050
package/runner/workflows.mjs
CHANGED
|
@@ -1,377 +1,442 @@
|
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
export function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
export function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
function
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (!
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
*/
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*/
|
|
363
|
-
export function
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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 { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, 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
|
+
// Like a skill folder, a workflow carries any other text files beside SKILL.md (templates, references, examples).
|
|
30
|
+
const MAX_RESOURCE_FILES = 100;
|
|
31
|
+
const MAX_RESOURCE_BYTES = 1000000;
|
|
32
|
+
// Each path segment: no leading dot, no Windows-reserved characters.
|
|
33
|
+
const RESOURCE_SEGMENT = /^[^./\\:*?"<>|\x00-\x1f][^/\\:*?"<>|\x00-\x1f]{0,99}$/;
|
|
34
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
35
|
+
|
|
36
|
+
/** The folder holding every workflow of this computer. */
|
|
37
|
+
export function workflows_home(home) { return join(home, 'workflows'); }
|
|
38
|
+
|
|
39
|
+
/** A workflow's folder, built from a checked ID and never from a caller's path. */
|
|
40
|
+
// Built-in room modes own these "/" commands, so no workflow may take their names.
|
|
41
|
+
export const RESERVED_WORKFLOW_IDS = ['goal', 'discuss'];
|
|
42
|
+
|
|
43
|
+
export function workflow_directory(home, id) {
|
|
44
|
+
if (!WORKFLOW_ID_PATTERN.test(id || '')) throw new Error('工作流 ID 只能用小写英文和数字,可用单个连字符连接(不在首尾),1—40 位');
|
|
45
|
+
return join(workflows_home(home), id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Split single-line `key: value` frontmatter from a Markdown body. */
|
|
49
|
+
export function parse_markdown(text, label) {
|
|
50
|
+
const lines = String(text).replace(/^/, '').replace(/\r\n/g, '\n').split('\n');
|
|
51
|
+
if (lines[0] !== '---') throw new Error(`${label} 须以 --- 开头的头信息开始`);
|
|
52
|
+
const end = lines.indexOf('---', 1);
|
|
53
|
+
if (end < 0) throw new Error(`${label} 的头信息缺少结尾 ---`);
|
|
54
|
+
const fields = {};
|
|
55
|
+
for (const line of lines.slice(1, end)) {
|
|
56
|
+
if (!line.trim()) continue;
|
|
57
|
+
const colon = line.indexOf(':');
|
|
58
|
+
if (colon < 1) throw new Error(`${label} 的头信息只支持单行 key: value:${line}`);
|
|
59
|
+
// Quotes around a value are YAML's, not part of it.
|
|
60
|
+
fields[line.slice(0, colon).trim()] = line.slice(colon + 1).trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
61
|
+
}
|
|
62
|
+
return { fields, body: lines.slice(end + 1).join('\n').trim() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Write fields back as single-line frontmatter; empty fields are left out. */
|
|
66
|
+
export function serialize_markdown(fields, body) {
|
|
67
|
+
const header = Object.entries(fields).filter(([, value]) => value !== undefined && value !== '').map(([key, value]) => `${key}: ${String(value ?? '').replace(/\s*\n\s*/g, ' ')}`.trimEnd());
|
|
68
|
+
return `---\n${header.join('\n')}\n---\n${body ? `${body.trim()}\n` : ''}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Turn one parsed card file into the card shape the page edits. */
|
|
72
|
+
function card_from_markdown(card, text) {
|
|
73
|
+
const { fields, body } = parse_markdown(text, `agents/${card}.md`);
|
|
74
|
+
const unknown = Object.keys(fields).filter(key => !CARD_FILE_FIELDS.includes(key));
|
|
75
|
+
if (unknown.length) throw new Error(`agents/${card}.md 含不支持的字段:${unknown.join('、')}`);
|
|
76
|
+
const value = { card };
|
|
77
|
+
for (const key of CARD_FIELDS) if (fields[key]) value[key] = fields[key];
|
|
78
|
+
// Older cards wrote the member name `platform-card` as `name` and had no `platform`.
|
|
79
|
+
if (fields.platform) {
|
|
80
|
+
if (fields.name && fields.name !== card) throw new Error(`agents/${card}.md 的 name 须与文件名相同:${fields.name}`);
|
|
81
|
+
value.name = `${fields.platform}-${card}`;
|
|
82
|
+
}
|
|
83
|
+
if (body) value.system_prompt = body;
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Turn SKILL.md, card files and the folder's other files into one workflow object, unchecked. */
|
|
88
|
+
function workflow_from_files(skill_text, cards, resources = {}) {
|
|
89
|
+
const { fields, body } = parse_markdown(skill_text, 'SKILL.md');
|
|
90
|
+
return { id: fields.name || '', label: fields.label || '', description: fields.description || '', skill: body,
|
|
91
|
+
agents: Object.entries(cards).map(([card, text]) => card_from_markdown(card, text)), resources };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A path, relative to the workflow folder, that a resource may take: inside it, not SKILL.md and not under agents/. */
|
|
95
|
+
function resource_path_allowed(path) {
|
|
96
|
+
return path !== 'SKILL.md' && !path.startsWith('agents/') && path.split('/').every(segment => RESOURCE_SEGMENT.test(segment));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Check resource files `{ "relative/path": text }`. */
|
|
100
|
+
function validate_resources(resources) {
|
|
101
|
+
if (!resources || typeof resources !== 'object' || Array.isArray(resources)) throw new Error('附带文件须为 { 路径: 文本 }');
|
|
102
|
+
const entries = Object.entries(resources);
|
|
103
|
+
if (entries.length > MAX_RESOURCE_FILES) throw new Error(`附带文件最多 ${MAX_RESOURCE_FILES} 个`);
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
for (const [path, text] of entries) {
|
|
106
|
+
if (!resource_path_allowed(path)) throw new Error(`附带文件路径不合法:${path}`);
|
|
107
|
+
if (seen.has(path.toLowerCase())) throw new Error(`附带文件路径重复(不区分大小写):${path}`);
|
|
108
|
+
seen.add(path.toLowerCase());
|
|
109
|
+
if (typeof text !== 'string') throw new Error(`附带文件 ${path} 须为文本`);
|
|
110
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_RESOURCE_BYTES) throw new Error(`附带文件 ${path} 超过 ${MAX_RESOURCE_BYTES / 1000000} MB`);
|
|
111
|
+
}
|
|
112
|
+
return Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A folder's files other than SKILL.md and agents/: UTF-8 text is kept, anything else is named back as ignored. */
|
|
116
|
+
function read_resource_folder(directory, prefix, resources, ignored) {
|
|
117
|
+
for (const name of readdirSync(directory).sort()) {
|
|
118
|
+
const relative = `${prefix}${name}`;
|
|
119
|
+
if (relative === 'SKILL.md' || relative === 'agents') continue;
|
|
120
|
+
const path = join(directory, name);
|
|
121
|
+
if (lstatSync(path).isDirectory()) { read_resource_folder(path, `${relative}/`, resources, ignored); continue; }
|
|
122
|
+
let text = null;
|
|
123
|
+
try { text = utf8.decode(readFileSync(path)); } catch { /* not text */ }
|
|
124
|
+
if (text !== null && resource_path_allowed(relative)) resources[relative] = text;
|
|
125
|
+
else ignored.push(relative);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Write resource files below a workflow folder. */
|
|
130
|
+
function write_resources(directory, resources) {
|
|
131
|
+
for (const [path, text] of Object.entries(resources)) {
|
|
132
|
+
const target = join(directory, ...path.split('/'));
|
|
133
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
134
|
+
writeFileSync(target, text);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Copy a saved folder's files other than SKILL.md and agents/, for a save that carries none of its own. */
|
|
139
|
+
function keep_resources(directory, staging) {
|
|
140
|
+
for (const name of readdirSync(directory)) {
|
|
141
|
+
if (name === 'SKILL.md' || name === 'agents') continue;
|
|
142
|
+
cpSync(join(directory, name), join(staging, name), { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check a whole workflow. `validate_card` is the expert preset check, shared so a card and a preset obey one rule.
|
|
148
|
+
* Returns a normalized copy with only known fields.
|
|
149
|
+
*/
|
|
150
|
+
export function validate_workflow(input, validate_card) {
|
|
151
|
+
if (!input || typeof input !== 'object') throw new Error('工作流须为对象');
|
|
152
|
+
const { id } = input;
|
|
153
|
+
workflow_directory('.', id);
|
|
154
|
+
// Checked only when saving: a workflow saved earlier under one of these names can still be read and deleted.
|
|
155
|
+
if (RESERVED_WORKFLOW_IDS.includes(id)) throw new Error(`工作流 ID 不能用 ${id}:它是内置模式 /${id} 的命令`);
|
|
156
|
+
const label = String(input.label || '').trim(), description = String(input.description || '').trim(), skill = String(input.skill || '').trim();
|
|
157
|
+
if (label.length > MAX_WORKFLOW_LABEL) throw new Error(`工作流显示名最多 ${MAX_WORKFLOW_LABEL} 字`);
|
|
158
|
+
if (description.length > MAX_WORKFLOW_DESCRIPTION || /\n/.test(description)) throw new Error(`工作流描述最多 ${MAX_WORKFLOW_DESCRIPTION} 字,且不能换行`);
|
|
159
|
+
if (!skill) throw new Error('SKILL.md 正文不能为空');
|
|
160
|
+
if (skill.length > MAX_WORKFLOW_SKILL) throw new Error(`SKILL.md 正文最多 ${MAX_WORKFLOW_SKILL} 字`);
|
|
161
|
+
const agents = input.agents || [];
|
|
162
|
+
if (!Array.isArray(agents) || agents.length > MAX_WORKFLOW_CARDS) throw new Error(`卡片须为列表,最多 ${MAX_WORKFLOW_CARDS} 张`);
|
|
163
|
+
const cards = new Set(), names = new Set();
|
|
164
|
+
const normalized = agents.map(agent => {
|
|
165
|
+
// requested_access and needs_platform are an import preview's notes for the page, not card fields.
|
|
166
|
+
const { card, requested_access: ignored_request, needs_platform: ignored_platform, ...preset } = agent || {};
|
|
167
|
+
if (!CARD_ID_PATTERN.test(card || '')) throw new Error(`卡片 ID 只能用小写英文、数字和下划线,1—16 位:${card || '(空)'}`);
|
|
168
|
+
if (cards.has(card.toLowerCase())) throw new Error(`卡片 ID 重复(不区分大小写):${card}`);
|
|
169
|
+
cards.add(card.toLowerCase());
|
|
170
|
+
// Empty fields are dropped; unknown ones stay so the preset check refuses them.
|
|
171
|
+
const kept = Object.fromEntries(Object.entries(preset).filter(([, value]) => value !== undefined && value !== ''));
|
|
172
|
+
try { validate_card(kept); } catch (error) { throw new Error(`卡片 ${card}:${error.message}`); }
|
|
173
|
+
if (!kept.name?.endsWith(`-${card}`)) throw new Error(`卡片 ${card} 的成员名须为“平台-${card}”:${kept.name || '(空)'}`);
|
|
174
|
+
if (names.has(kept.name)) throw new Error(`同一工作流内卡片名称重复:${kept.name}`);
|
|
175
|
+
names.add(kept.name);
|
|
176
|
+
if ((kept.description || '').includes('\n')) throw new Error(`${kept.name} 的描述不能换行`);
|
|
177
|
+
return { card, ...kept };
|
|
178
|
+
});
|
|
179
|
+
// Absent resources mean "keep the saved folder's other files" (a host draft or the editor carries none); `{}` clears them.
|
|
180
|
+
return { id, label, description, skill, agents: normalized,
|
|
181
|
+
...(input.resources === undefined ? {} : { resources: validate_resources(input.resources) }) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Refuse links anywhere in a folder, so a shared workflow cannot point outside itself. */
|
|
185
|
+
function assert_no_links(directory) {
|
|
186
|
+
if (lstatSync(directory).isSymbolicLink()) throw new Error('工作流目录不能是符号链接');
|
|
187
|
+
for (const name of readdirSync(directory)) {
|
|
188
|
+
const path = join(directory, name);
|
|
189
|
+
const stat = lstatSync(path);
|
|
190
|
+
if (stat.isSymbolicLink()) throw new Error(`工作流目录包含符号链接:${name}`);
|
|
191
|
+
if (stat.isDirectory()) assert_no_links(path);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Recover an interrupted replacement before any reader can observe a missing workflow. Called under the library lock. */
|
|
196
|
+
function recover_workflows(home) {
|
|
197
|
+
const root = workflows_home(home);
|
|
198
|
+
for (const name of readdirSync(root)) {
|
|
199
|
+
const match = BACKUP_DIRECTORY.exec(name);
|
|
200
|
+
if (!match) continue;
|
|
201
|
+
const backup = join(root, name), directory = workflow_directory(home, match[1]);
|
|
202
|
+
assert_no_links(backup);
|
|
203
|
+
if (existsSync(directory)) rmSync(backup, { recursive: true, force: true });
|
|
204
|
+
else renameSync(backup, directory);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Serialize publication, recovery and reads across processes; SQLite releases the lock even if its owner exits. */
|
|
209
|
+
function with_workflow_lock(home, action) {
|
|
210
|
+
mkdirSync(workflows_home(home), { recursive: true });
|
|
211
|
+
const lock = new DatabaseSync(join(home, 'workflows-lock.sqlite3'));
|
|
212
|
+
try {
|
|
213
|
+
lock.exec(`PRAGMA busy_timeout=${WORKFLOW_LOCK_WAIT_MS}; BEGIN IMMEDIATE`);
|
|
214
|
+
recover_workflows(home);
|
|
215
|
+
return action();
|
|
216
|
+
} finally { lock.close(); }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Read a workflow folder (the library's own or one being imported) without checking its content. */
|
|
220
|
+
export function read_workflow_folder(directory) {
|
|
221
|
+
if (!existsSync(join(directory, 'SKILL.md'))) throw new Error('文件夹里没有 SKILL.md');
|
|
222
|
+
assert_no_links(directory);
|
|
223
|
+
const agents = join(directory, 'agents');
|
|
224
|
+
const cards = {};
|
|
225
|
+
const resources = {}, ignored = [];
|
|
226
|
+
read_resource_folder(directory, '', resources, ignored);
|
|
227
|
+
if (existsSync(agents)) {
|
|
228
|
+
for (const name of readdirSync(agents).sort()) {
|
|
229
|
+
const match = CARD_PATH.exec(`agents/${name}`);
|
|
230
|
+
if (match) cards[match[1]] = readFileSync(join(agents, name), 'utf8');
|
|
231
|
+
else ignored.push(`agents/${name}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { workflow: workflow_from_files(readFileSync(join(directory, 'SKILL.md'), 'utf8'), cards, resources), ignored };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Read a workflow sent as files (a zip or folder the page opened): `{ "SKILL.md": text, "agents/<card>.md": text,
|
|
239
|
+
* "<other path>": text }`, paths relative to the folder holding SKILL.md. Other text files come along as resources;
|
|
240
|
+
* a path sent as null (a file the page could not read as text) or one a resource may not take is named back as ignored.
|
|
241
|
+
*/
|
|
242
|
+
export function read_workflow_files(files) {
|
|
243
|
+
if (!files || typeof files !== 'object' || Array.isArray(files)) throw new Error('工作流文件须为 { 路径: 文本 }');
|
|
244
|
+
if (typeof files['SKILL.md'] !== 'string') throw new Error('没有 SKILL.md');
|
|
245
|
+
const cards = {}, resources = {}, ignored = [];
|
|
246
|
+
for (const path of Object.keys(files).sort()) {
|
|
247
|
+
if (path === 'SKILL.md') continue;
|
|
248
|
+
const match = CARD_PATH.exec(path);
|
|
249
|
+
if (match && typeof files[path] === 'string') cards[match[1]] = files[path];
|
|
250
|
+
else if (typeof files[path] === 'string' && resource_path_allowed(path)) resources[path] = files[path];
|
|
251
|
+
else ignored.push(path);
|
|
252
|
+
}
|
|
253
|
+
return { workflow: workflow_from_files(files['SKILL.md'], cards, resources), ignored };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Summaries for the library list; a broken folder is listed with its error instead of hiding it. */
|
|
257
|
+
export function list_workflows(home) {
|
|
258
|
+
return with_workflow_lock(home, () => readdirSync(workflows_home(home)).filter(name => WORKFLOW_ID_PATTERN.test(name)).sort().map(id => {
|
|
259
|
+
try {
|
|
260
|
+
const workflow = read_saved_workflow(home, id);
|
|
261
|
+
return { id, label: workflow.label || id, description: workflow.description, cards: workflow.agents.length };
|
|
262
|
+
} catch (error) { return { id, label: id, description: '', cards: 0, error: error.message }; }
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Read a library entry while its caller holds the publication lock. */
|
|
267
|
+
function read_saved_workflow(home, id) {
|
|
268
|
+
const directory = workflow_directory(home, id);
|
|
269
|
+
if (!existsSync(directory)) throw new Error(`工作流不存在:${id}`);
|
|
270
|
+
const { workflow } = read_workflow_folder(directory);
|
|
271
|
+
return { ...workflow, id, directory };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** One workflow in full, as the editor shows it. */
|
|
275
|
+
export function get_workflow(home, id) {
|
|
276
|
+
return with_workflow_lock(home, () => read_saved_workflow(home, id));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Write a checked workflow as files, publishing a complete directory with crash recovery. */
|
|
280
|
+
export function save_workflow(home, input, validate_card) {
|
|
281
|
+
const workflow = validate_workflow(input, validate_card);
|
|
282
|
+
return with_workflow_lock(home, () => {
|
|
283
|
+
const directory = workflow_directory(home, workflow.id);
|
|
284
|
+
if (existsSync(directory)) assert_no_links(directory);
|
|
285
|
+
const staging = join(workflows_home(home), `.tmp-${workflow.id}-${randomUUID()}`);
|
|
286
|
+
mkdirSync(join(staging, 'agents'), { recursive: true });
|
|
287
|
+
writeFileSync(join(staging, 'SKILL.md'), serialize_markdown({ name: workflow.id, label: workflow.label, description: workflow.description }, workflow.skill));
|
|
288
|
+
for (const { card, system_prompt, name, ...fields } of workflow.agents) {
|
|
289
|
+
const platform = name.slice(0, name.length - card.length - 1);
|
|
290
|
+
writeFileSync(join(staging, 'agents', `${card}.md`), serialize_markdown({ name: card, platform,
|
|
291
|
+
...Object.fromEntries(CARD_FIELDS.slice(1).map(key => [key, fields[key]])) }, system_prompt));
|
|
292
|
+
}
|
|
293
|
+
if (workflow.resources) write_resources(staging, workflow.resources);
|
|
294
|
+
else if (existsSync(directory)) keep_resources(directory, staging);
|
|
295
|
+
const old = existsSync(directory) ? join(workflows_home(home), `.old-${workflow.id}-${randomUUID()}`) : '';
|
|
296
|
+
if (old) renameSync(directory, old);
|
|
297
|
+
try { renameSync(staging, directory); }
|
|
298
|
+
catch (error) { if (old) renameSync(old, directory); throw error; }
|
|
299
|
+
if (old) rmSync(old, { recursive: true, force: true });
|
|
300
|
+
return read_saved_workflow(home, workflow.id);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Remove one workflow folder. */
|
|
305
|
+
export function delete_workflow(home, id) {
|
|
306
|
+
return with_workflow_lock(home, () => {
|
|
307
|
+
const directory = workflow_directory(home, id);
|
|
308
|
+
if (!existsSync(directory)) throw new Error(`工作流不存在:${id}`);
|
|
309
|
+
assert_no_links(directory);
|
|
310
|
+
rmSync(directory, { recursive: true, force: true });
|
|
311
|
+
return { deleted: id };
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Lines of a pasted part, without its header; the header gives order, total and workflow. */
|
|
316
|
+
function draft_part(text) {
|
|
317
|
+
const lines = String(text).replace(/\r\n/g, '\n').split('\n');
|
|
318
|
+
const headers = lines.map(line => PART_LINE.exec(line)).filter(Boolean);
|
|
319
|
+
if (headers.length !== 1) throw new Error('每段草稿须有且只有一行 <!-- workflow-part: 序号/总数 id: 工作流ID -->');
|
|
320
|
+
const [, index, total, id] = headers[0];
|
|
321
|
+
return { index: Number(index), total: Number(total), id, lines: lines.filter(line => !PART_LINE.test(line)) };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Parse pasted host drafts (one message each) into SKILL.md and card texts.
|
|
326
|
+
* Separators count only as whole, unindented lines; text before the first file is ignored.
|
|
327
|
+
*/
|
|
328
|
+
export function parse_workflow_draft(parts) {
|
|
329
|
+
if (!Array.isArray(parts) || !parts.length || parts.length > MAX_DRAFT_PARTS) throw new Error(`草稿须为 1—${MAX_DRAFT_PARTS} 段文本`);
|
|
330
|
+
const pieces = parts.map(draft_part).sort((a, b) => a.index - b.index);
|
|
331
|
+
const { total, id } = pieces[0];
|
|
332
|
+
if (pieces.some(piece => piece.total !== total || piece.id !== id)) throw new Error('各段草稿的总数或工作流 ID 不一致');
|
|
333
|
+
const missing = Array.from({ length: total }, (_, index) => index + 1).filter(index => !pieces.some(piece => piece.index === index));
|
|
334
|
+
if (missing.length) throw new Error(`还缺第 ${missing.join('、')} 段`);
|
|
335
|
+
if (pieces.length !== total) throw new Error('草稿有重复的段');
|
|
336
|
+
const files = new Map();
|
|
337
|
+
let current = null;
|
|
338
|
+
for (const line of pieces.flatMap(piece => piece.lines)) {
|
|
339
|
+
const marker = FILE_LINE.exec(line);
|
|
340
|
+
if (!marker) { current?.push(line); continue; }
|
|
341
|
+
const path = marker[1];
|
|
342
|
+
if (path !== 'SKILL.md' && !CARD_PATH.test(path)) throw new Error(`草稿只能包含 SKILL.md 和 agents/卡片ID.md:${path}`);
|
|
343
|
+
if (files.has(path)) throw new Error(`草稿里 ${path} 出现了两次`);
|
|
344
|
+
current = [];
|
|
345
|
+
files.set(path, current);
|
|
346
|
+
}
|
|
347
|
+
if (!files.size) throw new Error('草稿里没有任何 <!-- workflow-file: 路径 --> 分隔行');
|
|
348
|
+
if (!files.has('SKILL.md')) throw new Error('草稿里缺少 SKILL.md');
|
|
349
|
+
const text = path => files.get(path).join('\n').replace(/\s+$/, '');
|
|
350
|
+
const cards = Object.fromEntries([...files.keys()].filter(path => path !== 'SKILL.md').map(path => [CARD_PATH.exec(path)[1], text(path)]));
|
|
351
|
+
const workflow = workflow_from_files(text('SKILL.md'), cards);
|
|
352
|
+
if (workflow.id !== id) throw new Error(`SKILL.md 的 name(${workflow.id})与分段头的 id(${id})不一致`);
|
|
353
|
+
return workflow;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Order of project access by what it allows; an imported card asking for more than reading starts at reading. */
|
|
357
|
+
const READ_ONLY = 'read';
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Parse an import (files sent by the page, a folder on this computer or a draft) without saving it.
|
|
361
|
+
* Imported cards asking for project writes start read-only and carry `requested_access` for the page to offer.
|
|
362
|
+
*/
|
|
363
|
+
export function preview_workflow(input, validate_card, known_platform) {
|
|
364
|
+
let source;
|
|
365
|
+
if (input.files) source = read_workflow_files(input.files);
|
|
366
|
+
else if (typeof input.folder === 'string' && input.folder) source = read_workflow_folder(resolve(input.folder));
|
|
367
|
+
else source = { workflow: parse_workflow_draft(input.parts), ignored: [] };
|
|
368
|
+
const workflow = validate_workflow(source.workflow, validate_card);
|
|
369
|
+
const agents = workflow.agents.map(agent => {
|
|
370
|
+
const requested = agent.project_access && agent.project_access !== READ_ONLY ? agent.project_access : '';
|
|
371
|
+
return { ...agent, ...(requested ? { project_access: READ_ONLY, requested_access: requested } : {}),
|
|
372
|
+
...(known_platform(agent.name) ? {} : { needs_platform: true }) };
|
|
373
|
+
});
|
|
374
|
+
return { ...workflow, agents, ignored: source.ignored };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* What a room's runner needs from its enabled workflow, read fresh for each execution.
|
|
379
|
+
* Returns null when no workflow is enabled; an unreadable one returns its error so members fall back to room settings.
|
|
380
|
+
*/
|
|
381
|
+
export function load_room_workflow(config) {
|
|
382
|
+
const binding = config.workflow;
|
|
383
|
+
if (!binding?.enabled || !config.api_home) return null;
|
|
384
|
+
try {
|
|
385
|
+
const workflow = get_workflow(config.api_home, binding.id);
|
|
386
|
+
const { directory } = workflow;
|
|
387
|
+
const cards = Object.fromEntries(workflow.agents.map(agent => [agent.card, agent]));
|
|
388
|
+
return { id: binding.id, label: workflow.label || binding.id, description: workflow.description || '', directory, skill_path: join(directory, 'SKILL.md'), skill: workflow.skill,
|
|
389
|
+
revision: createHash('sha256').update(workflow.skill).digest('hex').slice(0, REVISION_LENGTH),
|
|
390
|
+
members: binding.members || {}, cards };
|
|
391
|
+
} catch (error) { return { id: binding.id, error: error.message }; }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The member's profile for this execution: a workflow member's prompt comes from its card as it is now. */
|
|
395
|
+
export function effective_profile(profile, member_name, workflow) {
|
|
396
|
+
if (!profile || !workflow || workflow.error) return profile;
|
|
397
|
+
const card = Object.entries(workflow.members).find(([, name]) => name === member_name)?.[0];
|
|
398
|
+
const source = card && workflow.cards[card];
|
|
399
|
+
if (!source) return profile;
|
|
400
|
+
const { system_prompt: ignored, ...rest } = profile;
|
|
401
|
+
// An empty card prompt leaves the software's own preset in place, as an unset member prompt does.
|
|
402
|
+
return source.system_prompt ? { ...rest, system_prompt: source.system_prompt } : rest;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The host's view of the workflow: which card is which member now, whether present, and what it may do. A card the
|
|
407
|
+
* workflow dropped is left out; `missing` lists cards added after it was enabled, which no member plays yet.
|
|
408
|
+
*/
|
|
409
|
+
export function workflow_task_field(workflow, room_members, participants) {
|
|
410
|
+
const members = Object.entries(workflow.members).map(([card, name]) => {
|
|
411
|
+
const member = (room_members || []).find(item => item.name === name);
|
|
412
|
+
const source = workflow.cards[card];
|
|
413
|
+
if (!member || member.status === 'removed' || !source) return null;
|
|
414
|
+
return { card, member: member.label || name, label: source.label || '', description: source.description || '',
|
|
415
|
+
status: member.status, project_access: participants?.[name]?.project_access || 'read' };
|
|
416
|
+
}).filter(Boolean);
|
|
417
|
+
const missing = Object.values(workflow.cards).filter(card => !workflow.members[card.card])
|
|
418
|
+
.map(card => ({ card: card.card, label: card.label || '', description: card.description || '' }));
|
|
419
|
+
// The host reads SKILL.md only when a request fits the description and its revision is one it has not read.
|
|
420
|
+
return { id: workflow.id, label: workflow.label, description: workflow.description || '', revision: workflow.revision || '', members,
|
|
421
|
+
...(missing.length ? { missing } : {}) };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Whether a bound workflow is in use: while at least one member it brought (other than the host) is here. Sending them
|
|
426
|
+
* away or removing them ends it; a workflow that brought no one but the host lasts while it is bound.
|
|
427
|
+
*/
|
|
428
|
+
export function workflow_in_use(workflow, room_members) {
|
|
429
|
+
const host = (room_members || []).find(member => member.role === 'host')?.name;
|
|
430
|
+
const brought = Object.values(workflow.members || {}).filter(name => name !== host);
|
|
431
|
+
return !brought.length || (room_members || []).some(member => brought.includes(member.name) && member.status === 'active');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Write a room's workflow binding into its configuration object (the caller saves it). */
|
|
435
|
+
export function bind_room_workflow(config, { id, enabled, members }) {
|
|
436
|
+
workflow_directory('.', id);
|
|
437
|
+
if (members && (typeof members !== 'object' || Array.isArray(members)
|
|
438
|
+
|| Object.entries(members).some(([card, name]) => !CARD_ID_PATTERN.test(card) || typeof name !== 'string' || !name))) {
|
|
439
|
+
throw new Error('workflow.members 须为 {卡片ID: 成员名}');
|
|
440
|
+
}
|
|
441
|
+
return { ...config, workflow: { id, enabled: Boolean(enabled), members: { ...(config.workflow?.id === id ? config.workflow.members : {}), ...(members || {}) } } };
|
|
442
|
+
}
|