openworkgraph 0.2.13 → 0.3.1

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 (40) hide show
  1. package/dist/api.d.ts +1 -0
  2. package/dist/api.js +6 -0
  3. package/dist/backend/adapter.d.ts +1 -0
  4. package/dist/backend/adapter.js +161 -8
  5. package/dist/backend/stdio.d.ts +1 -1
  6. package/dist/backend/stdio.js +2 -2
  7. package/dist/backend/types.d.ts +12 -0
  8. package/dist/builtin-skills.d.ts +56 -0
  9. package/dist/builtin-skills.js +514 -0
  10. package/dist/graph-transfer.js +7 -2
  11. package/dist/inputs.d.ts +1 -0
  12. package/dist/inputs.js +1 -1
  13. package/dist/operations/daemon.js +8 -2
  14. package/dist/operations/update.d.ts +2 -0
  15. package/dist/operations/update.js +30 -11
  16. package/dist/plugins.d.ts +4 -0
  17. package/dist/plugins.js +33 -1
  18. package/dist/runtime-backend.d.ts +3 -1
  19. package/dist/runtime-backend.js +11 -2
  20. package/dist/runtime.d.ts +7 -0
  21. package/dist/runtime.js +17 -2
  22. package/dist/service.d.ts +1 -0
  23. package/dist/service.js +28 -10
  24. package/dist/skill-configs.d.ts +43 -0
  25. package/dist/skill-configs.js +554 -0
  26. package/dist/skill-schema.d.ts +10 -0
  27. package/dist/skill-schema.js +144 -0
  28. package/dist/skill-sessions.d.ts +24 -0
  29. package/dist/skill-sessions.js +165 -0
  30. package/dist/skill-source.d.ts +37 -0
  31. package/dist/skill-source.js +104 -0
  32. package/dist/skills-api.d.ts +8 -4
  33. package/dist/skills-api.js +48 -27
  34. package/dist/skills-management-api.d.ts +12 -0
  35. package/dist/skills-management-api.js +139 -0
  36. package/dist/vendor/protocol/index.d.ts +4 -2
  37. package/dist/vendor/protocol/index.js +2 -1
  38. package/dist/vendor/protocol/skills.d.ts +107 -0
  39. package/dist/vendor/protocol/skills.js +33 -0
  40. package/package.json +2 -2
