dsh-plugin-tool-management 0.16.6 → 0.18.0
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/CHANGELOG.md +115 -0
- package/README.md +20 -4
- package/README_EN.md +20 -4
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +59 -0
- package/lib/audit-log.js +6 -2
- package/lib/client.js +1615 -148
- package/lib/compat/capability-specs.js +321 -0
- package/lib/compat/host-probe.js +261 -0
- package/lib/compat/host-roots.js +168 -0
- package/lib/compat/probe-types.js +6 -0
- package/lib/compat/probe.js +54 -714
- package/lib/context-inject-contract.js +114 -0
- package/lib/context-inject-frame.js +147 -0
- package/lib/context-inject-select.js +127 -0
- package/lib/context-inject-surface.js +97 -0
- package/lib/context-inject.js +57 -459
- package/lib/host-names.js +150 -0
- package/lib/http-api.js +229 -0
- package/lib/hub.js +84 -11
- package/lib/imports/parsers.js +40 -15
- package/lib/index.js +235 -843
- package/lib/mcp/inspect-probe.js +199 -0
- package/lib/mcp/loader-token.js +26 -0
- package/lib/mcp/manager.js +267 -558
- package/lib/mcp/patch-yaml.js +207 -40
- package/lib/mcp/secret-guard.js +122 -16
- package/lib/mcp/tool-visibility.js +388 -0
- package/lib/memories/archive-engine.js +163 -16
- package/lib/memories/archive.js +10 -1
- package/lib/memories/discovery.js +261 -0
- package/lib/memories/export-plan.js +42 -0
- package/lib/memories/index-io.js +125 -12
- package/lib/memories/memories-types.js +1 -0
- package/lib/memories/service.js +167 -78
- package/lib/memories/snapshot.js +41 -260
- package/lib/op-registry.js +72 -8
- package/lib/ops/compat.js +17 -2
- package/lib/ops/prompts.js +18 -5
- package/lib/ops/quick-prompts.js +26 -0
- package/lib/ops/scene-records.js +157 -10
- package/lib/ops/scene-sync.js +71 -1
- package/lib/ops/sessions.js +63 -13
- package/lib/ops/snapshot.js +123 -24
- package/lib/ops/trash.js +45 -9
- package/lib/prompts/quick-service.js +211 -0
- package/lib/prompts/service.js +7 -2
- package/lib/request-gate.js +115 -56
- package/lib/scene-linkage.js +184 -0
- package/lib/scene-prompt-sync.js +46 -7
- package/lib/scenes/candidates.js +21 -3
- package/lib/sessions/archive-schemas.js +121 -0
- package/lib/sessions/path-key.js +46 -0
- package/lib/sessions/workspace.js +45 -168
- package/lib/settings-sidecars.js +143 -0
- package/lib/skills/core-types.js +8 -0
- package/lib/skills/core-util.js +81 -0
- package/lib/skills/core.js +97 -2297
- package/lib/skills/dir-watch.js +71 -0
- package/lib/skills/frontmatter.js +96 -0
- package/lib/skills/homes.js +14 -0
- package/lib/skills/import-pipeline.js +231 -0
- package/lib/skills/manager-state.js +800 -0
- package/lib/skills/official.js +269 -0
- package/lib/skills/provider-registration.js +89 -0
- package/lib/skills/service.js +4 -234
- package/lib/skills/skill-roots.js +287 -0
- package/lib/skills/skill-trash.js +265 -0
- package/lib/skills/system-trash.js +97 -0
- package/lib/skills/upload-pipeline.js +374 -0
- package/lib/slash-settings.js +25 -0
- package/lib/subagents/persona-parse.js +312 -0
- package/lib/subagents/persona-types.js +5 -0
- package/lib/subagents/persona-write.js +93 -0
- package/lib/subagents/run-provider.js +159 -0
- package/lib/subagents/service.js +20 -639
- package/lib/subagents/tool-filter.js +98 -0
- package/lib/token-config.js +240 -0
- package/lib/tool-table-switch.js +163 -0
- package/lib/tools/scene.js +40 -9
- package/package.json +3 -2
package/lib/ops/snapshot.js
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
// 导出物不该顺手把它们带走;要完整导出必须显式传 `includeSecrets:true`(界面二次确认)。
|
|
15
15
|
// 打码值导回来由 `secret-guard` 拿目标机的旧真值顶替(`mcpm-import` 既有的行为)。
|
|
16
16
|
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
17
|
-
import { join, resolve } from 'node:path';
|
|
17
|
+
import { basename, join, resolve } from 'node:path';
|
|
18
18
|
import { unzipSync, zipSync } from 'fflate';
|
|
19
|
+
import { maskSecretValue, maskUrlQuery } from '../mcp/secret-guard.js';
|
|
19
20
|
const SNAP_PREFIX = 'dsh-tool-management-snapshot-';
|
|
20
21
|
/** 快照目录里各部分的名字(导入侧按这些名字找东西,别改)。 */
|
|
21
22
|
const F_MANIFEST = 'manifest.json';
|
|
@@ -25,11 +26,11 @@ const F_SCENES = 'scenes.json';
|
|
|
25
26
|
const D_MEMORIES = 'memories';
|
|
26
27
|
const D_SUBAGENTS = 'subagents';
|
|
27
28
|
const D_PROMPTS = 'prompts';
|
|
29
|
+
const D_QUICK = 'quick-prompts';
|
|
28
30
|
const D_SKILLS = 'skills';
|
|
29
|
-
/**
|
|
30
|
-
const FILE_DOMAINS = [D_MEMORIES, D_SUBAGENTS, D_PROMPTS];
|
|
31
|
-
const ALL_DOMAINS = ['mcp', D_MEMORIES, D_SUBAGENTS, D_PROMPTS, D_SKILLS, 'settings', 'scenes'];
|
|
32
|
-
const MASKED = '••••••';
|
|
31
|
+
/** 四个"文件域":整个目录打进一份 zip,键是相对路径。 */
|
|
32
|
+
const FILE_DOMAINS = [D_MEMORIES, D_SUBAGENTS, D_PROMPTS, D_QUICK];
|
|
33
|
+
const ALL_DOMAINS = ['mcp', D_MEMORIES, D_SUBAGENTS, D_PROMPTS, D_QUICK, D_SKILLS, 'settings', 'scenes'];
|
|
33
34
|
function stamp() {
|
|
34
35
|
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
35
36
|
}
|
|
@@ -86,7 +87,7 @@ function namesOf(res, prefer) {
|
|
|
86
87
|
return [];
|
|
87
88
|
const bags = [res.data, res].filter(Boolean);
|
|
88
89
|
for (const bag of bags) {
|
|
89
|
-
for (const key of ['rows', 'personas', 'presets', 'scenes', 'skills', 'list', 'entries', 'items']) {
|
|
90
|
+
for (const key of ['rows', 'personas', 'presets', 'prompts', 'scenes', 'skills', 'list', 'entries', 'items']) {
|
|
90
91
|
const arr = bag[key];
|
|
91
92
|
if (!Array.isArray(arr))
|
|
92
93
|
continue;
|
|
@@ -110,6 +111,43 @@ function namesOf(res, prefer) {
|
|
|
110
111
|
}
|
|
111
112
|
return [];
|
|
112
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* 从 `skill-state` 的返回里取「<来源>/<技能名>」清单。
|
|
116
|
+
*
|
|
117
|
+
* 为什么不能复用 `namesOf`(2026-09-30 审查 P1-6):`skill-state` 的形状是
|
|
118
|
+
* `{ ok, data: { roots: [{ key, skills: [{ name, declaredName, … }] }] } }` —— 顶层键是
|
|
119
|
+
* `roots`,而 `namesOf` 只找 `rows / personas / presets / scenes / skills / list / entries /
|
|
120
|
+
* items` 这些**数组**键,一个都对不上 → 恒返回 `[]` → 技能域**永远导出 0 项**,而界面显示的
|
|
121
|
+
* 是"迁移完成"。key 用**声明名**(frontmatter `name`),口径与 `index.ts` 的
|
|
122
|
+
* `skillArchiveKeysOfRoot` 一致(档案里存的就是声明名,用条目名拼会在 stale 校验里被丢掉)。
|
|
123
|
+
*/
|
|
124
|
+
function skillKeysOf(res) {
|
|
125
|
+
if (!res || res.ok === false)
|
|
126
|
+
return [];
|
|
127
|
+
for (const bag of [res.data, res].filter(Boolean)) {
|
|
128
|
+
const roots = bag.roots;
|
|
129
|
+
if (!Array.isArray(roots))
|
|
130
|
+
continue;
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const root of roots) {
|
|
133
|
+
const rootKey = String(root?.key || '');
|
|
134
|
+
if (!rootKey)
|
|
135
|
+
continue;
|
|
136
|
+
const skills = root.skills;
|
|
137
|
+
if (!Array.isArray(skills))
|
|
138
|
+
continue;
|
|
139
|
+
for (const sk of skills) {
|
|
140
|
+
const s = sk;
|
|
141
|
+
const name = String((s && (s.declaredName || s.name)) || '');
|
|
142
|
+
if (name)
|
|
143
|
+
out.push(rootKey + '/' + name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (out.length)
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
113
151
|
/** 场景清单(带 label / description / 提示词绑定):`rules-list` 的 scenes 那一栏,读不到就是空。 */
|
|
114
152
|
function sceneRecordsOf(res) {
|
|
115
153
|
const bags = [res && res.data, res].filter(Boolean);
|
|
@@ -128,11 +166,18 @@ function sceneRecordsOf(res) {
|
|
|
128
166
|
}
|
|
129
167
|
return [];
|
|
130
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* 键值对打码:**走 `secret-guard` 的唯一口径**(`$VAR` / `!!js` 这类间接引用原样保留)。
|
|
171
|
+
*
|
|
172
|
+
* 这里曾经自己写了一份「所有键一律打成 `••••••`」的版本 —— 与 MCP 列表视图的口径不一致,
|
|
173
|
+
* 结果是配置里的间接引用被当成密钥导出、导回来时 `resolveMaskedKv` 顶替不出真值只能丢键。
|
|
174
|
+
* 打码形态只允许有一处定义(审查 P0-4)。
|
|
175
|
+
*/
|
|
131
176
|
function maskKv(map) {
|
|
132
177
|
const out = {};
|
|
133
178
|
if (map && typeof map === 'object' && !Array.isArray(map)) {
|
|
134
179
|
for (const k of Object.keys(map))
|
|
135
|
-
out[k] =
|
|
180
|
+
out[k] = maskSecretValue(map[k]);
|
|
136
181
|
}
|
|
137
182
|
return out;
|
|
138
183
|
}
|
|
@@ -175,8 +220,14 @@ export function createSnapshotOps(deps) {
|
|
|
175
220
|
copy.env = maskKv(copy.env);
|
|
176
221
|
if (copy.headers)
|
|
177
222
|
copy.headers = maskKv(copy.headers);
|
|
223
|
+
// URL 查询串走 `secret-guard.maskUrlQuery`(与 MCP 列表视图同一口径)。这里原先是**自写的
|
|
224
|
+
// 一条窄正则** `([?&](?:key|token|secret|password)=)`,只认四个固定参数名 ——
|
|
225
|
+
// `?api_key=` / `?access_token=` / `?sig=` / `?client_secret=` 这类 MCP 托管服务最常见的
|
|
226
|
+
// 形态全部漏掉,而这正是「凭据挂在 URL 上」的主流写法。后果不是界面显示问题:默认
|
|
227
|
+
// (`includeSecrets` 缺省 false)导出的 `mcp.json` 里就是**明文凭据**,而这份目录的用途
|
|
228
|
+
// 就是拷去 U 盘 / 网盘 / 另一台机器(审查 P0-4)。
|
|
178
229
|
if (typeof copy.url === 'string')
|
|
179
|
-
copy.url = copy.url
|
|
230
|
+
copy.url = maskUrlQuery(copy.url);
|
|
180
231
|
return copy;
|
|
181
232
|
});
|
|
182
233
|
try {
|
|
@@ -186,11 +237,12 @@ export function createSnapshotOps(deps) {
|
|
|
186
237
|
return { ok: false, error: '写 mcp.json 失败: ' + deps.message(e) };
|
|
187
238
|
}
|
|
188
239
|
domains.mcp = mcpOut.length;
|
|
189
|
-
// ── 文件域:记忆 / 子智能体 /
|
|
240
|
+
// ── 文件域:记忆 / 子智能体 / 提示词 / 快捷提示词,各打进一份 zip(键 = 相对路径)──
|
|
190
241
|
const roots = {
|
|
191
242
|
[D_MEMORIES]: deps.hubPath(D_MEMORIES),
|
|
192
243
|
[D_SUBAGENTS]: deps.hubPath(D_SUBAGENTS),
|
|
193
244
|
[D_PROMPTS]: deps.promptsDir(),
|
|
245
|
+
[D_QUICK]: deps.quickPromptsDir(),
|
|
194
246
|
};
|
|
195
247
|
for (const kind of FILE_DOMAINS) {
|
|
196
248
|
const files = await walk(roots[kind]);
|
|
@@ -215,7 +267,7 @@ export function createSnapshotOps(deps) {
|
|
|
215
267
|
}
|
|
216
268
|
// ── 技能:源目录在外部(不在 hub),交给既有的 `bundle-export` 走它自己的收集路径 ──
|
|
217
269
|
const skillState = await deps.invokeOp('skill-state', {});
|
|
218
|
-
const skillKeys =
|
|
270
|
+
const skillKeys = skillKeysOf(skillState);
|
|
219
271
|
if (!skillKeys.length)
|
|
220
272
|
notes.push('技能这次没导出:没从 skill-state 读到「<来源>/<名字>」形态的清单');
|
|
221
273
|
if (skillKeys.length) {
|
|
@@ -302,6 +354,7 @@ export function createSnapshotOps(deps) {
|
|
|
302
354
|
mcp: namesOf(await deps.invokeOp('mcpm-list', {}), ['serverName']),
|
|
303
355
|
[D_SUBAGENTS]: namesOf(await deps.invokeOp('subagent-list', {}), ['name']),
|
|
304
356
|
[D_PROMPTS]: namesOf(await deps.invokeOp('agentsmd-list', {}), ['id']),
|
|
357
|
+
[D_QUICK]: namesOf(await deps.invokeOp('quickprompt-list', {}), ['id']),
|
|
305
358
|
[D_MEMORIES]: namesOf(await deps.invokeOp('rules-list', {}), ['id']),
|
|
306
359
|
scenes: sceneRecordsOf(await deps.invokeOp('rules-list', {})).map((r) => r.name),
|
|
307
360
|
};
|
|
@@ -385,13 +438,23 @@ export function createSnapshotOps(deps) {
|
|
|
385
438
|
return { ok: false, error: read.error };
|
|
386
439
|
const { files } = read;
|
|
387
440
|
const report = [];
|
|
388
|
-
/**
|
|
441
|
+
/**
|
|
442
|
+
* 一个域失败只记在这一行上:迁移的意义正是「哪域没过来」看得清,不是整批回滚。
|
|
443
|
+
*
|
|
444
|
+
* `warning` 也要透传(2026-09-30 审查 P2-9):各域的 import op 会用它报"带过来了但有
|
|
445
|
+
* 东西被跳过"(`mcpm-import` 的「这些密钥已是打码占位符,请在编辑里重新填写」就是其中
|
|
446
|
+
* 最重要的一条)。此前只取 imported/skipped,于是"迁移成功"的界面下藏着一份**需要用户
|
|
447
|
+
* 动手补的密钥清单**,用户在另一台机器上等到调用工具时才发现。
|
|
448
|
+
*/
|
|
389
449
|
async function run(domain, task) {
|
|
390
450
|
if (domains.indexOf(domain) < 0)
|
|
391
451
|
return;
|
|
392
452
|
try {
|
|
393
453
|
const r = await task();
|
|
394
|
-
report.push({
|
|
454
|
+
report.push({
|
|
455
|
+
domain, imported: r.imported, skipped: r.skipped,
|
|
456
|
+
...(r.warning ? { warnings: [r.warning] } : {}),
|
|
457
|
+
});
|
|
395
458
|
}
|
|
396
459
|
catch (e) {
|
|
397
460
|
report.push({ domain, imported: 0, skipped: 0, error: deps.message(e) });
|
|
@@ -404,7 +467,12 @@ export function createSnapshotOps(deps) {
|
|
|
404
467
|
const res = await deps.invokeOp('mcpm-import', overwrite ? { json: raw, conflict: 'overwrite' } : { json: raw });
|
|
405
468
|
if (!res || res.ok === false)
|
|
406
469
|
throw new Error(String((res && res.error) || 'mcpm-import 失败'));
|
|
407
|
-
return {
|
|
470
|
+
return {
|
|
471
|
+
imported: (res.added || []).length + (res.overwritten || []).length,
|
|
472
|
+
skipped: (res.skipped || []).length,
|
|
473
|
+
// 「打码占位符被跳过」这类结论必须跟着走:它是用户要动手补的清单,不是内部细节。
|
|
474
|
+
...(typeof res.warning === 'string' && res.warning ? { warning: res.warning } : {}),
|
|
475
|
+
};
|
|
408
476
|
});
|
|
409
477
|
await run(D_SUBAGENTS, async () => {
|
|
410
478
|
const zip = files[D_SUBAGENTS + '.zip'];
|
|
@@ -428,12 +496,13 @@ export function createSnapshotOps(deps) {
|
|
|
428
496
|
throw new Error(String((res && res.error) || 'subagent-import 失败'));
|
|
429
497
|
return { imported: (res.imported || []).length, skipped: skipped + (res.skipped || []).length };
|
|
430
498
|
});
|
|
431
|
-
|
|
432
|
-
|
|
499
|
+
// 两个提示词域同形:`<id>/<正文>` + 可选 `<id>/meta.json`,一次一份地导入。差别只在
|
|
500
|
+
// 正文文件名与 op 名(快捷域没有独立的 import op,`create` 就是那个形状),所以共用一段。
|
|
501
|
+
const importPromptLike = (domain, docName, importOp, updateOp) => run(domain, async () => {
|
|
502
|
+
const zip = files[domain + '.zip'];
|
|
433
503
|
if (!zip)
|
|
434
504
|
return { imported: 0, skipped: 0 };
|
|
435
505
|
const entries = unzipSync(new Uint8Array(await readFile(zip)));
|
|
436
|
-
// 提示词的落点是 `<id>/AGENTS.md`(+ 可选 `<id>/meta.json`),而 `agentsmd-import` 一次一份。
|
|
437
506
|
const bodies = {};
|
|
438
507
|
const metas = {};
|
|
439
508
|
let skipped = 0;
|
|
@@ -444,7 +513,7 @@ export function createSnapshotOps(deps) {
|
|
|
444
513
|
continue;
|
|
445
514
|
}
|
|
446
515
|
const text = Buffer.from(bytes).toString('utf8');
|
|
447
|
-
if (parts[1] ===
|
|
516
|
+
if (parts[1] === docName)
|
|
448
517
|
bodies[parts[0]] = text;
|
|
449
518
|
else if (parts[1] === 'meta.json') {
|
|
450
519
|
try {
|
|
@@ -456,17 +525,17 @@ export function createSnapshotOps(deps) {
|
|
|
456
525
|
let imported = 0;
|
|
457
526
|
for (const id of Object.keys(bodies)) {
|
|
458
527
|
const desc = String((metas[id] && metas[id].description) || '');
|
|
459
|
-
const res = await deps.invokeOp(
|
|
528
|
+
const res = await deps.invokeOp(importOp, { id, content: bodies[id], description: desc });
|
|
460
529
|
if (res && res.ok) {
|
|
461
530
|
imported += 1;
|
|
462
531
|
continue;
|
|
463
532
|
}
|
|
464
|
-
//
|
|
533
|
+
// 同名一律拒(两个 import 口都不覆盖)。覆盖模式下改走 upsert 那条 update 路径。
|
|
465
534
|
if (!overwrite) {
|
|
466
535
|
skipped += 1;
|
|
467
536
|
continue;
|
|
468
537
|
}
|
|
469
|
-
const upd = await deps.invokeOp(
|
|
538
|
+
const upd = await deps.invokeOp(updateOp, { id, nextId: id, content: bodies[id], description: desc });
|
|
470
539
|
if (upd && upd.ok)
|
|
471
540
|
imported += 1;
|
|
472
541
|
else
|
|
@@ -474,6 +543,8 @@ export function createSnapshotOps(deps) {
|
|
|
474
543
|
}
|
|
475
544
|
return { imported, skipped };
|
|
476
545
|
});
|
|
546
|
+
await importPromptLike(D_PROMPTS, 'AGENTS.md', 'agentsmd-import', 'agentsmd-update');
|
|
547
|
+
await importPromptLike(D_QUICK, 'PROMPT.md', 'quickprompt-create', 'quickprompt-update');
|
|
477
548
|
await run(D_MEMORIES, async () => {
|
|
478
549
|
const zip = files[D_MEMORIES + '.zip'];
|
|
479
550
|
if (!zip)
|
|
@@ -499,6 +570,7 @@ export function createSnapshotOps(deps) {
|
|
|
499
570
|
(byScene[scene] || (byScene[scene] = [])).push({ name, data: Buffer.from(bytes).toString('base64') });
|
|
500
571
|
}
|
|
501
572
|
let imported = 0;
|
|
573
|
+
const reasons = [];
|
|
502
574
|
for (const scene of Object.keys(byScene)) {
|
|
503
575
|
const res = await deps.invokeOp('rules-import', { scene, files: byScene[scene] });
|
|
504
576
|
if (!res || res.ok === false) {
|
|
@@ -507,9 +579,19 @@ export function createSnapshotOps(deps) {
|
|
|
507
579
|
}
|
|
508
580
|
const data = res.data || res;
|
|
509
581
|
imported += (data.imported || []).length;
|
|
510
|
-
|
|
582
|
+
const skippedRows = Array.isArray(data.skipped) ? data.skipped : [];
|
|
583
|
+
skipped += skippedRows.length;
|
|
584
|
+
// `rules-import` 的 skipped 是 `{name, reason}` —— 只数个数会把「为什么没过来」
|
|
585
|
+
// 丢掉,而那正是用户要照着修的东西(名字不合法 / 正文超限 / 附件重名)。
|
|
586
|
+
// 上限 5 条:这是提示不是日志,界面上一行放不下更多(审查 P2-9 同一条口径)。
|
|
587
|
+
for (const row of skippedRows.slice(0, 5)) {
|
|
588
|
+
const name = String((row && row.name) || '');
|
|
589
|
+
const reason = String((row && row.reason) || '');
|
|
590
|
+
if (reason)
|
|
591
|
+
reasons.push(`${name ? name + ':' : ''}${reason}`);
|
|
592
|
+
}
|
|
511
593
|
}
|
|
512
|
-
return { imported, skipped };
|
|
594
|
+
return { imported, skipped, ...(reasons.length ? { warning: reasons.join(';') } : {}) };
|
|
513
595
|
});
|
|
514
596
|
await run('scenes', async () => {
|
|
515
597
|
if (!files[F_SCENES])
|
|
@@ -577,9 +659,26 @@ export function createSnapshotOps(deps) {
|
|
|
577
659
|
let imported = 0;
|
|
578
660
|
let skipped = 0;
|
|
579
661
|
for (const zip of zips) {
|
|
580
|
-
|
|
662
|
+
// 走 `skill-upload`(把 zip 读成 base64)而**不是** `skill-import`(2026-09-30 审查 P1-6):
|
|
663
|
+
// 导出侧产出的是 `bundle-export` 打的**一个 zip**(内含 `<技能名>/<文件>`),而
|
|
664
|
+
// `skill-import` 的 `analyzeSource` 用 `lstat` + `endsWith('.md')` 判定来源,**完全不认识
|
|
665
|
+
// `.zip`** —— 喂过去必然 `error.source.unrecognized`,该域整行报错。zip 的解包只存在于
|
|
666
|
+
// `skill-upload` 这条链(`importUploadedSkill` → `prepareUploadedSource` → `writeUploadedZip`);
|
|
667
|
+
// 已实测它能正确吃下这种形态(`<技能名>/SKILL.md` → 落 `hub/skills/<技能名>/`)。
|
|
668
|
+
let b64;
|
|
669
|
+
try {
|
|
670
|
+
b64 = (await readFile(zip)).toString('base64');
|
|
671
|
+
}
|
|
672
|
+
catch (e) {
|
|
673
|
+
throw new Error(`读取技能包失败:${deps.message(e)}`);
|
|
674
|
+
}
|
|
675
|
+
const res = await deps.invokeOp('skill-upload', {
|
|
676
|
+
name: basename(zip).replace(/\.zip$/i, ''),
|
|
677
|
+
zip: b64,
|
|
678
|
+
...(overwrite ? { conflict: 'overwrite' } : {}),
|
|
679
|
+
});
|
|
581
680
|
if (!res || res.ok === false)
|
|
582
|
-
throw new Error(String((res && res.error) || 'skill-
|
|
681
|
+
throw new Error(String((res && res.error) || 'skill-upload 失败'));
|
|
583
682
|
const data = res.data || {};
|
|
584
683
|
imported += (data.imported || []).length;
|
|
585
684
|
skipped += (data.skipped || []).length + (data.failed || []).length;
|
package/lib/ops/trash.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 删除进回收站、从回收站恢复、回收站列出与彻底删除。与记忆正文 ops 分域,
|
|
4
4
|
// 但共用 copyIntoMemoriesTrash 等入口,经 MemoriesOpsCtx 显式传入。
|
|
5
|
-
import { copyFile, lstat, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
5
|
+
import { copyFile, cp, lstat, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { isValidTrashId } from '../hub.js';
|
|
8
8
|
import { LEGACY_BUNDLE_DOC, bundleDocName, isValidGroupSegment, isValidGroupPath, fail, MEMORIES_TRASH_DIR } from '../memories/constants.js';
|
|
@@ -45,7 +45,14 @@ export function buildTrashOps(rc) {
|
|
|
45
45
|
catch {
|
|
46
46
|
return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
|
|
47
47
|
}
|
|
48
|
-
if (!manifest || typeof manifest.name !== 'string' || typeof manifest.group !== 'string'
|
|
48
|
+
if (!manifest || typeof manifest.name !== 'string' || typeof manifest.group !== 'string'
|
|
49
|
+
|| !isValidGroupSegment(manifest.name)
|
|
50
|
+
// `group` 是**磁盘数据**(manifest.json 可能被手改 / 别的进程写 / 随一份恶意档案包进来),
|
|
51
|
+
// 而它是拼进 `join(memoriesRoot, group)` 的那一段 —— 只判类型的话 `../..` 就能把正文写到
|
|
52
|
+
// 记忆根之外(`mkdir({recursive:true})` 还会顺手把目录建出来)。同文件 `rulesTrashList`
|
|
53
|
+
// 一直是有这道判据的(`if (group !== '' && !isValidGroupPath(group)) continue`),
|
|
54
|
+
// 两条路径口径必须一致(审查 P0-3)。
|
|
55
|
+
|| (manifest.group !== '' && !isValidGroupPath(manifest.group))) {
|
|
49
56
|
return fail('error.rules.notFound', `回收站条目损坏:${trashId}`);
|
|
50
57
|
}
|
|
51
58
|
// 恢复目标已存在(期间用户重建了同名规则)→ 拒绝,避免覆盖。
|
|
@@ -54,14 +61,43 @@ export function buildTrashOps(rc) {
|
|
|
54
61
|
return fail('error.rules.exists', `同名记忆已存在:${conflict.id},请先移除后再恢复`);
|
|
55
62
|
}
|
|
56
63
|
const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
// 落点根内断言(realpath 口径):`isValidGroupPath` 只挡 `..` 与分隔符,挡不住
|
|
65
|
+
// 「中间目录是指向根外的链接」。与 `rulesRemove` 的删除前断言同一道闸。
|
|
66
|
+
const groupDir = join(memoriesRoot, manifest.group);
|
|
67
|
+
const denied = await refuseOutsideRoot(groupDir);
|
|
68
|
+
if (denied)
|
|
69
|
+
return denied;
|
|
70
|
+
// 恢复 = 先复制、后删回收站条目。**复制中途失败必须回滚**(2026-09-30 审查 F11):
|
|
71
|
+
// 原来这里 `cp` / `copyFile` 都没有 try/catch,失败时目标留下半份内容、回收站条目仍在,
|
|
72
|
+
// 于是下一次点恢复会撞上面那条「同名记忆已存在」—— 这条记忆**永远恢复不了**,只能人工
|
|
73
|
+
// 去删目录。场景回收站(`sceneTrashRestore`)早有这条回滚(`moveIntoTrash` 反向搬回),
|
|
74
|
+
// 记忆回收站这条缺了。这里方向相反:源还在回收站,所以回滚是**删掉没放完整的副本**。
|
|
75
|
+
const target = form === 'bundle'
|
|
76
|
+
? join(groupDir, manifest.name)
|
|
77
|
+
: join(groupDir, manifest.name + '.md');
|
|
78
|
+
try {
|
|
79
|
+
await mkdir(groupDir, { recursive: true });
|
|
80
|
+
if (form === 'bundle')
|
|
81
|
+
await cp(join(trashDir, 'bundle'), target, { recursive: true });
|
|
82
|
+
else
|
|
83
|
+
await copyFile(join(trashDir, 'rule.md'), target);
|
|
61
84
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
85
|
+
catch (e) {
|
|
86
|
+
let cleaned = true;
|
|
87
|
+
try {
|
|
88
|
+
await rm(target, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
catch (cleanupError) {
|
|
91
|
+
// 落点**本来就没建成**时(父路径不是目录 → ENOTDIR;`force` 已吞掉 ENOENT),
|
|
92
|
+
// 没有残留要清,别把它报成「清理失败」——那会让用户去手工删一个不存在的路径。
|
|
93
|
+
const code = cleanupError?.code;
|
|
94
|
+
cleaned = code === 'ENOENT' || code === 'ENOTDIR';
|
|
95
|
+
}
|
|
96
|
+
// 清理失败也如实说清楚,别让用户以为还能原样重试。
|
|
97
|
+
const tail = cleaned
|
|
98
|
+
? '(未放回完整的落点已清理,回收站条目仍在,可直接重试)'
|
|
99
|
+
: `(未放回完整的落点没能清理:${target},请先手工删除再重试)`;
|
|
100
|
+
return fail('error.rules.ioFailed', `恢复记忆失败:${String(e?.message ?? e)}${tail}`);
|
|
65
101
|
}
|
|
66
102
|
await rm(trashDir, { recursive: true, force: true });
|
|
67
103
|
invalidateSnapshot();
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// dsh-plugin-tool-management —— 快捷提示词库(hub/quick-prompts/)。
|
|
2
|
+
//
|
|
3
|
+
// 与「全局提示词预设」(`./service.ts`)是**两件事**,别混着看:
|
|
4
|
+
// 预设 = 一份会覆盖写 `~/.dsh/AGENTS.md` 的指令基线,进上下文、被场景绑定、模型能在之间挑;
|
|
5
|
+
// 快捷 = 用户存好的一段现成文字,只由**用户自己**在对话框 `/` 菜单里点出来贴进草稿。
|
|
6
|
+
//
|
|
7
|
+
// 所以它**不注入上下文、不进场景**,实现上靠"另起一份存储"来保证:预设服务的
|
|
8
|
+
// `list()` 只扫 `hub/prompts/` 下的合法目录,注入取的是场景绑定的那份正文,模型工具
|
|
9
|
+
// `prompt_manager_list` 复用的又是 `agentsmd-list` —— 四条路径各自只认 `hub/prompts/`,
|
|
10
|
+
// 快捷词放在隔壁目录里就天然进不去。**不要**为了少一个目录把它塞进 prompts/ 加个标记位:
|
|
11
|
+
// 那等于让四条路径各自学会过滤,漏一条就是"快捷词被当成基线注入"。
|
|
12
|
+
// 条目那颗开关(`enabled`)只管"在不在对话框 `/` 菜单里出现",同样不碰上面四条路径。
|
|
13
|
+
//
|
|
14
|
+
// `list()` 连**正文一起给**,不是偷懒省一次请求:宿主的 `settle()` 拿到 onPick 的返回值后
|
|
15
|
+
// **同步**执行插入(input-trigger client.js),异步读正文来不及 —— 点的时候手里就得有那段字。
|
|
16
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { isValidPresetId, normalizePresetId } from './preset-id.js';
|
|
19
|
+
import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
|
|
20
|
+
/** 快捷提示词正文的文件名。与预设的 `AGENTS.md` 刻意不同名:这两个库不该被互换过。 */
|
|
21
|
+
const FILENAME = 'PROMPT.md';
|
|
22
|
+
/** 侧车(说明 + 启停),与正文同目录:正文本身一个字都不该被这些元信息污染。 */
|
|
23
|
+
const META_FILE = 'meta.json';
|
|
24
|
+
export function createQuickPromptsService(deps) {
|
|
25
|
+
const message = (e) => String((e && e.message) || e);
|
|
26
|
+
const idError = (raw) => {
|
|
27
|
+
const result = normalizePresetId(raw);
|
|
28
|
+
return result.ok ? null : result.error;
|
|
29
|
+
};
|
|
30
|
+
/** 侧车读回来的形状:缺字段一律按"没动过"解释(说明为空、开关为开)。 */
|
|
31
|
+
async function readMeta(id) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(await readFile(join(deps.dir, id, META_FILE), 'utf8'));
|
|
34
|
+
return {
|
|
35
|
+
description: typeof parsed.description === 'string' ? parsed.description : '',
|
|
36
|
+
// 只有明确写着 false 才算关:这颗开关之前的老库根本没有这个字段,默认必须是"照常在 / 里出现"。
|
|
37
|
+
enabled: parsed.enabled !== false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { description: '', enabled: true };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 侧车写:`undefined` = 那一项不动(空串 / `true` 才是明确回到默认)。
|
|
46
|
+
* 两项都回到默认时连文件一起删 —— 不留空壳(原来"说明清空就删侧车"那条纪律照旧)。
|
|
47
|
+
*/
|
|
48
|
+
async function writeMeta(id, patch) {
|
|
49
|
+
const cur = await readMeta(id);
|
|
50
|
+
const description = patch.description === undefined ? cur.description : String(patch.description).trim();
|
|
51
|
+
const enabled = patch.enabled === undefined ? cur.enabled : patch.enabled === true;
|
|
52
|
+
const body = {};
|
|
53
|
+
if (description !== '')
|
|
54
|
+
body.description = description;
|
|
55
|
+
if (enabled !== true)
|
|
56
|
+
body.enabled = false;
|
|
57
|
+
const file = join(deps.dir, id, META_FILE);
|
|
58
|
+
try {
|
|
59
|
+
if (!Object.keys(body).length) {
|
|
60
|
+
await rm(file, { force: true });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
await writeFile(file, JSON.stringify(body, null, 2) + '\n', 'utf8');
|
|
64
|
+
}
|
|
65
|
+
catch { /* 元信息写不进去不该让保存失败:正文才是本体 */ }
|
|
66
|
+
}
|
|
67
|
+
async function list() {
|
|
68
|
+
try {
|
|
69
|
+
let entries = [];
|
|
70
|
+
try {
|
|
71
|
+
entries = await readdir(deps.dir, { withFileTypes: true });
|
|
72
|
+
}
|
|
73
|
+
catch { /* 库还没建过 = 空 */ }
|
|
74
|
+
const ids = entries.filter((e) => e.isDirectory() && isValidPresetId(e.name)).map((e) => e.name);
|
|
75
|
+
const prompts = await Promise.all(ids.map(async (id) => {
|
|
76
|
+
const meta = await readMeta(id);
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
description: meta.description,
|
|
80
|
+
enabled: meta.enabled,
|
|
81
|
+
// 读不到的那条(正文被外部删了)给空串:界面上仍然列出来,用户能看见"它在但没内容",
|
|
82
|
+
// 而不是凭空少一条却查不出为什么。
|
|
83
|
+
content: await readFile(join(deps.dir, id, FILENAME), 'utf8').catch(() => ''),
|
|
84
|
+
};
|
|
85
|
+
}));
|
|
86
|
+
prompts.sort((a, b) => a.id.localeCompare(b.id));
|
|
87
|
+
return { ok: true, prompts };
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
return { ok: false, error: message(e) };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function create(id, content, description) {
|
|
94
|
+
const bad = idError(id);
|
|
95
|
+
if (bad)
|
|
96
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
97
|
+
const safeId = String(id ?? '').trim();
|
|
98
|
+
const dir = join(deps.dir, safeId);
|
|
99
|
+
try {
|
|
100
|
+
await stat(dir);
|
|
101
|
+
return { ok: false, error: 'id 已存在:' + safeId };
|
|
102
|
+
}
|
|
103
|
+
catch { /* 不存在,继续 */ }
|
|
104
|
+
await mkdir(dir, { recursive: true });
|
|
105
|
+
await writeFile(join(dir, FILENAME), String(content ?? ''), 'utf8');
|
|
106
|
+
await writeMeta(safeId, { description });
|
|
107
|
+
return { ok: true, id: safeId };
|
|
108
|
+
}
|
|
109
|
+
async function update(id, content, nextId, description) {
|
|
110
|
+
const bad = idError(id);
|
|
111
|
+
if (bad)
|
|
112
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
113
|
+
const safeId = String(id ?? '').trim();
|
|
114
|
+
try {
|
|
115
|
+
await stat(join(deps.dir, safeId));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return { ok: false, error: '快捷提示词不存在:' + safeId };
|
|
119
|
+
}
|
|
120
|
+
let targetId = safeId;
|
|
121
|
+
if (nextId !== undefined && String(nextId).trim() !== safeId) {
|
|
122
|
+
const badNext = idError(nextId);
|
|
123
|
+
if (badNext)
|
|
124
|
+
return { ok: false, error: '非法新 id:' + badNext };
|
|
125
|
+
targetId = String(nextId).trim();
|
|
126
|
+
try {
|
|
127
|
+
await stat(join(deps.dir, targetId));
|
|
128
|
+
return { ok: false, error: '新 id 已存在:' + targetId };
|
|
129
|
+
}
|
|
130
|
+
catch { /* 可用 */ }
|
|
131
|
+
try {
|
|
132
|
+
await rename(join(deps.dir, safeId), join(deps.dir, targetId));
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
return { ok: false, error: '改名失败:' + message(e) };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await writeFile(join(deps.dir, targetId, FILENAME), String(content ?? ''), 'utf8');
|
|
139
|
+
await writeMeta(targetId, { description });
|
|
140
|
+
return { ok: true, id: targetId, ...(targetId === safeId ? {} : { renamedFrom: safeId }) };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 启停:只写侧车那一个字段,正文与说明一律不动。
|
|
144
|
+
* 目录不存在时照实拒绝 —— 静默"成功"会在界面上留下一颗凭空出现的开关。
|
|
145
|
+
*/
|
|
146
|
+
async function setEnabled(id, enabled) {
|
|
147
|
+
const safeId = String(id ?? '').trim();
|
|
148
|
+
const bad = idError(safeId);
|
|
149
|
+
if (bad)
|
|
150
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
151
|
+
try {
|
|
152
|
+
await stat(join(deps.dir, safeId));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return { ok: false, error: '快捷提示词不存在:' + safeId };
|
|
156
|
+
}
|
|
157
|
+
await writeMeta(safeId, { enabled });
|
|
158
|
+
return { ok: true, id: safeId, enabled };
|
|
159
|
+
}
|
|
160
|
+
/** 删除 = 整个目录进回收站(与预设同一条纪律:不静默删用户写的东西)。 */
|
|
161
|
+
async function remove(id) {
|
|
162
|
+
const safeId = String(id ?? '').trim();
|
|
163
|
+
const bad = idError(safeId);
|
|
164
|
+
if (bad)
|
|
165
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
166
|
+
const dir = join(deps.dir, safeId);
|
|
167
|
+
try {
|
|
168
|
+
await stat(dir);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return { ok: false, error: '快捷提示词不存在:' + safeId };
|
|
172
|
+
}
|
|
173
|
+
const moved = await moveToTrash('quick-prompts', safeId, [{ from: dir, dest: 'prompt' }]);
|
|
174
|
+
if (moved.ok === false)
|
|
175
|
+
return { ok: false, error: '移入回收站失败:' + moved.error };
|
|
176
|
+
return { ok: true, id: safeId };
|
|
177
|
+
}
|
|
178
|
+
async function trashList() {
|
|
179
|
+
return { ok: true, trash: await listTrashEntries('quick-prompts') };
|
|
180
|
+
}
|
|
181
|
+
async function trashRestore(id) {
|
|
182
|
+
const entry = await readTrashEntry('quick-prompts', String(id ?? '').trim());
|
|
183
|
+
if (!entry)
|
|
184
|
+
return { ok: false, error: '回收站条目不存在:' + String(id ?? '').trim() };
|
|
185
|
+
const bad = idError(entry.name);
|
|
186
|
+
if (bad)
|
|
187
|
+
return { ok: false, error: '回收站里的 id 不合法:' + bad };
|
|
188
|
+
const target = join(deps.dir, entry.name);
|
|
189
|
+
try {
|
|
190
|
+
await stat(target);
|
|
191
|
+
return { ok: false, error: '无法恢复,同 id 已存在:' + entry.name };
|
|
192
|
+
}
|
|
193
|
+
catch { /* 可用 */ }
|
|
194
|
+
try {
|
|
195
|
+
await mkdir(deps.dir, { recursive: true });
|
|
196
|
+
await moveOutOfTrash('quick-prompts', entry.id, 'prompt', target);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
return { ok: false, error: '恢复失败:' + message(e) };
|
|
200
|
+
}
|
|
201
|
+
await purgeTrashEntry('quick-prompts', entry.id);
|
|
202
|
+
return { ok: true, id: entry.name };
|
|
203
|
+
}
|
|
204
|
+
async function trashDelete(id) {
|
|
205
|
+
const clean = String(id ?? '').trim();
|
|
206
|
+
if (!(await purgeTrashEntry('quick-prompts', clean)))
|
|
207
|
+
return { ok: false, error: '回收站条目不存在:' + clean };
|
|
208
|
+
return { ok: true, id: clean };
|
|
209
|
+
}
|
|
210
|
+
return { list, create, update, setEnabled, remove, trashList, trashRestore, trashDelete };
|
|
211
|
+
}
|
package/lib/prompts/service.js
CHANGED
|
@@ -17,6 +17,11 @@ import { join } from 'node:path';
|
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
18
|
import { isValidPresetId, LAST_APPLIED_PRESET_ID, normalizePresetId } from './preset-id.js';
|
|
19
19
|
import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
|
|
20
|
+
// 全局 AGENTS.md 的落盘走**原子写**(temp + rename,见 `memories/index-io.ts`)。
|
|
21
|
+
// 为什么非它不可(2026-09-30 审查 P2-2):这个文件是宿主**每个 agent/pre-step 都会 stat 并
|
|
22
|
+
// 重读**的,裸 `writeFile` 在中断/断电时会留下**半截内容** —— 而半截的提示词会原样进模型
|
|
23
|
+
// 上下文(截断点还可能在代码块中间)。原子写保证读到的是「要么旧、要么新」的完整内容。
|
|
24
|
+
import { writeFileAtomically } from '../memories/index-io.js';
|
|
20
25
|
const FILENAME = 'AGENTS.md';
|
|
21
26
|
/** 描述侧车:与 AGENTS.md 同目录,避免描述的文字被当成提示词正文注入。 */
|
|
22
27
|
const META_FILE = 'meta.json';
|
|
@@ -321,7 +326,7 @@ export function createPromptsService(_ctx, deps) {
|
|
|
321
326
|
}
|
|
322
327
|
const prev = await readGlobal();
|
|
323
328
|
const backedUp = await backupGlobal(prev);
|
|
324
|
-
await
|
|
329
|
+
await writeFileAtomically(await deps.getGlobalAgentsMdPath(), content);
|
|
325
330
|
await writeAppliedId(safeId);
|
|
326
331
|
return { ok: true, id: safeId, backedUp };
|
|
327
332
|
}
|
|
@@ -335,7 +340,7 @@ export function createPromptsService(_ctx, deps) {
|
|
|
335
340
|
const prev = await readGlobal();
|
|
336
341
|
const backedUp = await backupGlobal(prev);
|
|
337
342
|
try {
|
|
338
|
-
await
|
|
343
|
+
await writeFileAtomically(await deps.getGlobalAgentsMdPath(), text);
|
|
339
344
|
}
|
|
340
345
|
catch (e) {
|
|
341
346
|
return { ok: false, error: '写入全局 AGENTS.md 失败:' + message(e) };
|