@morehao/dsh-skills-console 0.4.0 → 0.5.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.
- package/README.md +44 -20
- package/client.js +1299 -248
- package/lib/core/hash.js +13 -1
- package/lib/core/intro-store.js +156 -0
- package/lib/core/intro.js +430 -0
- package/lib/core/inventory.js +45 -17
- package/lib/core/invocation.js +578 -0
- package/lib/core/paths.js +2 -0
- package/lib/core/remote.js +103 -1
- package/lib/core/service.js +1103 -9
- package/lib/core/state.js +106 -6
- package/lib/index.js +16 -0
- package/lib/invocation-support.json +46 -0
- package/lib/routes.js +60 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
package/lib/core/service.js
CHANGED
|
@@ -13,6 +13,19 @@ import { buildLedger, readLock, scanCanonical } from './inventory.js';
|
|
|
13
13
|
import { readLockFile, updateLock } from './lock.js';
|
|
14
14
|
import { readBackup, restoreBackup, trashSkill } from './trash.js';
|
|
15
15
|
import { parseSkillFrontmatter } from './skill-md.js';
|
|
16
|
+
import {
|
|
17
|
+
InvocationError,
|
|
18
|
+
backupInvocation,
|
|
19
|
+
hashWithoutInvocation,
|
|
20
|
+
inspectPark,
|
|
21
|
+
parkSkill,
|
|
22
|
+
parseInvocation,
|
|
23
|
+
planManualRestore,
|
|
24
|
+
planManualWrite,
|
|
25
|
+
readSkillText,
|
|
26
|
+
unparkSkill,
|
|
27
|
+
writeFileAtomic,
|
|
28
|
+
} from './invocation.js';
|
|
16
29
|
import { readSkillFile, readSkillTree, resolveSkillRoot, SkillFileError } from './skill-files.js';
|
|
17
30
|
import {
|
|
18
31
|
checkSkill,
|
|
@@ -26,7 +39,17 @@ import {
|
|
|
26
39
|
skillTreeSha,
|
|
27
40
|
snapshotId,
|
|
28
41
|
} from './updates.js';
|
|
29
|
-
import { fetchAtomCommit, snapshotFromTarball } from './remote.js';
|
|
42
|
+
import { fetchAtomCommit, fetchRemoteSkillMd, snapshotFromTarball } from './remote.js';
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_INTRO_CONFIG,
|
|
45
|
+
contentFingerprint,
|
|
46
|
+
buildIntroPrompt,
|
|
47
|
+
normalizeIntroConfig,
|
|
48
|
+
runIntroCall,
|
|
49
|
+
introLang,
|
|
50
|
+
IntroError,
|
|
51
|
+
introEntry,
|
|
52
|
+
} from './intro.js';
|
|
30
53
|
|
|
31
54
|
const QUEUE_LIMIT = 8;
|
|
32
55
|
|
|
@@ -61,6 +84,28 @@ function skillFileFailure(error) {
|
|
|
61
84
|
return error;
|
|
62
85
|
}
|
|
63
86
|
|
|
87
|
+
/** 调用策略层的文件事务错误 → 路由错误;保留 409 语义。 */
|
|
88
|
+
function invocationFailure(error) {
|
|
89
|
+
if (error instanceof InvocationError) {
|
|
90
|
+
return new ServiceError(error.code, error.message, error.detail ?? null, error.status);
|
|
91
|
+
}
|
|
92
|
+
return error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 客户端覆盖能力表:只承诺已确认的客户端,不写成「所有 Agent 都生效」。
|
|
97
|
+
* 读不到就返回空表(UI 只显示通用提示),不影响写路径。
|
|
98
|
+
*/
|
|
99
|
+
const INVOCATION_SUPPORT = (() => {
|
|
100
|
+
try {
|
|
101
|
+
const raw = fs.readFileSync(new URL('../invocation-support.json', import.meta.url), 'utf8');
|
|
102
|
+
const parsed = JSON.parse(raw);
|
|
103
|
+
return { generatedAt: parsed.generatedAt || null, note: parsed.note || null, clients: Array.isArray(parsed.clients) ? parsed.clients : [] };
|
|
104
|
+
} catch {
|
|
105
|
+
return { generatedAt: null, note: null, clients: [] };
|
|
106
|
+
}
|
|
107
|
+
})();
|
|
108
|
+
|
|
64
109
|
/** 是我方写的 legacy 停用桩(真实目录 + marker)?删除体检时只放过这一类。 */
|
|
65
110
|
function isLegacyStubDir(dirPath) {
|
|
66
111
|
try {
|
|
@@ -225,6 +270,14 @@ export function createService({
|
|
|
225
270
|
invalidatePolicies = async () => {},
|
|
226
271
|
/** `{ available, liveAgents, registered, errors[] }`;没有 provider 时启停必须 409。 */
|
|
227
272
|
policyStatus = () => ({ available: false, liveAgents: 0, registered: 0, errors: [] }),
|
|
273
|
+
/** 技能介绍缓存(可丢);缺省时介绍接口返回 501,其余功能不受影响。 */
|
|
274
|
+
introStore = null,
|
|
275
|
+
/** 归一化后的介绍配置(provider/model 可空,空则用宿主默认模型)。 */
|
|
276
|
+
introOptions = normalizeIntroConfig(DEFAULT_INTRO_CONFIG),
|
|
277
|
+
/** 宿主 `llm` 服务(`ctx.get('llm')`);按需取,不能要求它在插件加载时已就绪。 */
|
|
278
|
+
getLlm = () => null,
|
|
279
|
+
/** 宿主 `agentDefaultModel.currentSelection()`;返回 `{ provider, model }` 或 null。 */
|
|
280
|
+
getDefaultModel = () => null,
|
|
228
281
|
}) {
|
|
229
282
|
const enqueue = createQueue(logger);
|
|
230
283
|
const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
|
|
@@ -267,6 +320,203 @@ export function createService({
|
|
|
267
320
|
return capabilities;
|
|
268
321
|
}
|
|
269
322
|
|
|
323
|
+
// ---- 技能介绍(模型生成) -------------------------------------------------
|
|
324
|
+
// 全局只允许一个生成在跑:辅助调用不进 agent 的重试/限流体系,并发越高越容易撞 429。
|
|
325
|
+
// 在第一个 await 之前占位,避免两个请求同时通过检查。
|
|
326
|
+
let introInFlight = null;
|
|
327
|
+
|
|
328
|
+
/** `/state` 下发的介绍能力:前端据此决定按钮显隐与提示。 */
|
|
329
|
+
function introCapability() {
|
|
330
|
+
return {
|
|
331
|
+
enabled: Boolean(introOptions.enabled),
|
|
332
|
+
available: Boolean(introStore) && Boolean(getLlm()),
|
|
333
|
+
langs: ['zh', 'en'],
|
|
334
|
+
timeoutMs: introOptions.timeoutMs,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 介绍用的模型路由:显式配置 > 宿主默认模型;解析不出来就是「没有可用模型」。 */
|
|
339
|
+
function resolveIntroRoute() {
|
|
340
|
+
if (introOptions.provider && introOptions.model) {
|
|
341
|
+
return { provider: introOptions.provider, model: introOptions.model };
|
|
342
|
+
}
|
|
343
|
+
let selection = null;
|
|
344
|
+
try {
|
|
345
|
+
selection = getDefaultModel();
|
|
346
|
+
} catch (error) {
|
|
347
|
+
logger('intro.default-model.failed', { message: error?.message });
|
|
348
|
+
selection = null;
|
|
349
|
+
}
|
|
350
|
+
if (selection?.provider && selection?.model) {
|
|
351
|
+
return { provider: selection.provider, model: selection.model };
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** 读 SKILL.md(只读;根反查与路径校验沿用查看器那一层)。 */
|
|
357
|
+
async function readIntroSource(name) {
|
|
358
|
+
assertSafeSkillName(name);
|
|
359
|
+
try {
|
|
360
|
+
const root = await resolveSkillRoot(paths, name);
|
|
361
|
+
const file = await readSkillFile({ root: root.root, kind: root.kind });
|
|
362
|
+
return { root, file };
|
|
363
|
+
} catch (error) {
|
|
364
|
+
throw skillFileFailure(error);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 只列文件名(不发内容):让模型知道技能还带哪些资料,不扩大数据外发面。 */
|
|
369
|
+
async function introFileNames(root, bodyRelPath) {
|
|
370
|
+
const names = new Set(bodyRelPath ? [bodyRelPath] : ['SKILL.md']);
|
|
371
|
+
try {
|
|
372
|
+
const tree = await readSkillTree({ root: root.root, kind: root.kind });
|
|
373
|
+
for (const item of tree.files) {
|
|
374
|
+
if (item.kind === 'file' && item.path) names.add(item.path);
|
|
375
|
+
}
|
|
376
|
+
} catch (error) {
|
|
377
|
+
logger('intro.files.failed', { message: error?.message });
|
|
378
|
+
}
|
|
379
|
+
return [...names].slice(0, 30);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** 缓存条目 → 接口形状;`stale` 只表示 SKILL.md 的内容哈希对不上。 */
|
|
383
|
+
function publicIntro(entry, hash) {
|
|
384
|
+
return {
|
|
385
|
+
text: entry.text,
|
|
386
|
+
stale: Boolean(hash) && entry.hash !== hash,
|
|
387
|
+
generatedAt: entry.generatedAt,
|
|
388
|
+
provider: entry.provider,
|
|
389
|
+
model: entry.model,
|
|
390
|
+
truncated: Boolean(entry.truncated),
|
|
391
|
+
usage: entry.usage || null,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 订阅候选:按订阅 id + 条目名反查快照,`skillPath` 只从服务端状态取。
|
|
397
|
+
* 客户端只给 id 与名字,永远不能自带路径。
|
|
398
|
+
*/
|
|
399
|
+
function remoteIntroTarget(subscriptionId, name) {
|
|
400
|
+
if (typeof subscriptionId !== 'string' || !subscriptionId || typeof name !== 'string' || !name) {
|
|
401
|
+
throw new ServiceError('error.skills.bad-request', '缺少订阅 id 或技能名', null, 400);
|
|
402
|
+
}
|
|
403
|
+
const subscriptions = Array.isArray(store.state?.subscriptions) ? store.state.subscriptions : [];
|
|
404
|
+
const subscription = subscriptions.find((item) => item.id === subscriptionId) || null;
|
|
405
|
+
if (!subscription) {
|
|
406
|
+
throw new ServiceError(
|
|
407
|
+
'error.skills.subscription-not-found',
|
|
408
|
+
'订阅不存在,可能已被取消',
|
|
409
|
+
{ subscriptionId },
|
|
410
|
+
404,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
const snapshot = subscription.snapshot || null;
|
|
414
|
+
const entry = (Array.isArray(snapshot?.entries) ? snapshot.entries : []).find((item) => item.name === name) || null;
|
|
415
|
+
if (!entry?.skillPath || !snapshot?.commit) {
|
|
416
|
+
throw new ServiceError(
|
|
417
|
+
'error.skills.intro-no-source',
|
|
418
|
+
'订阅快照里没有这个技能,请先点「检查更新」刷新快照',
|
|
419
|
+
{ name },
|
|
420
|
+
409,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
subscription,
|
|
425
|
+
entry,
|
|
426
|
+
source: subscription.source,
|
|
427
|
+
commit: snapshot.commit,
|
|
428
|
+
skillPath: entry.skillPath,
|
|
429
|
+
description: typeof entry.description === 'string' ? entry.description : '',
|
|
430
|
+
cacheKey: `remote:${subscription.source}@${snapshot.commit}:${entry.skillPath}`,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** 远端介绍没有「过期」概念:缓存 key 已经带 commit,换 commit 就是另一条。 */
|
|
435
|
+
function publicRemoteIntro(entry) {
|
|
436
|
+
return {
|
|
437
|
+
text: entry.text,
|
|
438
|
+
stale: false,
|
|
439
|
+
generatedAt: entry.generatedAt,
|
|
440
|
+
provider: entry.provider,
|
|
441
|
+
model: entry.model,
|
|
442
|
+
truncated: Boolean(entry.truncated),
|
|
443
|
+
usage: entry.usage || null,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function remoteIntroSourceFailure(error) {
|
|
448
|
+
const detail = { code: error?.code || null, status: error?.status ?? null, bytes: error?.bytes ?? null };
|
|
449
|
+
if (error?.code === 'intro-source-timeout') {
|
|
450
|
+
return new ServiceError('error.skills.intro-source-timeout', error.message, detail, 504);
|
|
451
|
+
}
|
|
452
|
+
if (error?.code === 'intro-source-too-large') {
|
|
453
|
+
return new ServiceError('error.skills.intro-source-too-large', error.message, detail, 413);
|
|
454
|
+
}
|
|
455
|
+
return new ServiceError('error.skills.intro-source', error?.message || '读不到远端的 SKILL.md', detail, 502);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 一次模型调用的统一超时:到点 abort,映射成 504(与用户取消区分)。 */
|
|
459
|
+
async function callIntroModel({ llm, route, prompt, signal }) {
|
|
460
|
+
let timedOut = false;
|
|
461
|
+
const controller = new AbortController();
|
|
462
|
+
const timer = setTimeout(() => {
|
|
463
|
+
timedOut = true;
|
|
464
|
+
controller.abort();
|
|
465
|
+
}, introOptions.timeoutMs);
|
|
466
|
+
const combined = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
|
|
467
|
+
try {
|
|
468
|
+
return await runIntroCall({
|
|
469
|
+
llm,
|
|
470
|
+
route,
|
|
471
|
+
prompt,
|
|
472
|
+
maxOutputTokens: introOptions.maxOutputTokens,
|
|
473
|
+
signal: combined,
|
|
474
|
+
});
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (timedOut) {
|
|
477
|
+
throw new ServiceError(
|
|
478
|
+
'error.skills.intro-timeout',
|
|
479
|
+
`生成超时(${Math.round(introOptions.timeoutMs / 1000)} 秒),请重试`,
|
|
480
|
+
{ timeoutMs: introOptions.timeoutMs },
|
|
481
|
+
504,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
throw error;
|
|
485
|
+
} finally {
|
|
486
|
+
clearTimeout(timer);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** 全局单飞:辅助调用不进 agent 的重试/限流体系,并发越高越容易撞 429。 */
|
|
491
|
+
async function withIntroInFlight(meta, task) {
|
|
492
|
+
if (introInFlight) {
|
|
493
|
+
throw new ServiceError(
|
|
494
|
+
'error.skills.intro-busy',
|
|
495
|
+
'正在生成另一个技能介绍,请稍后再试',
|
|
496
|
+
introInFlight,
|
|
497
|
+
409,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const startedAt = Date.now();
|
|
501
|
+
introInFlight = { ...meta, startedAt };
|
|
502
|
+
try {
|
|
503
|
+
return await task(startedAt);
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (error instanceof IntroError) throw introFailure(error);
|
|
506
|
+
throw error;
|
|
507
|
+
} finally {
|
|
508
|
+
introInFlight = null;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** 介绍层的异常统一转成路由能识别的 ServiceError(保留 429/413/504 语义)。 */
|
|
513
|
+
function introFailure(error) {
|
|
514
|
+
if (error instanceof IntroError) {
|
|
515
|
+
return new ServiceError(error.code, error.message, error.detail, error.status);
|
|
516
|
+
}
|
|
517
|
+
return error;
|
|
518
|
+
}
|
|
519
|
+
|
|
270
520
|
|
|
271
521
|
|
|
272
522
|
function detectedTargets() {
|
|
@@ -656,6 +906,200 @@ export function createService({
|
|
|
656
906
|
return records;
|
|
657
907
|
}
|
|
658
908
|
|
|
909
|
+
function canonicalDir(name) {
|
|
910
|
+
return path.join(paths.canonical, name);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function skillKindOf(dir) {
|
|
914
|
+
try {
|
|
915
|
+
const stat = fs.lstatSync(dir);
|
|
916
|
+
if (stat.isSymbolicLink()) return 'symlink';
|
|
917
|
+
if (stat.isDirectory()) return 'dir';
|
|
918
|
+
return 'other';
|
|
919
|
+
} catch {
|
|
920
|
+
/* 继续看平铺 .md */
|
|
921
|
+
}
|
|
922
|
+
return fs.existsSync(`${dir}.md`) ? 'file' : 'missing';
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function isParkedNow(name, install) {
|
|
926
|
+
return inspectPark(canonicalDir(name)).parked || install?.mode === 'hidden';
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function assertNotParked(name, action) {
|
|
930
|
+
if (isParkedNow(name, store.findInstall(name))) {
|
|
931
|
+
throw new ServiceError(
|
|
932
|
+
'error.skills.parked',
|
|
933
|
+
`${name} 已完全停用;${action}前请先恢复。否则安装器会把 SKILL.md 写回来,界面却还以为停着`,
|
|
934
|
+
{ name, action },
|
|
935
|
+
409,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function markDrift(name) {
|
|
941
|
+
if (store.readOnly) return;
|
|
942
|
+
try {
|
|
943
|
+
store.update((draft) => {
|
|
944
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
945
|
+
if (!row?.manual) return;
|
|
946
|
+
row.manual = { ...row.manual, drift: true };
|
|
947
|
+
});
|
|
948
|
+
} catch (error) {
|
|
949
|
+
logger('invocation.drift.persist.failed', { name, message: error.message });
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* 更新 / 回滚 / 重装后重放「仅手动」。失败不谎报成功:把 manual.drift 置 true,
|
|
955
|
+
* 由 UI 标「需修复」;更新本身已经成功,不回滚正文。
|
|
956
|
+
*/
|
|
957
|
+
function reapplyInvocation(name) {
|
|
958
|
+
const install = store.findInstall(name);
|
|
959
|
+
if (!install || install.mode !== 'manual' || install.hidden) return { ok: true, skipped: true };
|
|
960
|
+
const dir = canonicalDir(name);
|
|
961
|
+
let text;
|
|
962
|
+
try {
|
|
963
|
+
text = readSkillText(dir);
|
|
964
|
+
} catch (error) {
|
|
965
|
+
markDrift(name);
|
|
966
|
+
return { ok: false, code: error.code || 'error.skills.not-writable', message: error.message };
|
|
967
|
+
}
|
|
968
|
+
let parsed;
|
|
969
|
+
try {
|
|
970
|
+
parsed = parseInvocation(text);
|
|
971
|
+
} catch (error) {
|
|
972
|
+
markDrift(name);
|
|
973
|
+
return { ok: false, code: error.code || 'error.skills.unsupported', message: error.message };
|
|
974
|
+
}
|
|
975
|
+
// 只重放「缺失」;已经是 true 就当已生效。false / 其它值是被外部改过,
|
|
976
|
+
// 宁可标 drift 让用户看,也不覆盖作者或用户的编辑。
|
|
977
|
+
if (parsed.value !== 'absent' && parsed.value !== 'true') {
|
|
978
|
+
markDrift(name);
|
|
979
|
+
return { ok: false, code: 'error.skills.drift', message: '标记被改成非 true,未自动覆盖' };
|
|
980
|
+
}
|
|
981
|
+
let plan;
|
|
982
|
+
try {
|
|
983
|
+
plan = planManualWrite(text);
|
|
984
|
+
} catch (error) {
|
|
985
|
+
markDrift(name);
|
|
986
|
+
return { ok: false, code: error.code || 'error.skills.unsupported', message: error.message };
|
|
987
|
+
}
|
|
988
|
+
if (plan.author) {
|
|
989
|
+
// 这一行可能正是插件自己写的(状态里 origin=plugin);不能把所有权误判给作者
|
|
990
|
+
if (install.manual?.origin === 'plugin') return { ok: true, origin: 'plugin', changed: false };
|
|
991
|
+
store.update((draft) => {
|
|
992
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
993
|
+
if (!row) return;
|
|
994
|
+
row.manual = { origin: 'author', original: 'true', appliedAt: nowIso(), drift: false };
|
|
995
|
+
});
|
|
996
|
+
return { ok: true, origin: 'author', changed: false };
|
|
997
|
+
}
|
|
998
|
+
if (!plan.changed) return { ok: true, origin: 'plugin', changed: false };
|
|
999
|
+
try {
|
|
1000
|
+
backupInvocation({ backupDir: paths.backupsDir, name, content: text });
|
|
1001
|
+
writeFileAtomic(path.join(dir, 'SKILL.md'), plan.nextText);
|
|
1002
|
+
store.update((draft) => {
|
|
1003
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
1004
|
+
if (!row) return;
|
|
1005
|
+
row.manual = { origin: 'plugin', original: plan.original, appliedAt: nowIso(), drift: false };
|
|
1006
|
+
});
|
|
1007
|
+
return { ok: true, origin: 'plugin', changed: true, original: plan.original };
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
markDrift(name);
|
|
1010
|
+
return { ok: false, code: error.code || 'error.skills.write-failed', message: error.message };
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* 启动 reconcile:只处理 state 已经是 manual 的行。
|
|
1016
|
+
* - 文件里已有 true:视为已生效(若是作者声明,状态改成 author);
|
|
1017
|
+
* - 文件里没有 / 是 false:重放(覆盖「更新成功但 reapply 前崩溃」的窗口);
|
|
1018
|
+
* - 作者声明但被外部删掉:只标 drift,绝不替作者写回。
|
|
1019
|
+
*/
|
|
1020
|
+
function reconcileInvocations() {
|
|
1021
|
+
if (store.readOnly) return [];
|
|
1022
|
+
const reports = [];
|
|
1023
|
+
for (const install of state().installs) {
|
|
1024
|
+
if (install.mode !== 'manual' || install.hidden) continue;
|
|
1025
|
+
let parsed;
|
|
1026
|
+
try {
|
|
1027
|
+
parsed = parseInvocation(readSkillText(canonicalDir(install.name)));
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
markDrift(install.name);
|
|
1030
|
+
reports.push({ name: install.name, status: 'unreadable', message: error.message });
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
if (parsed.value === 'true') {
|
|
1034
|
+
if (!install.manual) {
|
|
1035
|
+
store.update((draft) => {
|
|
1036
|
+
const row = draft.installs.find((item) => item.name === install.name);
|
|
1037
|
+
if (row) row.manual = { origin: 'author', original: 'true', appliedAt: nowIso(), drift: false };
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
if (install.manual?.origin === 'author') {
|
|
1043
|
+
markDrift(install.name);
|
|
1044
|
+
reports.push({ name: install.name, status: 'author-marker-missing' });
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const result = reapplyInvocation(install.name);
|
|
1048
|
+
reports.push({ name: install.name, ...result });
|
|
1049
|
+
if (!result.ok) logger('invocation.reconcile.failed', { name: install.name, message: result.message });
|
|
1050
|
+
}
|
|
1051
|
+
if (reports.length > 0) logger('invocation.reconcile', { reports });
|
|
1052
|
+
return reports;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* 「仅手动」文件事务:备份 → 原子写 → state;state 写失败把原文件写回去。
|
|
1057
|
+
* 不在这里做「是否已经如此」的判断,调用方拿 `plan` 决定。
|
|
1058
|
+
*/
|
|
1059
|
+
function commitManual({ name, dir, plan, text }) {
|
|
1060
|
+
try {
|
|
1061
|
+
backupInvocation({ backupDir: paths.backupsDir, name, content: text });
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
throw new InvocationError(
|
|
1064
|
+
'error.skills.not-writable',
|
|
1065
|
+
`无法备份 ${name} 的 SKILL.md:${error.message}`,
|
|
1066
|
+
{ name },
|
|
1067
|
+
409,
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
writeFileAtomic(path.join(dir, 'SKILL.md'), plan.nextText);
|
|
1071
|
+
try {
|
|
1072
|
+
store.update((draft) => {
|
|
1073
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
1074
|
+
if (!row) return;
|
|
1075
|
+
row.mode = 'manual';
|
|
1076
|
+
row.enabled = true;
|
|
1077
|
+
delete row.legacy;
|
|
1078
|
+
row.hidden = null;
|
|
1079
|
+
row.manual = { origin: 'plugin', original: plan.original, appliedAt: nowIso(), drift: false };
|
|
1080
|
+
});
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
try {
|
|
1083
|
+
writeFileAtomic(path.join(dir, 'SKILL.md'), text);
|
|
1084
|
+
} catch (rollbackError) {
|
|
1085
|
+
logger('invocation.rollback.failed', { name, message: rollbackError.message });
|
|
1086
|
+
}
|
|
1087
|
+
throw error;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function commitAuthorManual(name) {
|
|
1092
|
+
store.update((draft) => {
|
|
1093
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
1094
|
+
if (!row) return;
|
|
1095
|
+
row.mode = 'manual';
|
|
1096
|
+
row.enabled = true;
|
|
1097
|
+
delete row.legacy;
|
|
1098
|
+
row.hidden = null;
|
|
1099
|
+
row.manual = { origin: 'author', original: 'true', appliedAt: nowIso(), drift: false };
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
|
|
659
1103
|
return {
|
|
660
1104
|
paths,
|
|
661
1105
|
store,
|
|
@@ -680,7 +1124,18 @@ export function createService({
|
|
|
680
1124
|
|
|
681
1125
|
/** 供插件 apply 时主动跑一次迁移(不必等到第一个请求)。 */
|
|
682
1126
|
ensureMigrated() {
|
|
683
|
-
|
|
1127
|
+
const current = this.reloadState();
|
|
1128
|
+
try {
|
|
1129
|
+
reconcileInvocations();
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
logger('invocation.reconcile.failed', { message: error?.message });
|
|
1132
|
+
}
|
|
1133
|
+
return current;
|
|
1134
|
+
},
|
|
1135
|
+
|
|
1136
|
+
/** 诊断/测试用:手动跑一次「仅手动」重放。 */
|
|
1137
|
+
reconcileInvocations() {
|
|
1138
|
+
return reconcileInvocations();
|
|
684
1139
|
},
|
|
685
1140
|
|
|
686
1141
|
async getState({ cwd, workspace } = {}) {
|
|
@@ -712,6 +1167,12 @@ export function createService({
|
|
|
712
1167
|
// unscanned:canonical 被挪到扫描根之外,本插件不提供可见性兜底(/diagnostics 会报出来)
|
|
713
1168
|
mode: paths.canonicalScanned ? 'direct' : 'unscanned',
|
|
714
1169
|
settings: state().settings,
|
|
1170
|
+
invocation: {
|
|
1171
|
+
support: INVOCATION_SUPPORT,
|
|
1172
|
+
modes: ['auto', 'manual', 'hidden'],
|
|
1173
|
+
},
|
|
1174
|
+
// 技能介绍(模型生成):前端据此决定按钮显隐;可用 ≠ 已登录,失败会在生成时如实报错
|
|
1175
|
+
intro: introCapability(),
|
|
715
1176
|
agents: { all: registry.all(), detected: detectedTargets() },
|
|
716
1177
|
subscriptions: state().subscriptions,
|
|
717
1178
|
workspaces: await workspaceList(),
|
|
@@ -768,7 +1229,7 @@ export function createService({
|
|
|
768
1229
|
disabledButVisible: state().installs.filter((item) => item.enabled === false && visible.byName.get(item.name)?.visible),
|
|
769
1230
|
/** 界面启用、模型不可见(真身缺失,或同名停用技能排在前面);自己声明 manual-only 的不算异常 */
|
|
770
1231
|
enabledButHidden: state().installs.filter((item) => {
|
|
771
|
-
if (item.enabled === false) return false;
|
|
1232
|
+
if (item.enabled === false || item.mode === 'hidden') return false;
|
|
772
1233
|
const view = visible.byName.get(item.name);
|
|
773
1234
|
if (view?.visible) return false;
|
|
774
1235
|
const canonical = path.join(paths.canonical, item.name);
|
|
@@ -904,6 +1365,265 @@ export function createService({
|
|
|
904
1365
|
}
|
|
905
1366
|
},
|
|
906
1367
|
|
|
1368
|
+
/**
|
|
1369
|
+
* 读缓存的技能介绍(只读,**不触发模型调用**)。
|
|
1370
|
+
* `stale` 表示 SKILL.md 已被改动过;文本仍然返回,让用户先看到东西。
|
|
1371
|
+
*/
|
|
1372
|
+
async getSkillIntro({ name, lang } = {}) {
|
|
1373
|
+
assertSafeSkillName(name);
|
|
1374
|
+
const language = introLang(lang);
|
|
1375
|
+
const capability = introCapability();
|
|
1376
|
+
if (!introStore) return { ok: true, name, lang: language, intro: null, capability };
|
|
1377
|
+
const { file } = await readIntroSource(name);
|
|
1378
|
+
const entry = introStore.get(name, language);
|
|
1379
|
+
return {
|
|
1380
|
+
ok: true,
|
|
1381
|
+
name,
|
|
1382
|
+
lang: language,
|
|
1383
|
+
intro: entry ? publicIntro(entry, contentFingerprint(file.content)) : null,
|
|
1384
|
+
capability,
|
|
1385
|
+
};
|
|
1386
|
+
},
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* 生成技能介绍:显式操作,一次一个模型调用。
|
|
1390
|
+
* 命中缓存且内容未变时直接返回(`cached: true`),不烧额度。
|
|
1391
|
+
*/
|
|
1392
|
+
async generateSkillIntro({ name, lang, force = false, signal = null } = {}) {
|
|
1393
|
+
assertSafeSkillName(name);
|
|
1394
|
+
const language = introLang(lang);
|
|
1395
|
+
if (!introOptions.enabled) {
|
|
1396
|
+
throw new ServiceError('error.skills.intro-disabled', '技能介绍已关闭(config.intro.enabled = false)', null, 501);
|
|
1397
|
+
}
|
|
1398
|
+
if (!introStore) {
|
|
1399
|
+
throw new ServiceError('error.skills.intro-unavailable', '介绍缓存不可用', null, 501);
|
|
1400
|
+
}
|
|
1401
|
+
const llm = getLlm();
|
|
1402
|
+
if (!llm) {
|
|
1403
|
+
throw new ServiceError(
|
|
1404
|
+
'error.skills.intro-unavailable',
|
|
1405
|
+
'当前宿主没有模型服务(llm),无法生成介绍',
|
|
1406
|
+
null,
|
|
1407
|
+
501,
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
return withIntroInFlight({ name, lang: language, kind: 'local' }, async (startedAt) => {
|
|
1412
|
+
try {
|
|
1413
|
+
const { root, file } = await readIntroSource(name);
|
|
1414
|
+
const hash = contentFingerprint(file.content);
|
|
1415
|
+
const cached = introStore.get(name, language);
|
|
1416
|
+
if (cached && !force) {
|
|
1417
|
+
const intro = publicIntro(cached, hash);
|
|
1418
|
+
if (!intro.stale) {
|
|
1419
|
+
return { ok: true, name, lang: language, cached: true, intro, usage: null };
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const route = resolveIntroRoute();
|
|
1424
|
+
if (!route) {
|
|
1425
|
+
throw new ServiceError(
|
|
1426
|
+
'error.skills.intro-no-model',
|
|
1427
|
+
'还没有可用的默认模型,请先在 DSH 里选择模型',
|
|
1428
|
+
null,
|
|
1429
|
+
409,
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const prompt = buildIntroPrompt({
|
|
1434
|
+
name,
|
|
1435
|
+
description: parseSkillFrontmatter(file.content).description || '',
|
|
1436
|
+
files: await introFileNames(root, file.relPath),
|
|
1437
|
+
content: file.content,
|
|
1438
|
+
maxInputBytes: introOptions.maxInputBytes,
|
|
1439
|
+
lang: language,
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
const result = await callIntroModel({ llm, route, prompt, signal });
|
|
1443
|
+
const entry = introEntry({
|
|
1444
|
+
text: result.text,
|
|
1445
|
+
hash,
|
|
1446
|
+
provider: result.provider,
|
|
1447
|
+
model: result.model,
|
|
1448
|
+
usage: result.usage,
|
|
1449
|
+
inputBytes: prompt.inputBytes,
|
|
1450
|
+
truncated: prompt.truncated,
|
|
1451
|
+
sourceBytes: prompt.sourceBytes,
|
|
1452
|
+
});
|
|
1453
|
+
introStore.put(name, language, entry);
|
|
1454
|
+
logger('intro.generate', {
|
|
1455
|
+
name,
|
|
1456
|
+
lang: language,
|
|
1457
|
+
provider: result.provider,
|
|
1458
|
+
model: result.model,
|
|
1459
|
+
inputBytes: prompt.inputBytes,
|
|
1460
|
+
truncated: prompt.truncated,
|
|
1461
|
+
durationMs: Date.now() - startedAt,
|
|
1462
|
+
usage: result.usage || null,
|
|
1463
|
+
});
|
|
1464
|
+
return {
|
|
1465
|
+
ok: true,
|
|
1466
|
+
name,
|
|
1467
|
+
lang: language,
|
|
1468
|
+
cached: false,
|
|
1469
|
+
intro: publicIntro(entry, hash),
|
|
1470
|
+
usage: result.usage || null,
|
|
1471
|
+
};
|
|
1472
|
+
} catch (error) {
|
|
1473
|
+
if (!(error instanceof ServiceError)) {
|
|
1474
|
+
logger('intro.generate.failed', { name, message: error?.message, code: error?.code });
|
|
1475
|
+
}
|
|
1476
|
+
throw error;
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
},
|
|
1480
|
+
|
|
1481
|
+
/** 读订阅候选的介绍缓存(只读,不联网、不调模型)。 */
|
|
1482
|
+
async getRemoteIntro({ subscriptionId, name, lang } = {}) {
|
|
1483
|
+
const language = introLang(lang);
|
|
1484
|
+
const capability = introCapability();
|
|
1485
|
+
const target = remoteIntroTarget(subscriptionId, name);
|
|
1486
|
+
const entry = introStore ? introStore.get(target.cacheKey, language) : null;
|
|
1487
|
+
return {
|
|
1488
|
+
ok: true,
|
|
1489
|
+
subscriptionId,
|
|
1490
|
+
name,
|
|
1491
|
+
lang: language,
|
|
1492
|
+
source: target.source,
|
|
1493
|
+
commit: target.commit,
|
|
1494
|
+
skillPath: target.skillPath,
|
|
1495
|
+
description: target.description,
|
|
1496
|
+
intro: entry ? publicRemoteIntro(entry) : null,
|
|
1497
|
+
capability,
|
|
1498
|
+
};
|
|
1499
|
+
},
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* 生成订阅候选的介绍:先按快照 commit 拉远端 SKILL.md,再走同一套提示词与模型调用。
|
|
1503
|
+
* 拉不到 / 超限只影响这次介绍,不影响安装按钮。
|
|
1504
|
+
*/
|
|
1505
|
+
async generateRemoteIntro({ subscriptionId, name, lang, force = false, signal = null } = {}) {
|
|
1506
|
+
const language = introLang(lang);
|
|
1507
|
+
if (!introOptions.enabled) {
|
|
1508
|
+
throw new ServiceError('error.skills.intro-disabled', '技能介绍已关闭(config.intro.enabled = false)', null, 501);
|
|
1509
|
+
}
|
|
1510
|
+
if (!introStore) {
|
|
1511
|
+
throw new ServiceError('error.skills.intro-unavailable', '介绍缓存不可用', null, 501);
|
|
1512
|
+
}
|
|
1513
|
+
const llm = getLlm();
|
|
1514
|
+
if (!llm) {
|
|
1515
|
+
throw new ServiceError(
|
|
1516
|
+
'error.skills.intro-unavailable',
|
|
1517
|
+
'当前宿主没有模型服务(llm),无法生成介绍',
|
|
1518
|
+
null,
|
|
1519
|
+
501,
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const target = remoteIntroTarget(subscriptionId, name);
|
|
1524
|
+
const cached = introStore.get(target.cacheKey, language);
|
|
1525
|
+
if (cached && !force) {
|
|
1526
|
+
return {
|
|
1527
|
+
ok: true,
|
|
1528
|
+
subscriptionId,
|
|
1529
|
+
name,
|
|
1530
|
+
lang: language,
|
|
1531
|
+
cached: true,
|
|
1532
|
+
source: target.source,
|
|
1533
|
+
commit: target.commit,
|
|
1534
|
+
skillPath: target.skillPath,
|
|
1535
|
+
intro: publicRemoteIntro(cached),
|
|
1536
|
+
usage: null,
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
return withIntroInFlight(
|
|
1541
|
+
{ name, lang: language, kind: 'remote', source: target.source },
|
|
1542
|
+
async (startedAt) => {
|
|
1543
|
+
try {
|
|
1544
|
+
const route = resolveIntroRoute();
|
|
1545
|
+
if (!route) {
|
|
1546
|
+
throw new ServiceError(
|
|
1547
|
+
'error.skills.intro-no-model',
|
|
1548
|
+
'还没有可用的默认模型,请先在 DSH 里选择模型',
|
|
1549
|
+
null,
|
|
1550
|
+
409,
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
let file;
|
|
1555
|
+
try {
|
|
1556
|
+
file = await fetchRemoteSkillMd({
|
|
1557
|
+
source: target.source,
|
|
1558
|
+
ref: target.commit,
|
|
1559
|
+
skillPath: target.skillPath,
|
|
1560
|
+
token: tokenOf,
|
|
1561
|
+
fetchImpl,
|
|
1562
|
+
});
|
|
1563
|
+
} catch (error) {
|
|
1564
|
+
throw remoteIntroSourceFailure(error);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const prompt = buildIntroPrompt({
|
|
1568
|
+
name,
|
|
1569
|
+
description: target.description,
|
|
1570
|
+
files: [],
|
|
1571
|
+
content: file.text,
|
|
1572
|
+
maxInputBytes: introOptions.maxInputBytes,
|
|
1573
|
+
lang: language,
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
const result = await callIntroModel({ llm, route, prompt, signal });
|
|
1577
|
+
const entry = introEntry({
|
|
1578
|
+
text: result.text,
|
|
1579
|
+
hash: target.commit,
|
|
1580
|
+
provider: result.provider,
|
|
1581
|
+
model: result.model,
|
|
1582
|
+
usage: result.usage,
|
|
1583
|
+
inputBytes: prompt.inputBytes,
|
|
1584
|
+
truncated: prompt.truncated,
|
|
1585
|
+
sourceBytes: prompt.sourceBytes,
|
|
1586
|
+
});
|
|
1587
|
+
introStore.put(target.cacheKey, language, entry);
|
|
1588
|
+
logger('intro.generate.remote', {
|
|
1589
|
+
source: target.source,
|
|
1590
|
+
name,
|
|
1591
|
+
lang: language,
|
|
1592
|
+
commit: target.commit,
|
|
1593
|
+
provider: result.provider,
|
|
1594
|
+
model: result.model,
|
|
1595
|
+
inputBytes: prompt.inputBytes,
|
|
1596
|
+
truncated: prompt.truncated,
|
|
1597
|
+
durationMs: Date.now() - startedAt,
|
|
1598
|
+
usage: result.usage || null,
|
|
1599
|
+
});
|
|
1600
|
+
return {
|
|
1601
|
+
ok: true,
|
|
1602
|
+
subscriptionId,
|
|
1603
|
+
name,
|
|
1604
|
+
lang: language,
|
|
1605
|
+
cached: false,
|
|
1606
|
+
source: target.source,
|
|
1607
|
+
commit: target.commit,
|
|
1608
|
+
skillPath: target.skillPath,
|
|
1609
|
+
intro: publicRemoteIntro(entry),
|
|
1610
|
+
usage: result.usage || null,
|
|
1611
|
+
};
|
|
1612
|
+
} catch (error) {
|
|
1613
|
+
if (!(error instanceof ServiceError)) {
|
|
1614
|
+
logger('intro.generate.remote.failed', {
|
|
1615
|
+
source: target.source,
|
|
1616
|
+
name,
|
|
1617
|
+
message: error?.message,
|
|
1618
|
+
code: error?.code,
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
throw error;
|
|
1622
|
+
}
|
|
1623
|
+
},
|
|
1624
|
+
);
|
|
1625
|
+
},
|
|
1626
|
+
|
|
907
1627
|
/** 列技能目录的文件树(只读,不跟随符号链接)。 */
|
|
908
1628
|
async listSkillFiles({ name }) {
|
|
909
1629
|
assertSafeSkillName(name);
|
|
@@ -952,6 +1672,332 @@ export function createService({
|
|
|
952
1672
|
};
|
|
953
1673
|
},
|
|
954
1674
|
|
|
1675
|
+
/**
|
|
1676
|
+
* 调用策略:auto / manual / hidden。
|
|
1677
|
+
*
|
|
1678
|
+
* - manual:只改 canonical SKILL.md 的 frontmatter;作者已声明 true 时只读;
|
|
1679
|
+
* - hidden:A′ park(改名为 SKILL.md.disabled + marker),失败回滚文件;
|
|
1680
|
+
* - auto:按 state 的 original 移除/还原插件行;作者声明不改,返回 409;
|
|
1681
|
+
* - hidden 期间不允许直接改成 auto/manual,必须先 unpark(避免「界面以为停着,实际已恢复」)。
|
|
1682
|
+
*/
|
|
1683
|
+
async setInvocation({ name, mode }) {
|
|
1684
|
+
assertSafeSkillName(name);
|
|
1685
|
+
assertWritable();
|
|
1686
|
+
if (!['auto', 'manual', 'hidden'].includes(mode)) {
|
|
1687
|
+
throw new ServiceError('error.skills.bad-mode', 'mode 只能是 auto / manual / hidden');
|
|
1688
|
+
}
|
|
1689
|
+
const initial = store.findInstall(name);
|
|
1690
|
+
if (!initial) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
1691
|
+
return enqueue(`mode:${name}`, async () => {
|
|
1692
|
+
const install = store.findInstall(name) || initial;
|
|
1693
|
+
const dir = canonicalDir(name);
|
|
1694
|
+
const kind = skillKindOf(dir);
|
|
1695
|
+
if (kind === 'file') {
|
|
1696
|
+
throw new ServiceError('error.skills.unsupported', `${name} 是平铺 .md 技能,不支持调用策略`, { name, kind }, 400);
|
|
1697
|
+
}
|
|
1698
|
+
if (kind !== 'dir') {
|
|
1699
|
+
throw new ServiceError('error.skills.not-installed', `${name} 不在 canonical 里`, { name, kind }, 400);
|
|
1700
|
+
}
|
|
1701
|
+
if (fs.lstatSync(dir).isSymbolicLink()) {
|
|
1702
|
+
throw new ServiceError('error.skills.not-writable', `${name} 的 canonical 是符号链接,拒绝编辑`, { name }, 409);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
const park = inspectPark(dir);
|
|
1706
|
+
const currentMode = park.parked || install.mode === 'hidden' ? 'hidden' : (install.mode || 'auto');
|
|
1707
|
+
|
|
1708
|
+
if (mode === 'hidden') {
|
|
1709
|
+
if (currentMode === 'hidden') return { ok: true, name, mode: 'hidden', already: true, policy: 'park' };
|
|
1710
|
+
const previousMode = currentMode === 'manual' ? 'manual' : 'auto';
|
|
1711
|
+
// marker + SKILL.md(崩在 rename 前 / 外部手动恢复):先把陈旧 marker 清掉再 park
|
|
1712
|
+
if (park.marker && park.hasSkill && !park.hasDisabled) {
|
|
1713
|
+
try {
|
|
1714
|
+
fs.rmSync(path.join(dir, '.dsh-skills-console.park.json'), { force: true });
|
|
1715
|
+
} catch {
|
|
1716
|
+
/* parkSkill 会再报 */
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
let parked;
|
|
1720
|
+
try {
|
|
1721
|
+
parked = parkSkill({ dir, name, previousMode });
|
|
1722
|
+
} catch (error) {
|
|
1723
|
+
throw invocationFailure(error);
|
|
1724
|
+
}
|
|
1725
|
+
const hidden = {
|
|
1726
|
+
disabledFile: parked.marker?.disabledFile || 'SKILL.md.disabled',
|
|
1727
|
+
marker: '.dsh-skills-console.park.json',
|
|
1728
|
+
previousMode,
|
|
1729
|
+
canonicalHash: parked.canonicalHash || parked.marker?.canonicalHash || null,
|
|
1730
|
+
at: nowIso(),
|
|
1731
|
+
};
|
|
1732
|
+
try {
|
|
1733
|
+
store.update((draft) => {
|
|
1734
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
1735
|
+
if (!row) return;
|
|
1736
|
+
row.mode = 'hidden';
|
|
1737
|
+
row.enabled = true;
|
|
1738
|
+
delete row.legacy;
|
|
1739
|
+
row.hidden = hidden;
|
|
1740
|
+
});
|
|
1741
|
+
} catch (error) {
|
|
1742
|
+
try {
|
|
1743
|
+
unparkSkill({ dir, name, fallbackMode: previousMode });
|
|
1744
|
+
} catch (rollbackError) {
|
|
1745
|
+
logger('invocation.park.rollback.failed', { name, message: rollbackError.message });
|
|
1746
|
+
}
|
|
1747
|
+
throw error;
|
|
1748
|
+
}
|
|
1749
|
+
try {
|
|
1750
|
+
await invalidatePolicies();
|
|
1751
|
+
} catch (error) {
|
|
1752
|
+
logger('policy.invalidate.failed', { name, message: error.message });
|
|
1753
|
+
}
|
|
1754
|
+
logger('invocation.hidden', { name, previousMode });
|
|
1755
|
+
return { ok: true, name, mode: 'hidden', previousMode, already: false, policy: 'park' };
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
if (currentMode === 'hidden') {
|
|
1759
|
+
throw new ServiceError(
|
|
1760
|
+
'error.skills.parked',
|
|
1761
|
+
`${name} 已完全停用;请先恢复,再改调用策略`,
|
|
1762
|
+
{ name, action: 'invocation', previousMode: install.hidden?.previousMode || 'auto' },
|
|
1763
|
+
409,
|
|
1764
|
+
);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
if (mode === 'manual') {
|
|
1768
|
+
let text;
|
|
1769
|
+
let plan;
|
|
1770
|
+
try {
|
|
1771
|
+
text = readSkillText(dir);
|
|
1772
|
+
plan = planManualWrite(text);
|
|
1773
|
+
} catch (error) {
|
|
1774
|
+
throw invocationFailure(error);
|
|
1775
|
+
}
|
|
1776
|
+
if (plan.author) {
|
|
1777
|
+
if (install.mode === 'manual' && install.manual?.origin === 'author') {
|
|
1778
|
+
return { ok: true, name, mode: 'manual', origin: 'author', already: true, policy: 'frontmatter' };
|
|
1779
|
+
}
|
|
1780
|
+
commitAuthorManual(name);
|
|
1781
|
+
try {
|
|
1782
|
+
await invalidatePolicies();
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
logger('policy.invalidate.failed', { name, message: error.message });
|
|
1785
|
+
}
|
|
1786
|
+
logger('invocation.manual', { name, origin: 'author' });
|
|
1787
|
+
return { ok: true, name, mode: 'manual', origin: 'author', already: false, policy: 'frontmatter' };
|
|
1788
|
+
}
|
|
1789
|
+
if (install.mode === 'manual' && install.manual?.origin === 'plugin' && !plan.changed) {
|
|
1790
|
+
return { ok: true, name, mode: 'manual', origin: 'plugin', already: true, policy: 'frontmatter' };
|
|
1791
|
+
}
|
|
1792
|
+
try {
|
|
1793
|
+
commitManual({ name, dir, plan, text });
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
throw invocationFailure(error);
|
|
1796
|
+
}
|
|
1797
|
+
try {
|
|
1798
|
+
await invalidatePolicies();
|
|
1799
|
+
} catch (error) {
|
|
1800
|
+
logger('policy.invalidate.failed', { name, message: error.message });
|
|
1801
|
+
}
|
|
1802
|
+
logger('invocation.manual', { name, origin: 'plugin', original: plan.original });
|
|
1803
|
+
return {
|
|
1804
|
+
ok: true,
|
|
1805
|
+
name,
|
|
1806
|
+
mode: 'manual',
|
|
1807
|
+
origin: 'plugin',
|
|
1808
|
+
original: plan.original,
|
|
1809
|
+
already: false,
|
|
1810
|
+
policy: 'frontmatter',
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// auto
|
|
1815
|
+
if (install.mode === 'manual' && install.manual) {
|
|
1816
|
+
if (install.manual.origin === 'author') {
|
|
1817
|
+
throw new ServiceError(
|
|
1818
|
+
'error.skills.author',
|
|
1819
|
+
'这个技能由作者声明了仅手动,插件不能替你改回自动;请编辑 SKILL.md',
|
|
1820
|
+
{ name, action: 'auto' },
|
|
1821
|
+
409,
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
let text;
|
|
1825
|
+
let plan;
|
|
1826
|
+
try {
|
|
1827
|
+
text = readSkillText(dir);
|
|
1828
|
+
plan = planManualRestore(text, install.manual.original);
|
|
1829
|
+
} catch (error) {
|
|
1830
|
+
if (error instanceof InvocationError && error.code === 'error.skills.drift') {
|
|
1831
|
+
markDrift(name);
|
|
1832
|
+
}
|
|
1833
|
+
throw invocationFailure(error);
|
|
1834
|
+
}
|
|
1835
|
+
if (plan.changed) {
|
|
1836
|
+
try {
|
|
1837
|
+
try {
|
|
1838
|
+
backupInvocation({ backupDir: paths.backupsDir, name, content: text });
|
|
1839
|
+
} catch (error) {
|
|
1840
|
+
throw new InvocationError(
|
|
1841
|
+
'error.skills.not-writable',
|
|
1842
|
+
`无法备份 ${name} 的 SKILL.md:${error.message}`,
|
|
1843
|
+
{ name },
|
|
1844
|
+
409,
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
writeFileAtomic(path.join(dir, 'SKILL.md'), plan.nextText);
|
|
1848
|
+
} catch (error) {
|
|
1849
|
+
throw invocationFailure(error);
|
|
1850
|
+
}
|
|
1851
|
+
try {
|
|
1852
|
+
store.update((draft) => {
|
|
1853
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
1854
|
+
if (!row) return;
|
|
1855
|
+
row.mode = 'auto';
|
|
1856
|
+
row.enabled = true;
|
|
1857
|
+
delete row.legacy;
|
|
1858
|
+
row.manual = null;
|
|
1859
|
+
row.hidden = null;
|
|
1860
|
+
});
|
|
1861
|
+
} catch (error) {
|
|
1862
|
+
try {
|
|
1863
|
+
writeFileAtomic(path.join(dir, 'SKILL.md'), text);
|
|
1864
|
+
} catch (rollbackError) {
|
|
1865
|
+
logger('invocation.rollback.failed', { name, message: rollbackError.message });
|
|
1866
|
+
}
|
|
1867
|
+
throw error;
|
|
1868
|
+
}
|
|
1869
|
+
} else {
|
|
1870
|
+
store.update((draft) => {
|
|
1871
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
1872
|
+
if (!row) return;
|
|
1873
|
+
row.mode = 'auto';
|
|
1874
|
+
row.enabled = true;
|
|
1875
|
+
delete row.legacy;
|
|
1876
|
+
row.manual = null;
|
|
1877
|
+
row.hidden = null;
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
try {
|
|
1881
|
+
await invalidatePolicies();
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
logger('policy.invalidate.failed', { name, message: error.message });
|
|
1884
|
+
}
|
|
1885
|
+
logger('invocation.auto', { name, changed: plan.changed });
|
|
1886
|
+
return { ok: true, name, mode: 'auto', already: false, policy: 'frontmatter' };
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
if (install.mode === 'auto' && !install.legacy) {
|
|
1890
|
+
return { ok: true, name, mode: 'auto', already: true, policy: 'frontmatter' };
|
|
1891
|
+
}
|
|
1892
|
+
store.update((draft) => {
|
|
1893
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
1894
|
+
if (!row) return;
|
|
1895
|
+
row.mode = 'auto';
|
|
1896
|
+
row.enabled = true;
|
|
1897
|
+
delete row.legacy;
|
|
1898
|
+
row.manual = null;
|
|
1899
|
+
row.hidden = null;
|
|
1900
|
+
});
|
|
1901
|
+
try {
|
|
1902
|
+
await invalidatePolicies();
|
|
1903
|
+
} catch (error) {
|
|
1904
|
+
logger('policy.invalidate.failed', { name, message: error.message });
|
|
1905
|
+
}
|
|
1906
|
+
logger('invocation.auto', { name, changed: false, legacy: true });
|
|
1907
|
+
return { ok: true, name, mode: 'auto', already: false, policy: 'frontmatter' };
|
|
1908
|
+
});
|
|
1909
|
+
},
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* A′ 恢复:校验 marker / disabled,把文件名改回来;不重新下载、不重建导出链。
|
|
1913
|
+
* 停用期间外部写回 SKILL.md 时默认 409;`keep='current' | 'parked'` 明确选择保留哪份。
|
|
1914
|
+
*/
|
|
1915
|
+
async unpark({ name, keep = null }) {
|
|
1916
|
+
assertSafeSkillName(name);
|
|
1917
|
+
assertWritable();
|
|
1918
|
+
if (keep !== null && keep !== 'current' && keep !== 'parked') {
|
|
1919
|
+
throw new ServiceError('error.skills.bad-mode', 'keep 只能是 current / parked');
|
|
1920
|
+
}
|
|
1921
|
+
return enqueue(`mode:${name}`, async () => {
|
|
1922
|
+
const dir = canonicalDir(name);
|
|
1923
|
+
const park = inspectPark(dir);
|
|
1924
|
+
const existing = store.findInstall(name);
|
|
1925
|
+
if (!park.parked) {
|
|
1926
|
+
// state 说 hidden,但 marker 和 disabled 都没了:内容已丢,不能假装恢复
|
|
1927
|
+
if (existing?.mode === 'hidden') {
|
|
1928
|
+
throw new ServiceError(
|
|
1929
|
+
'error.skills.park-broken',
|
|
1930
|
+
`${name} 记录为完全停用,但技能文件不在;请从回收站或备份找回`,
|
|
1931
|
+
{ name, reason: 'both-missing' },
|
|
1932
|
+
409,
|
|
1933
|
+
);
|
|
1934
|
+
}
|
|
1935
|
+
throw new ServiceError('error.skills.not-parked', `${name} 没有处于完全停用状态`, { name }, 409);
|
|
1936
|
+
}
|
|
1937
|
+
let install = existing;
|
|
1938
|
+
const fallbackMode = install?.hidden?.previousMode || install?.mode || 'auto';
|
|
1939
|
+
let result;
|
|
1940
|
+
try {
|
|
1941
|
+
result = unparkSkill({ dir, name, fallbackMode: fallbackMode === 'manual' ? 'manual' : 'auto', keep });
|
|
1942
|
+
} catch (error) {
|
|
1943
|
+
throw invocationFailure(error);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
if (!install) {
|
|
1947
|
+
// state 丢了但 marker 还在:先补一条接管记录,后面才有行可更新
|
|
1948
|
+
const lock = readLock(paths);
|
|
1949
|
+
const record = adoptionRecord(name, lock.skills?.[name] || null);
|
|
1950
|
+
store.update((draft) => applyAdoptions(draft, [record]));
|
|
1951
|
+
install = store.findInstall(name);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
let mode = result.mode === 'manual' ? 'manual' : 'auto';
|
|
1955
|
+
let manual = install?.manual || null;
|
|
1956
|
+
if (mode === 'manual') {
|
|
1957
|
+
if (!manual) {
|
|
1958
|
+
let parsed = null;
|
|
1959
|
+
try {
|
|
1960
|
+
parsed = parseInvocation(readSkillText(dir));
|
|
1961
|
+
} catch {
|
|
1962
|
+
/* 按 auto 收敛 */
|
|
1963
|
+
}
|
|
1964
|
+
if (parsed?.value === 'true') {
|
|
1965
|
+
manual = { origin: 'author', original: 'true', appliedAt: nowIso(), drift: false };
|
|
1966
|
+
} else {
|
|
1967
|
+
mode = 'auto';
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
// 恢复时内容被外部改过 / 选了保留当前版本:manual 标记可能不再可信,标 drift 让 UI 报
|
|
1972
|
+
if (mode === 'manual' && manual && result.hashMismatch) manual = { ...manual, drift: true };
|
|
1973
|
+
store.update((draft) => {
|
|
1974
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
1975
|
+
if (!row) return;
|
|
1976
|
+
row.mode = mode;
|
|
1977
|
+
row.enabled = true;
|
|
1978
|
+
delete row.legacy;
|
|
1979
|
+
row.hidden = null;
|
|
1980
|
+
if (mode === 'manual') row.manual = manual;
|
|
1981
|
+
else row.manual = null;
|
|
1982
|
+
});
|
|
1983
|
+
try {
|
|
1984
|
+
await invalidatePolicies();
|
|
1985
|
+
} catch (error) {
|
|
1986
|
+
logger('policy.invalidate.failed', { name, message: error.message });
|
|
1987
|
+
}
|
|
1988
|
+
logger('invocation.unpark', { name, mode, hashMismatch: result.hashMismatch });
|
|
1989
|
+
return {
|
|
1990
|
+
ok: true,
|
|
1991
|
+
name,
|
|
1992
|
+
mode,
|
|
1993
|
+
previousMode: result.previousMode,
|
|
1994
|
+
hashMismatch: result.hashMismatch,
|
|
1995
|
+
kept: result.kept,
|
|
1996
|
+
removed: result.removed,
|
|
1997
|
+
};
|
|
1998
|
+
});
|
|
1999
|
+
},
|
|
2000
|
+
|
|
955
2001
|
/**
|
|
956
2002
|
* 停用 = 只写 state(由 policy provider 覆盖 canonical 候选);启用 = 写 state 并清掉
|
|
957
2003
|
* 可能残留的 legacy 停用桩。
|
|
@@ -977,10 +2023,27 @@ export function createService({
|
|
|
977
2023
|
}
|
|
978
2024
|
const install = store.findInstall(name);
|
|
979
2025
|
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
2026
|
+
// 旧 /mode 只服务 legacy dsh-off;manual / hidden 必须走 /invocation,避免两套开关打架
|
|
2027
|
+
if (install.mode === 'manual' || install.mode === 'hidden' || install.hidden) {
|
|
2028
|
+
throw new ServiceError(
|
|
2029
|
+
'error.skills.mode-conflict',
|
|
2030
|
+
`${name} 使用三档调用策略,请用 /invocation 而不是旧 /mode`,
|
|
2031
|
+
{ name, mode: install.mode },
|
|
2032
|
+
409,
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
980
2035
|
return enqueue(`mode:${name}`, async () => {
|
|
981
2036
|
const canonicalPath = path.join(paths.canonical, name);
|
|
982
2037
|
// 在队列里重新读一次:并发两次 off/on 时不能拿入队前的快照判断「已经如此」。
|
|
983
2038
|
const current = store.findInstall(name) || install;
|
|
2039
|
+
if (current.mode === 'manual' || current.mode === 'hidden' || current.hidden) {
|
|
2040
|
+
throw new ServiceError(
|
|
2041
|
+
'error.skills.mode-conflict',
|
|
2042
|
+
`${name} 使用三档调用策略,请用 /invocation 而不是旧 /mode`,
|
|
2043
|
+
{ name, mode: current.mode },
|
|
2044
|
+
409,
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
984
2047
|
const already = mode === 'off' ? current.enabled === false : current.enabled !== false;
|
|
985
2048
|
|
|
986
2049
|
if (mode === 'off') {
|
|
@@ -1032,7 +2095,11 @@ export function createService({
|
|
|
1032
2095
|
}
|
|
1033
2096
|
store.update((draft) => {
|
|
1034
2097
|
const row = draft.installs.find((item) => item.name === name);
|
|
1035
|
-
if (row)
|
|
2098
|
+
if (row) {
|
|
2099
|
+
row.enabled = false;
|
|
2100
|
+
row.mode = 'auto';
|
|
2101
|
+
row.legacy = 'dsh-off';
|
|
2102
|
+
}
|
|
1036
2103
|
});
|
|
1037
2104
|
await invalidatePolicies();
|
|
1038
2105
|
logger('mode.off', { name, policy: 'provider' });
|
|
@@ -1061,7 +2128,11 @@ export function createService({
|
|
|
1061
2128
|
}
|
|
1062
2129
|
store.update((draft) => {
|
|
1063
2130
|
const row = draft.installs.find((item) => item.name === name);
|
|
1064
|
-
if (row)
|
|
2131
|
+
if (row) {
|
|
2132
|
+
row.enabled = true;
|
|
2133
|
+
row.mode = 'auto';
|
|
2134
|
+
delete row.legacy;
|
|
2135
|
+
}
|
|
1065
2136
|
draft.legacyShadows = (draft.legacyShadows || []).filter((item) => item.skill !== name);
|
|
1066
2137
|
});
|
|
1067
2138
|
await invalidatePolicies();
|
|
@@ -1074,7 +2145,11 @@ export function createService({
|
|
|
1074
2145
|
assertWritable();
|
|
1075
2146
|
await requireInstaller();
|
|
1076
2147
|
const requested = (skills || []).filter(Boolean);
|
|
1077
|
-
for (const name of requested)
|
|
2148
|
+
for (const name of requested) {
|
|
2149
|
+
assertSafeSkillName(name);
|
|
2150
|
+
// parked 期间安装/重装会把 SKILL.md 写回来,界面却还以为停着 —— 必须先恢复
|
|
2151
|
+
assertNotParked(name, '安装');
|
|
2152
|
+
}
|
|
1078
2153
|
|
|
1079
2154
|
return enqueue('install', async () => {
|
|
1080
2155
|
const subscription = subscriptionId
|
|
@@ -1161,6 +2236,11 @@ export function createService({
|
|
|
1161
2236
|
draft.installs.push({ ...record, enabled: true });
|
|
1162
2237
|
}
|
|
1163
2238
|
});
|
|
2239
|
+
// 重装 / 更新同一个 manual 技能时,新内容没有插件行;按 state 重新写回。
|
|
2240
|
+
const invocation = reapplyInvocation(item.name);
|
|
2241
|
+
if (!invocation.ok) {
|
|
2242
|
+
warnings.push({ name: item.name, warning: `仅手动标记未能重放:${invocation.message || invocation.code}` });
|
|
2243
|
+
}
|
|
1164
2244
|
installed.push({
|
|
1165
2245
|
name: item.name,
|
|
1166
2246
|
lockHash,
|
|
@@ -1168,6 +2248,7 @@ export function createService({
|
|
|
1168
2248
|
treeSha: record.treeSha,
|
|
1169
2249
|
installedHash,
|
|
1170
2250
|
backup: item.backup || null,
|
|
2251
|
+
invocation,
|
|
1171
2252
|
});
|
|
1172
2253
|
}
|
|
1173
2254
|
|
|
@@ -1641,7 +2722,12 @@ export function createService({
|
|
|
1641
2722
|
ref,
|
|
1642
2723
|
targetTreeSha,
|
|
1643
2724
|
skillPath,
|
|
1644
|
-
|
|
2725
|
+
// 归一化哈希:manual 的插件行不算本地改动,否则每次更新都被自己的标记挡住
|
|
2726
|
+
localModified: (() => {
|
|
2727
|
+
if (install.mode === 'hidden' || inspectPark(path.join(paths.canonical, name)).parked) return false;
|
|
2728
|
+
const current = hashWithoutInvocation(path.join(paths.canonical, name), install.manual);
|
|
2729
|
+
return Boolean(install.installedHash) && (current === null || current !== install.installedHash);
|
|
2730
|
+
})(),
|
|
1645
2731
|
};
|
|
1646
2732
|
},
|
|
1647
2733
|
|
|
@@ -1649,6 +2735,7 @@ export function createService({
|
|
|
1649
2735
|
assertWritable();
|
|
1650
2736
|
await requireInstaller();
|
|
1651
2737
|
return enqueue(`apply:${name}`, async () => {
|
|
2738
|
+
assertNotParked(name, '更新');
|
|
1652
2739
|
const plan = await this.planUpdate({ name });
|
|
1653
2740
|
if (token && token !== plan.token) {
|
|
1654
2741
|
throw new ServiceError('error.skills.stale-plan', '计划已失效(磁盘或上游被改动过),请重新检查');
|
|
@@ -1727,6 +2814,9 @@ export function createService({
|
|
|
1727
2814
|
// 更新重建的是 canonical 目录:导出链按原目标复验补建。`enabled` 保持不变——
|
|
1728
2815
|
// 停用期间更新,模型仍然看不到新正文,直到启用(不再需要刷新任何桩的 hash)。
|
|
1729
2816
|
const relinked = exportSkill(name, exportTargetsFor(name));
|
|
2817
|
+
// 新内容默认没有插件行;仍 manual 就重放,失败只标 drift,不回滚已成功的更新
|
|
2818
|
+
const invocation = reapplyInvocation(name);
|
|
2819
|
+
if (!invocation.ok) logger('invocation.reapply.failed', { name, message: invocation.message });
|
|
1730
2820
|
logger('updates.apply', { name, from: plan.fromCommit, to: plan.toCommit, backup: item.backup || null });
|
|
1731
2821
|
return {
|
|
1732
2822
|
ok: true,
|
|
@@ -1735,6 +2825,7 @@ export function createService({
|
|
|
1735
2825
|
toCommit: plan.toCommit || null,
|
|
1736
2826
|
backup: item.backup || null,
|
|
1737
2827
|
relinked,
|
|
2828
|
+
invocation,
|
|
1738
2829
|
};
|
|
1739
2830
|
});
|
|
1740
2831
|
},
|
|
@@ -1742,6 +2833,7 @@ export function createService({
|
|
|
1742
2833
|
async rollback({ name }) {
|
|
1743
2834
|
assertWritable();
|
|
1744
2835
|
return enqueue(`rollback:${name}`, async () => {
|
|
2836
|
+
assertNotParked(name, '回滚');
|
|
1745
2837
|
const install = store.findInstall(name);
|
|
1746
2838
|
if (!install?.backup?.key) throw new ServiceError('error.skills.no-backup', `${name} 没有可回滚的备份`);
|
|
1747
2839
|
const backup = readBackup({ paths, key: install.backup.key });
|
|
@@ -1767,7 +2859,8 @@ export function createService({
|
|
|
1767
2859
|
},
|
|
1768
2860
|
});
|
|
1769
2861
|
}
|
|
1770
|
-
|
|
2862
|
+
// 备份可能带着插件写的 frontmatter 行;基线统一用归一化哈希
|
|
2863
|
+
const installedHash = hashWithoutInvocation(canonicalPath, install.manual) || contentHash(canonicalPath);
|
|
1771
2864
|
store.update((draft) => {
|
|
1772
2865
|
const row = draft.installs.find((item) => item.name === name);
|
|
1773
2866
|
if (!row) return;
|
|
@@ -1778,8 +2871,9 @@ export function createService({
|
|
|
1778
2871
|
row.treeSha = install.backup.treeSha ?? null;
|
|
1779
2872
|
draft.checks[name] = { name, status: 'unknown', detail: '已回滚,需重新检查', checkedAt: nowIso() };
|
|
1780
2873
|
});
|
|
2874
|
+
const invocation = reapplyInvocation(name);
|
|
1781
2875
|
logger('updates.rollback', { name, backup: install.backup.key });
|
|
1782
|
-
return { ok: true, name, restoredFrom: install.backup.key, installedHash };
|
|
2876
|
+
return { ok: true, name, restoredFrom: install.backup.key, installedHash, invocation };
|
|
1783
2877
|
});
|
|
1784
2878
|
},
|
|
1785
2879
|
};
|