@@ -0,0 +1,514 @@
1
+ import { isSkillName } from "./vendor/protocol/index.js";
2
+ import { promises as fs } from 'node:fs';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { homedir, hostname } from 'node:os';
5
+ import { join, resolve, dirname } from 'node:path';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import { parseDocument } from 'yaml';
8
+ import { ServiceError } from './errors.js';
9
+ import { parseSkillConfig } from './skill-schema.js';
10
+ import { DEFAULT_SKILL_SOURCE, GitHubSkillSource, SKILL_LIMITS, gitBlobSha, packageDigest, validatePackageFiles, safeSkillPath } from './skill-source.js';
11
+ const key = (id) => createHash('sha256').update(id).digest('hex');
12
+ const isMissing = (error) => error.code === 'ENOENT';
13
+ class SkillDeadline {
14
+ expires;
15
+ constructor(duration) { this.expires = Date.now() + duration; }
16
+ check() { if (Date.now() >= this.expires)
17
+ throw new ServiceError('CONFLICT', '技能操作超过总时限,请稍后重试。', { retryable: true }); }
18
+ async wait(work) {
19
+ this.check();
20
+ let timer;
21
+ try {
22
+ const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new ServiceError('CONFLICT', '技能操作超过总时限,请稍后重试。', { retryable: true })), Math.max(1, this.expires - Date.now())); });
23
+ const result = await Promise.race([work(), timeout]);
24
+ this.check();
25
+ return result;
26
+ }
27
+ finally {
28
+ if (timer)
29
+ clearTimeout(timer);
30
+ }
31
+ }
32
+ }
33
+ export function parseSkillFrontmatter(text) {
34
+ if (Buffer.byteLength(text) > SKILL_LIMITS.metadataBytes)
35
+ throw new ServiceError('INVALID_REQUEST', 'SKILL.md 超过大小限制。');
36
+ const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
37
+ if (!match)
38
+ throw new ServiceError('INVALID_REQUEST', 'SKILL.md 缺少 YAML frontmatter。');
39
+ try {
40
+ const document = parseDocument(match[1], { uniqueKeys: true });
41
+ if (document.errors.length)
42
+ throw Error('yaml');
43
+ const value = document.toJS({ maxAliasCount: 32 });
44
+ if (!value || !isSkillName(value.name) || typeof value.description !== 'string' || !value.description.trim() || value.description.length > 16_384)
45
+ throw Error('metadata');
46
+ return { name: value.name, description: value.description.trim() };
47
+ }
48
+ catch {
49
+ throw new ServiceError('INVALID_REQUEST', 'SKILL.md 的名称或 YAML 简介无效。');
50
+ }
51
+ }
52
+ /** Shared packages, workspace-independent installation pointers, immutable versions. */
53
+ export class BuiltinSkills {
54
+ root;
55
+ source;
56
+ provider;
57
+ ready;
58
+ refreshing;
59
+ constructor(_directories, _serviceId, options = {}) {
60
+ this.root = resolve(options.root ?? join(homedir(), '.openworkgraph', 'skills'));
61
+ this.source = { ...(options.source ?? DEFAULT_SKILL_SOURCE) };
62
+ this.provider = options.provider ?? new GitHubSkillSource();
63
+ }
64
+ initialize() {
65
+ return this.ready ??= (async () => {
66
+ await fs.mkdir(this.root, { recursive: true, mode: 0o700 });
67
+ const stat = await fs.lstat(this.root);
68
+ if (!stat.isDirectory() || stat.isSymbolicLink())
69
+ throw new ServiceError('INVALID_REQUEST', '技能管理目录不能是链接。');
70
+ await fs.chmod(this.root, 0o700);
71
+ await this.directory('versions');
72
+ await this.directory('staging');
73
+ })();
74
+ }
75
+ async checked(relative) {
76
+ const parts = relative.split('/');
77
+ let path = this.root;
78
+ const rootStat = await fs.lstat(path);
79
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
80
+ throw new ServiceError('INVALID_REQUEST', '技能管理目录无效。');
81
+ for (const part of parts) {
82
+ if (!part || part === '.' || part === '..' || part.includes('\\') || part.includes('\0'))
83
+ throw new ServiceError('INVALID_REQUEST', '技能管理路径无效。');
84
+ path = join(path, part);
85
+ try {
86
+ if ((await fs.lstat(path)).isSymbolicLink())
87
+ throw new ServiceError('INVALID_REQUEST', '技能管理路径不能是链接。');
88
+ }
89
+ catch (error) {
90
+ if (!isMissing(error))
91
+ throw error;
92
+ }
93
+ }
94
+ return path;
95
+ }
96
+ async directory(relative) {
97
+ const path = await this.checked(relative);
98
+ await fs.mkdir(path, { recursive: true, mode: 0o700 });
99
+ if (!(await fs.lstat(path)).isDirectory())
100
+ throw new ServiceError('INVALID_REQUEST', '技能管理目录无效。');
101
+ await fs.chmod(path, 0o700);
102
+ return path;
103
+ }
104
+ async readJson(relative) {
105
+ try {
106
+ const path = await this.checked(relative);
107
+ if ((await fs.stat(path)).size > 16 * 1024 * 1024)
108
+ throw new ServiceError('INVALID_REQUEST', '技能管理记录过大。');
109
+ return JSON.parse(await fs.readFile(path, 'utf8'));
110
+ }
111
+ catch (error) {
112
+ if (isMissing(error))
113
+ return undefined;
114
+ throw error;
115
+ }
116
+ }
117
+ async atomicJson(relative, value) {
118
+ const target = await this.checked(relative);
119
+ const temp = target + '.' + randomUUID() + '.tmp';
120
+ try {
121
+ const file = await fs.open(temp, 'wx', 0o600);
122
+ try {
123
+ await file.writeFile(JSON.stringify(value));
124
+ await file.sync();
125
+ }
126
+ finally {
127
+ await file.close();
128
+ }
129
+ await fs.rename(temp, target);
130
+ if (process.platform !== 'win32') {
131
+ const parent = await fs.open(dirname(target), 'r');
132
+ try {
133
+ await parent.sync();
134
+ }
135
+ finally {
136
+ await parent.close();
137
+ }
138
+ }
139
+ }
140
+ finally {
141
+ await fs.rm(temp, { force: true });
142
+ }
143
+ }
144
+ async state() {
145
+ const value = await this.readJson('installed.json');
146
+ if (!value)
147
+ return { version: 1, items: {} };
148
+ if (value.version !== 1 || !value.items || typeof value.items !== 'object' || Array.isArray(value.items))
149
+ throw new ServiceError('INVALID_REQUEST', '技能安装记录损坏。');
150
+ for (const [id, item] of Object.entries(value.items))
151
+ if (item.skillId !== id || !Number.isSafeInteger(item.revision) || item.revision < 0 || (item.packageVersion && !/^[a-f0-9]{64}$/.test(item.packageVersion)))
152
+ throw new ServiceError('INVALID_REQUEST', '技能安装记录损坏。');
153
+ return value;
154
+ }
155
+ async claimLock(relative) {
156
+ const target = await this.checked(relative);
157
+ const parent = relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/')) + '/' : '';
158
+ const prepared = parent + '.owner-' + randomUUID();
159
+ await this.directory(prepared);
160
+ try {
161
+ const owner = { pid: process.pid, hostname: hostname(), token: randomUUID() };
162
+ await this.atomicJson(prepared + '/owner.json', owner);
163
+ try {
164
+ await fs.lstat(target);
165
+ return false;
166
+ }
167
+ catch (error) {
168
+ if (!isMissing(error))
169
+ throw error;
170
+ }
171
+ try {
172
+ await fs.rename(await this.checked(prepared), target);
173
+ return true;
174
+ }
175
+ catch (error) {
176
+ if (['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code ?? '')) {
177
+ try {
178
+ if ((await fs.lstat(target)).isDirectory())
179
+ return false;
180
+ }
181
+ catch (error) {
182
+ if (isMissing(error))
183
+ return false;
184
+ throw error;
185
+ }
186
+ }
187
+ throw error;
188
+ }
189
+ }
190
+ finally {
191
+ await fs.rm(await this.checked(prepared), { recursive: true, force: true });
192
+ }
193
+ }
194
+ async lockOwner(relative) {
195
+ const owner = await this.readJson(relative + '/owner.json');
196
+ if (!owner || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || typeof owner.token !== 'string' || typeof owner.hostname !== 'string')
197
+ return undefined;
198
+ return owner;
199
+ }
200
+ deadOwner(owner) {
201
+ if (owner.hostname !== hostname())
202
+ return false;
203
+ try {
204
+ process.kill(owner.pid, 0);
205
+ return false;
206
+ }
207
+ catch (error) {
208
+ return error.code === 'ESRCH';
209
+ }
210
+ }
211
+ async recoverLock(relative, depth = 0) {
212
+ const owner = await this.lockOwner(relative);
213
+ if (!owner || !this.deadOwner(owner) || depth > 4)
214
+ return false;
215
+ const recovery = relative + '/.recovery';
216
+ try {
217
+ if (!await this.claimLock(recovery)) {
218
+ await this.recoverLock(recovery, depth + 1);
219
+ return false;
220
+ }
221
+ }
222
+ catch (error) {
223
+ if (isMissing(error))
224
+ return false;
225
+ throw error;
226
+ }
227
+ let moved = false;
228
+ try {
229
+ const current = await this.lockOwner(relative);
230
+ if (!current || current.token !== owner.token || !this.deadOwner(current))
231
+ return false;
232
+ const retired = relative + '.retired-' + randomUUID();
233
+ await fs.rename(await this.checked(relative), await this.checked(retired));
234
+ moved = true;
235
+ await fs.rm(await this.checked(retired), { recursive: true, force: true });
236
+ return true;
237
+ }
238
+ finally {
239
+ if (!moved)
240
+ await fs.rm(await this.checked(recovery), { recursive: true, force: true });
241
+ }
242
+ }
243
+ async lock(work) {
244
+ await this.initialize();
245
+ const started = Date.now();
246
+ for (;;) {
247
+ if (await this.claimLock('installation.lock'))
248
+ break;
249
+ if (await this.recoverLock('installation.lock'))
250
+ continue;
251
+ if (Date.now() - started > 30_000)
252
+ throw new ServiceError('CONFLICT', '技能操作正在其他工作空间进行,或无法确认锁进程已退出,请稍后重试。', { retryable: true });
253
+ await delay(50);
254
+ }
255
+ try {
256
+ // Only the lock holder may clean interrupted downloads. Published versions are retained.
257
+ for (const entry of await fs.readdir(await this.checked('staging')))
258
+ if (/^install-[a-f0-9-]+$/.test(entry))
259
+ await fs.rm(await this.checked('staging/' + entry), { recursive: true, force: true });
260
+ return await work();
261
+ }
262
+ finally {
263
+ const retired = 'installation.lock.retired-' + randomUUID();
264
+ await fs.rename(await this.checked('installation.lock'), await this.checked(retired));
265
+ await fs.rm(await this.checked(retired), { recursive: true, force: true });
266
+ }
267
+ }
268
+ async download(pkg, commitSha, file, deadline) {
269
+ const read = () => this.provider.read(this.source, commitSha, this.source.directory + '/' + pkg.directory + '/' + file.path);
270
+ const bytes = Buffer.from(await (deadline ? deadline.wait(read) : read()));
271
+ if (bytes.length !== file.size || bytes.length > SKILL_LIMITS.fileBytes || gitBlobSha(bytes) !== file.sha)
272
+ throw new ServiceError('INVALID_REQUEST', '技能文件大小或内容摘要校验失败。');
273
+ return bytes;
274
+ }
275
+ async metadata(pkg, commitSha, deadline) {
276
+ validatePackageFiles(pkg);
277
+ const read = async (name) => {
278
+ const file = pkg.files.find(file => file.path === name);
279
+ if (file.size > SKILL_LIMITS.metadataBytes)
280
+ throw new ServiceError('INVALID_REQUEST', '技能说明或配置超过大小限制。');
281
+ return (await this.download(pkg, commitSha, file, deadline)).toString('utf8');
282
+ };
283
+ const [skill, config, readme] = await Promise.all([read('SKILL.md'), read('config.json'), read('README.md')]);
284
+ if (!readme.trim())
285
+ throw new ServiceError('INVALID_REQUEST', '技能包 README.md 不能为空。');
286
+ return { ...parseSkillFrontmatter(skill), config: parseSkillConfig(config) };
287
+ }
288
+ refresh() {
289
+ return this.refreshing ??= (async () => {
290
+ const deadline = new SkillDeadline(SKILL_LIMITS.catalogMs);
291
+ const snapshot = await deadline.wait(() => this.provider.snapshot(this.source));
292
+ if (!/^[a-f0-9]{40}$/.test(snapshot.commitSha) || snapshot.packages.length > 500)
293
+ throw new ServiceError('INVALID_REQUEST', '技能来源目录无效。');
294
+ const packages = [];
295
+ const seen = new Set();
296
+ for (const pkg of snapshot.packages) {
297
+ deadline.check();
298
+ if (seen.has(pkg.directory))
299
+ throw new ServiceError('INVALID_REQUEST', '技能来源含重复目录。');
300
+ seen.add(pkg.directory);
301
+ const item = { skillId: this.source.id + ':' + pkg.directory, source: this.source, directory: pkg.directory, name: pkg.directory, description: '', installed: false, revision: 0, packageVersion: packageDigest(pkg.files), commitSha: snapshot.commitSha, updateAvailable: false, configuration: 'none' };
302
+ try {
303
+ const metadata = await this.metadata(pkg, snapshot.commitSha, deadline);
304
+ item.name = metadata.name;
305
+ item.description = metadata.description;
306
+ item.configuration = metadata.config.environment.length ? 'required' : 'none';
307
+ packages.push({ ...pkg, item, config: metadata.config });
308
+ }
309
+ catch (error) {
310
+ item.error = error instanceof ServiceError ? error.message : '无法读取技能包,请刷新重试。';
311
+ packages.push({ ...pkg, item });
312
+ }
313
+ }
314
+ deadline.check();
315
+ const cache = { source: this.source, fetchedAt: Date.now(), packages };
316
+ await this.atomicJson('catalog-' + key(JSON.stringify(this.source)) + '.json', cache);
317
+ return cache;
318
+ })().finally(() => { this.refreshing = undefined; });
319
+ }
320
+ async cached(refresh = false) {
321
+ await this.initialize();
322
+ const cache = await this.readJson('catalog-' + key(JSON.stringify(this.source)) + '.json');
323
+ if (!refresh && cache && Date.now() - cache.fetchedAt < 5 * 60_000)
324
+ return { cache };
325
+ try {
326
+ return { cache: await this.refresh() };
327
+ }
328
+ catch (error) {
329
+ return { cache, error: error instanceof ServiceError ? error.message : '技能来源暂时不可用,请检查网络后重试。' };
330
+ }
331
+ }
332
+ async catalog(refresh = false) {
333
+ const { cache, error } = await this.cached(refresh);
334
+ const state = await this.state();
335
+ const result = new Map();
336
+ for (const pkg of cache?.packages ?? []) {
337
+ const local = state.items[pkg.item.skillId];
338
+ result.set(pkg.item.skillId, local?.installed ? { ...local, updateAvailable: local.packageVersion !== pkg.item.packageVersion, ...(pkg.item.error ? { error: pkg.item.error } : {}) } : { ...pkg.item, revision: local?.revision ?? 0 });
339
+ }
340
+ for (const item of Object.values(state.items))
341
+ if (item.installed && !result.has(item.skillId))
342
+ result.set(item.skillId, item);
343
+ return { items: [...result.values()].sort((a, b) => a.name.localeCompare(b.name)), stale: !!error, ...(error ? { error } : {}) };
344
+ }
345
+ async installed() { await this.initialize(); return Object.values((await this.state()).items).filter(item => item.installed); }
346
+ versionDirectory(id, version) {
347
+ if (!id || id.length > 1024 || !/^[a-f0-9]{64}$/.test(version))
348
+ throw new ServiceError('INVALID_REQUEST', '技能身份或包版本无效。');
349
+ return 'versions/' + key(id) + '/' + version;
350
+ }
351
+ async packageEntry(skillId, packageVersion) {
352
+ await this.initialize();
353
+ const current = (await this.state()).items[skillId];
354
+ const version = packageVersion ?? (current?.installed ? current.packageVersion : undefined);
355
+ if (!version)
356
+ throw new ServiceError('NOT_FOUND', '技能尚未安装或已卸载。');
357
+ const relative = this.versionDirectory(skillId, version);
358
+ const manifest = await this.readJson(relative + '/manifest.json');
359
+ if (!manifest || manifest.version !== 1 || manifest.item.skillId !== skillId || manifest.item.packageVersion !== version)
360
+ throw new ServiceError('NOT_FOUND', '技能固定包版本不可用。');
361
+ validatePackageFiles({ directory: manifest.item.directory, files: manifest.files });
362
+ if (packageDigest(manifest.files) !== version)
363
+ throw new ServiceError('INVALID_REQUEST', '技能包元数据摘要无效。');
364
+ for (const file of manifest.files) {
365
+ const path = await this.checked(relative + '/package/' + file.path);
366
+ const stat = await fs.stat(path);
367
+ if (!stat.isFile() || stat.size !== file.size || gitBlobSha(await fs.readFile(path)) !== file.sha)
368
+ throw new ServiceError('INVALID_REQUEST', '已安装技能包完整性校验失败。');
369
+ }
370
+ const config = parseSkillConfig(await fs.readFile(await this.checked(relative + '/package/config.json'), 'utf8'));
371
+ return { path: await this.checked(relative + '/package/SKILL.md'), config, item: current?.installed && current.packageVersion === version ? current : manifest.item };
372
+ }
373
+ /** Holds the same cross-process lock as updates while a configuration write uses its schema. */
374
+ async withPackageEntry(skillId, expectedVersion, work) {
375
+ return this.lock(async () => {
376
+ const entry = await this.packageEntry(skillId);
377
+ if (entry.item.packageVersion !== expectedVersion)
378
+ throw new ServiceError('REVISION_CONFLICT', '技能包已更新,请重新打开设置后保存。');
379
+ return work(entry);
380
+ });
381
+ }
382
+ /** Only files declared in a validated immutable manifest or pinned source snapshot are readable. */
383
+ async readPackageFile(skillId, packageVersion, path) {
384
+ safeSkillPath(path);
385
+ await this.initialize();
386
+ const relative = this.versionDirectory(skillId, packageVersion);
387
+ const manifest = await this.readJson(relative + '/manifest.json');
388
+ if (manifest) {
389
+ if (manifest.version !== 1 || manifest.item.skillId !== skillId || manifest.item.packageVersion !== packageVersion)
390
+ throw new ServiceError('INVALID_REQUEST', '技能包元数据无效。');
391
+ validatePackageFiles({ directory: manifest.item.directory, files: manifest.files });
392
+ if (packageDigest(manifest.files) !== packageVersion)
393
+ throw new ServiceError('INVALID_REQUEST', '技能包元数据摘要无效。');
394
+ const file = manifest.files.find(file => file.path === path);
395
+ if (!file)
396
+ throw new ServiceError('NOT_FOUND', '技能包资源不存在。');
397
+ const target = await this.checked(relative + '/package/' + path);
398
+ const stat = await fs.stat(target);
399
+ if (!stat.isFile() || stat.size !== file.size)
400
+ throw new ServiceError('INVALID_REQUEST', '技能资源完整性校验失败。');
401
+ const bytes = await fs.readFile(target);
402
+ if (gitBlobSha(bytes) !== file.sha)
403
+ throw new ServiceError('INVALID_REQUEST', '技能资源完整性校验失败。');
404
+ return bytes;
405
+ }
406
+ const { cache, error } = await this.cached();
407
+ const pkg = cache?.packages.find(pkg => pkg.item.skillId === skillId && pkg.item.packageVersion === packageVersion);
408
+ if (!pkg || pkg.item.error)
409
+ throw new ServiceError('NOT_FOUND', error ?? '技能固定包版本或资源不可用,请刷新详情。');
410
+ validatePackageFiles(pkg);
411
+ const file = pkg.files.find(file => file.path === path);
412
+ if (!file)
413
+ throw new ServiceError('NOT_FOUND', '技能包资源不存在。');
414
+ return this.download(pkg, pkg.item.commitSha, file);
415
+ }
416
+ async detail(skillId, locale = 'en') {
417
+ if (!/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(locale))
418
+ throw new ServiceError('INVALID_REQUEST', '技能说明语言无效。');
419
+ const names = [...new Set(['README.' + locale + '.md', 'README.' + locale.split('-')[0] + '.md', 'README.md'])];
420
+ const current = (await this.installed()).find(item => item.skillId === skillId);
421
+ if (current) {
422
+ const entry = await this.packageEntry(skillId);
423
+ const relative = this.versionDirectory(skillId, current.packageVersion);
424
+ const manifest = (await this.readJson(relative + '/manifest.json'));
425
+ const name = names.find(name => manifest.files.some(file => file.path === name));
426
+ const cached = await this.readJson('catalog-' + key(JSON.stringify(this.source)) + '.json');
427
+ const remote = cached?.packages.find(pkg => pkg.item.skillId === skillId);
428
+ return { item: { ...entry.item, updateAvailable: !!remote && remote.item.packageVersion !== entry.item.packageVersion }, config: entry.config, readme: await fs.readFile(join(dirname(entry.path), name), 'utf8') };
429
+ }
430
+ const { cache, error } = await this.cached();
431
+ const pkg = cache?.packages.find(pkg => pkg.item.skillId === skillId);
432
+ if (!pkg)
433
+ throw new ServiceError('NOT_FOUND', error ?? '技能不存在。');
434
+ if (pkg.item.error || !pkg.config)
435
+ throw new ServiceError('INVALID_REQUEST', pkg.item.error ?? '技能包配置无效。');
436
+ const file = pkg.files.find(file => file.path === names.find(name => pkg.files.some(file => file.path === name)));
437
+ if (file.size > SKILL_LIMITS.metadataBytes)
438
+ throw new ServiceError('PAYLOAD_TOO_LARGE', '技能说明超过大小限制。');
439
+ return { item: { ...pkg.item, revision: (await this.state()).items[skillId]?.revision ?? 0 }, config: pkg.config, readme: (await this.download(pkg, pkg.item.commitSha, file)).toString('utf8') };
440
+ }
441
+ async install(skillId, expectedRevision) {
442
+ const deadline = new SkillDeadline(SKILL_LIMITS.installMs);
443
+ return this.lock(async () => {
444
+ deadline.check();
445
+ const state = await this.state();
446
+ const current = state.items[skillId];
447
+ this.checkRevision(current, expectedRevision);
448
+ const { cache, error } = await deadline.wait(() => this.cached(true));
449
+ // A stale catalog may be shown, but must never be used to publish an installation.
450
+ if (error)
451
+ throw new ServiceError('NOT_FOUND', error, { retryable: true });
452
+ const pkg = cache?.packages.find(pkg => pkg.item.skillId === skillId);
453
+ if (!pkg)
454
+ throw new ServiceError('NOT_FOUND', '技能不存在。');
455
+ if (pkg.item.error)
456
+ throw new ServiceError('INVALID_REQUEST', pkg.item.error);
457
+ if (current?.installed && current.packageVersion === pkg.item.packageVersion)
458
+ return current;
459
+ const item = { ...pkg.item, installed: true, revision: (current?.revision ?? 0) + 1, updateAvailable: false };
460
+ const target = this.versionDirectory(skillId, item.packageVersion);
461
+ const staging = 'staging/install-' + randomUUID();
462
+ await this.directory(staging + '/package');
463
+ try {
464
+ for (const file of pkg.files) {
465
+ const bytes = await this.download(pkg, item.commitSha, file, deadline);
466
+ deadline.check();
467
+ const path = await this.checked(staging + '/package/' + file.path);
468
+ await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
469
+ await fs.writeFile(path, bytes, { flag: 'wx', mode: file.mode === '100755' ? 0o700 : 0o600 });
470
+ }
471
+ parseSkillFrontmatter(await fs.readFile(await this.checked(staging + '/package/SKILL.md'), 'utf8'));
472
+ parseSkillConfig(await fs.readFile(await this.checked(staging + '/package/config.json'), 'utf8'));
473
+ deadline.check();
474
+ await this.atomicJson(staging + '/manifest.json', { version: 1, item, files: pkg.files, installedAt: new Date().toISOString() });
475
+ await this.directory('versions/' + key(skillId));
476
+ const existing = await this.readJson(target + '/manifest.json');
477
+ deadline.check();
478
+ if (!existing)
479
+ await fs.rename(await this.checked(staging), await this.checked(target));
480
+ else
481
+ await this.packageEntry(skillId, item.packageVersion);
482
+ deadline.check();
483
+ state.items[skillId] = item;
484
+ await this.atomicJson('installed.json', state);
485
+ return item;
486
+ }
487
+ finally {
488
+ await fs.rm(await this.checked(staging), { recursive: true, force: true });
489
+ }
490
+ });
491
+ }
492
+ async uninstall(skillId, expectedRevision) {
493
+ return this.lock(async () => {
494
+ const state = await this.state();
495
+ const current = state.items[skillId];
496
+ this.checkRevision(current, expectedRevision);
497
+ if (!current)
498
+ throw new ServiceError('NOT_FOUND', '技能不存在。');
499
+ if (!current.installed)
500
+ return current;
501
+ const item = { ...current, installed: false, revision: current.revision + 1, updateAvailable: false };
502
+ state.items[skillId] = item;
503
+ await this.atomicJson('installed.json', state);
504
+ return item;
505
+ });
506
+ }
507
+ checkRevision(item, expected) {
508
+ if (!Number.isSafeInteger(expected) || expected < 0)
509
+ throw new ServiceError('INVALID_REQUEST', '技能操作需要有效的 expectedRevision。');
510
+ if ((item?.revision ?? 0) !== expected)
511
+ throw new ServiceError('REVISION_CONFLICT', '技能安装状态已改变,请刷新后重试。');
512
+ }
513
+ }
514
+ //# sourceMappingURL=builtin-skills.js.map
@@ -4,7 +4,7 @@ import { previewEdgeError } from "./vendor/protocol/index.js";
4
4
  import { Graphs, assertProjectWritable, resourceLinks, nodeDimension } from './graphs.js';
5
5
  import { Resources } from './resources.js';
6
6
  import { mimeMatchesBytes, sniffMime } from './blob-store.js';
7
- import { checkedJson, PluginRegistry } from './plugins.js';
7
+ import { checkedJson, PluginRegistry, portableSkillContent } from './plugins.js';
8
8
  import { atomic } from './persistence/database.js';
9
9
  import { canonicalJsonHash } from './persistence/repositories.js';
10
10
  import { ServiceError } from './errors.js';
@@ -13,7 +13,10 @@ const DEFAULT_LIMITS = { maxBundleBytes: WORKGRAPH_BUNDLE_MAX_BYTES, maxResource
13
13
  const core = new Set(['text', 'image', 'document', 'video', 'file', 'preview', 'execution', 'group']);
14
14
  const key = (id, version) => JSON.stringify([id, version]);
15
15
  function portableNode(node) {
16
- return structuredClone(node);
16
+ const result = structuredClone(node);
17
+ if (core.has(node.type))
18
+ result.content = portableSkillContent(result.content);
19
+ return result;
17
20
  }
18
21
  function invalid(message) { throw new ServiceError('INVALID_REQUEST', message); }
19
22
  function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
@@ -181,6 +184,8 @@ export class GraphTransfer {
181
184
  if (nodes.has(n.id) || typeof n.readOnly !== 'boolean' || !Number.isFinite(n.x) || !Number.isFinite(n.y) || Math.abs(n.x) > 1e9 || Math.abs(n.y) > 1e9)
182
185
  invalid('Invalid or duplicate node');
183
186
  checkedJson(n.content, 2 * 1024 * 1024, 32);
187
+ if (core.has(n.type))
188
+ n.content = portableSkillContent(n.content);
184
189
  if (n.width !== undefined)
185
190
  nodeDimension(n.width);
186
191
  if (n.height !== undefined)
package/dist/inputs.d.ts CHANGED
@@ -52,6 +52,7 @@ export interface InputPreview {
52
52
  }
53
53
  export type BuildInputOptions = {
54
54
  expectedExecutionRevision?: number;
55
+ skills?: import("./vendor/protocol/index.js").FrozenSkill[];
55
56
  } & ({
56
57
  model: ModelSelection;
57
58
  imageRoute?: Extract<ImageRoute, {
package/dist/inputs.js CHANGED
@@ -58,7 +58,7 @@ export class InputPreparation {
58
58
  else {
59
59
  if (!options.model || typeof options.model.model !== 'string' || !options.model.model.trim() || (options.model.reasoningEffort !== null && typeof options.model.reasoningEffort !== 'string'))
60
60
  throw new ServiceError('MODEL_UNAVAILABLE', 'A valid model selection is required');
61
- payload = { ...base, model: { model: options.model.model, reasoningEffort: options.model.reasoningEffort }, ...(options.imageRoute ? { imageRoute: options.imageRoute, inputMode } : {}) };
61
+ payload = { ...base, model: { model: options.model.model, reasoningEffort: options.model.reasoningEffort }, ...(options.skills?.length ? { skills: structuredClone(options.skills) } : {}), ...(options.imageRoute ? { imageRoute: options.imageRoute, inputMode } : {}) };
62
62
  }
63
63
  // Include exact source revisions and edge provenance in the digest without sending ancestors to the model.
64
64
  const inputDigest = createHash('sha256').update(JSON.stringify({ graphId, nodeId, ...payload, sources: preview.sources })).digest('hex');
@@ -7,7 +7,7 @@ import { waitForBackups, clearStaleResourceLeases } from './backups.js';
7
7
  import { localCapacity } from './capacity.js';
8
8
  import { registerLocalPlugin } from './plugins.js';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { isGlobalNpmInstallation, startWebUpdate } from './update.js';
10
+ import { isGlobalNpmInstallation, isNpxInstallation, startWebUpdate } from './update.js';
11
11
  export function activeRunCount(db) {
12
12
  return Number(db.prepare("SELECT count(*) AS n FROM runs WHERE status NOT IN ('accepted','queued','paused_restore','succeeded','failed','cancelled','interrupted')").get().n);
13
13
  }
@@ -17,9 +17,11 @@ export async function serveManaged(dataDir, host, port, onStopped) {
17
17
  let service;
18
18
  const cliPath = fileURLToPath(new URL('../cli.js', import.meta.url));
19
19
  const npmInstallation = isGlobalNpmInstallation(cliPath);
20
+ const npxInstallation = npmInstallation.then(global => global ? false : isNpxInstallation(cliPath));
20
21
  try {
21
22
  service = createService(lock.root, {
22
23
  npmInstallation: () => npmInstallation,
24
+ npxInstallation: () => npxInstallation,
23
25
  updateRuntime: async (onExit) => {
24
26
  if (state !== 'running')
25
27
  throw new Error('Runtime is stopping.');
@@ -40,10 +42,13 @@ export async function serveManaged(dataDir, host, port, onStopped) {
40
42
  let stopping = false;
41
43
  const drains = new Set();
42
44
  let closing;
45
+ let listenAddress;
43
46
  let recoveryAttempted = false;
44
47
  const onSignal = () => { void stop(false).catch(() => log.append('shutdown_failed')); };
45
48
  const removeSignals = () => { process.removeListener('SIGINT', onSignal); process.removeListener('SIGTERM', onSignal); };
46
- const status = () => ({ state, activeRuns: activeRunCount(service.db), listenHost: service.server.address().address, ...listenerInfo(service.server.address(), service.info.serviceId, service.instanceId) });
49
+ // server.address() becomes null as soon as HTTP shutdown begins; local
50
+ // management must remain available until connections close and locks release.
51
+ const status = () => ({ state, activeRuns: closing ? 0 : activeRunCount(service.db), listenHost: listenAddress.address, ...listenerInfo(listenAddress, service.info.serviceId, service.instanceId) });
47
52
  async function stop(interrupt) {
48
53
  // Escalation remains available while an earlier drain waits indefinitely.
49
54
  if (stopping && (!interrupt || state === 'interrupting'))
@@ -91,6 +96,7 @@ export async function serveManaged(dataDir, host, port, onStopped) {
91
96
  clearStaleResourceLeases(service.db, lock);
92
97
  await service.api.backups.recoverCreating(lock);
93
98
  await new Promise((done, reject) => { service.server.once('error', reject); service.server.listen(port, host, done); });
99
+ listenAddress = service.server.address();
94
100
  // Own the kernel lock before transferring scheduler epochs. Runtime.start
95
101
  // resets acceptingRuns after a normal shutdown and starts its dispatch timer.
96
102
  recoveryAttempted = true;
@@ -1,5 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readInstance, requestInstance, startBackground, waitUntilStopped } from './lifecycle.js';
3
+ /** Recognize the package actually running from npm exec/npx's cache, including custom cache roots. */
4
+ export declare function isNpxInstallation(cliPath: string, spawnProcess?: SpawnProcess): Promise<boolean>;
3
5
  /** Compare the running package against npm's actual global package location. */
4
6
  export declare function isGlobalNpmInstallation(cliPath: string, spawnProcess?: SpawnProcess): Promise<boolean>;
5
7
  /** The updater must outlive the Runtime that it stops. */