dsh-plugin-tool-management 0.9.0 → 0.10.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 +323 -245
- package/README.md +212 -201
- package/README_EN.md +206 -199
- 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 +171 -111
- package/lib/client.js +6220 -5451
- package/lib/compat/probe.js +0 -5
- package/lib/context-inject.js +468 -39
- package/lib/history/bridge.js +7 -0
- package/lib/history/tombstone.js +1 -2
- package/lib/hub.js +3 -1
- package/lib/index.js +735 -95
- package/lib/mcp/override-blocks.js +10 -6
- package/lib/mcp/state-section.js +64 -21
- package/lib/rules/archive-engine.js +93 -18
- package/lib/rules/archive.js +12 -7
- package/lib/rules/service.js +195 -44
- package/lib/scene-prompt-sync.js +4 -12
- package/lib/skills/catalog.js +6 -9
- package/lib/skills/core.js +5 -4
- package/lib/skills/service.js +56 -2
- package/lib/subagents/catalog.js +31 -15
- package/lib/subagents/service.js +307 -34
- package/lib/subagents/tools.js +29 -4
- package/package.json +105 -102
- package/lib/history/projcache.js +0 -335
package/lib/rules/service.js
CHANGED
|
@@ -27,9 +27,10 @@
|
|
|
27
27
|
//
|
|
28
28
|
// 错误约定:业务校验失败返回 { ok:false, error: 中文, code, params? }(与 skills core 一致);
|
|
29
29
|
// ops 成功返回扁平 { ok:true, ... },不套 { ok:true, data }。
|
|
30
|
-
import { randomUUID } from 'node:crypto';
|
|
30
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
31
31
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
32
32
|
import { copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
33
34
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
34
35
|
import { parseSkillDoc, renameWithRetry, resolveDshHome, unquote } from '../skills/core.js';
|
|
35
36
|
import { expandUploads, planMemoryImport } from '../imports/upload.js';
|
|
@@ -486,9 +487,9 @@ export function parseModeState(raw) {
|
|
|
486
487
|
list.push(tool);
|
|
487
488
|
}
|
|
488
489
|
}
|
|
489
|
-
// v2 快照的**服务器级 /
|
|
490
|
-
// 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP
|
|
491
|
-
//
|
|
490
|
+
// v2 快照的**服务器级 / 来源级**名单、v0.8 的子智能体名单、v0.9.1 的人设全量映射
|
|
491
|
+
// 必须原样透传 —— 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP 与
|
|
492
|
+
// 技能来源永远不回滚(用户报的「MCP 不复原」「技能目录不回退」),只有工具级 / 技能级能还原。
|
|
492
493
|
const mcpServers = (Array.isArray(snapshotRaw.mcpServers) ? snapshotRaw.mcpServers : [])
|
|
493
494
|
.filter((x) => !!x && typeof x === 'object')
|
|
494
495
|
.map((x) => ({ id: String(x.id || ''), level: String(x.level || ''), disabled: x.disabled === true }))
|
|
@@ -501,6 +502,9 @@ export function parseModeState(raw) {
|
|
|
501
502
|
// 同 subagents 一组:进入时被档案**关掉**的人设(退出要重新打开)。漏掉它 = 用户实测的
|
|
502
503
|
// 「进场景关掉了,退出却没开回来」——快照落盘后读回来就只剩「被启用」那一半名单。
|
|
503
504
|
const subagentsOn = (Array.isArray(snapshotRaw.subagentsOn) ? snapshotRaw.subagentsOn : []).map((x) => String(x)).filter(Boolean);
|
|
505
|
+
// v0.9.1 的人设**全量**开关映射(名字 → 进场景时是否开着):同样必须原样透传 ——
|
|
506
|
+
// 剥掉它,退出模式就只能退回上面两个部分名单,「场景里手动开过的人设」不再被还原。
|
|
507
|
+
const subagentsAll = toFlagMap(snapshotRaw.subagentsAll);
|
|
504
508
|
// v0.8.1 的场景备注恢复名单:**必须原样透传**——这里漏掉它,退出模式时备注永不回退
|
|
505
509
|
//(与历史上 mcpServers / skillSources 被剥掉是同一类 bug)。
|
|
506
510
|
const mcpNotes = (Array.isArray(snapshotRaw.mcpNotes) ? snapshotRaw.mcpNotes : [])
|
|
@@ -517,6 +521,7 @@ export function parseModeState(raw) {
|
|
|
517
521
|
...(skillSources.length ? { skillSources } : {}),
|
|
518
522
|
...(subagents.length ? { subagents } : {}),
|
|
519
523
|
...(subagentsOn.length ? { subagentsOn } : {}),
|
|
524
|
+
...(Object.keys(subagentsAll).length ? { subagentsAll } : {}),
|
|
520
525
|
...(mcpNotes.length ? { mcpNotes } : {}),
|
|
521
526
|
}
|
|
522
527
|
: null,
|
|
@@ -953,7 +958,7 @@ function probeSceneFilesSync(rulesRoot, index) {
|
|
|
953
958
|
].join('\u0002');
|
|
954
959
|
return { refs, scenes, truncated, signature };
|
|
955
960
|
}
|
|
956
|
-
/** 指纹里必须包含一切影响渲染的索引字段(active / enabled / order / groups.order / scenes.order)。 */
|
|
961
|
+
/** 指纹里必须包含一切影响渲染的索引字段(active / enabled / order / groups.order / scenes.order / label / description)。 */
|
|
957
962
|
function signatureOfIndex(index) {
|
|
958
963
|
const active = normalizeActive(index.active);
|
|
959
964
|
const rules = Object.keys(index.rules).sort().map((id) => {
|
|
@@ -962,9 +967,11 @@ function signatureOfIndex(index) {
|
|
|
962
967
|
});
|
|
963
968
|
const groups = Object.keys(index.groups).sort().map((g) => `${g}\u0000${index.groups[g]?.order ?? DEFAULT_GROUP_ORDER}`);
|
|
964
969
|
// 场景顺序决定段内场景的先后 → 必须进指纹,否则改顺序后段文本不会重算。
|
|
970
|
+
// label 与 description 同理(sceneHeader 的「场景说明」一行直接渲染 description)——
|
|
971
|
+
// 手改索引文件(带外变更)时只有指纹变化才会触发重算。
|
|
965
972
|
const scenes = Object.keys(index.scenes || {}).sort().map((s) => {
|
|
966
973
|
const e = index.scenes[s];
|
|
967
|
-
return `${s}\u0000${e.order ?? (s === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER)}\u0000${e.label ?? ''}`;
|
|
974
|
+
return `${s}\u0000${e.order ?? (s === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER)}\u0000${e.label ?? ''}\u0000${e.description ?? ''}`;
|
|
968
975
|
});
|
|
969
976
|
return `A:${active === null ? '*' : active.join(',')}|R:${rules.join(';')}|G:${groups.join(';')}|S:${scenes.join(';')}`;
|
|
970
977
|
}
|
|
@@ -1200,8 +1207,8 @@ function sceneHeader(scene, index) {
|
|
|
1200
1207
|
return `${head}\n\n**场景说明:${description}**\n\n`;
|
|
1201
1208
|
}
|
|
1202
1209
|
/**
|
|
1203
|
-
*
|
|
1204
|
-
* ——
|
|
1210
|
+
* 段首的引导语:让模型知道下面是**用户为本机写的参考信息**,并且**以它为准**
|
|
1211
|
+
* —— 涉及本机的事一律照它办,确实无关时才放下。
|
|
1205
1212
|
*
|
|
1206
1213
|
* 写法(2026-09-16 用户裁定,基于真实注入结果的三次修正):
|
|
1207
1214
|
* - **单行、加粗**,不再用括号分两行 —— 括号跨行在真实提示词里读起来像被截断,
|
|
@@ -1216,10 +1223,37 @@ function sceneHeader(scene, index) {
|
|
|
1216
1223
|
*
|
|
1217
1224
|
* 用词:不用「常驻」「注入」这类内部行话(模型没有先验);用户裁定用「信息」而不是
|
|
1218
1225
|
* 「记忆」——「记忆」在系统提示词里指代不明,而这段的实质就是用户写的参考信息。
|
|
1226
|
+
*
|
|
1227
|
+
* 2026-09-17 重写(用户:「当前模式会不重视这些提示词」)。上一版的三个毛病都在**授权**
|
|
1228
|
+
* 上,而不在措辞好不好看上:
|
|
1229
|
+
* 1. 「与当前任务相关时直接采用」——**没有给"相关"的判据**。最省力的解读永远是"无关",
|
|
1230
|
+
* 因为判成无关不需要任何工作;
|
|
1231
|
+
* 2. 通篇没有优先级规则。与本机实际情况冲突时,模型会默默按自己的默认假设走,
|
|
1232
|
+
* 而用户完全不知道发生了什么;
|
|
1233
|
+
* 3. 「无关时忽略」——「忽略」是这句话里**最后一个动词**,也是记得最牢的那个。它把
|
|
1234
|
+
* 一个免打扰出口写成了对内容的态度许可。
|
|
1235
|
+
* 现在:给出**判据**(凡涉及本机路径 / 配置 / 工具 / 习惯)、给出**裁决规则**、把出口降级为
|
|
1236
|
+
* 「不必提及」(关于**要不要声明**,不是关于**要不要采用**)。出口保留是必要的 —— 去掉它
|
|
1237
|
+
* 会让模型对无关条目强行攀附,那是另一种失真。
|
|
1238
|
+
*
|
|
1239
|
+
* 2026-09-17 第二版:**按条目类型分级授权**(用户采纳的四条里的第 1、4 条)。Claude Code
|
|
1240
|
+
* 把两类内容分进两个系统、用**相反**的授权:用户指令(`claudemd.ts:89`)是
|
|
1241
|
+
* "These instructions OVERRIDE any default behavior and you MUST follow them exactly as
|
|
1242
|
+
* written",而记忆(`memdir/memoryTypes.ts:202`)是
|
|
1243
|
+
* "If a recalled memory conflicts with current information, trust what you observe now"
|
|
1244
|
+
* —— 书里(ch11:25)说记忆是 "working notes, not gospel"。上一版把两类塞进一句授权,
|
|
1245
|
+
* 对**场景说明**(用户写的约定)是对的,对**记忆条目**(可能是几个月前记下的事实)是错的。
|
|
1246
|
+
* 好在这两类在渲染时就分处不同位置:场景说明在 `sceneHeader` 的 `**场景说明:…**` 里,
|
|
1247
|
+
* 记忆条目在 `memoryBlock` 里 —— 所以一句话就能分级,不必改数据结构。
|
|
1248
|
+
*
|
|
1249
|
+
* 冲突阶梯(第 4 条)来自 Codex `base_instructions/default.md:22-27` 与 Claude Code 的
|
|
1250
|
+
* `caller override > agent definition > parent model > default`:把"谁高于谁"写明,
|
|
1251
|
+
* 模型才不会在「用户当场说的 ≠ 本机记录」时悬空。写明它还有一个反直觉的好处 ——
|
|
1252
|
+
* 它让授权更可信:这说明本条不是要让记忆压过用户,只是要压过模型的默认假设。
|
|
1219
1253
|
*/
|
|
1220
|
-
const SCENE_MEMORY_NOTE = '
|
|
1254
|
+
const SCENE_MEMORY_NOTE = '**用户为本机写的参考信息:「场景说明」是用户的约定,一律照办,覆盖你的默认做法;其余条目是记录,可能已过期 —— 与当前实际情况冲突时以你看到的为准,与用户当场说的冲突时以用户为准。无关时不必提及。以下就是全部信息。**';
|
|
1221
1255
|
/** 有未注入条目时的版本:去掉完整性声明(见上)。 */
|
|
1222
|
-
const SCENE_MEMORY_NOTE_PARTIAL = '
|
|
1256
|
+
const SCENE_MEMORY_NOTE_PARTIAL = '**用户为本机写的参考信息:「场景说明」是用户的约定,一律照办,覆盖你的默认做法;其余条目是记录,可能已过期 —— 与当前实际情况冲突时以你看到的为准,与用户当场说的冲突时以用户为准。无关时不必提及。**';
|
|
1223
1257
|
/**
|
|
1224
1258
|
* 段首固定块。以 `\n` 结尾,与场景块 join 后自然空一行。
|
|
1225
1259
|
* 预算按**较长**的那版算(见 renderSceneMemory),保守一点只会浪费几个字节。
|
|
@@ -1251,6 +1285,37 @@ function attachmentNamesSync(bundleDir, name) {
|
|
|
1251
1285
|
return [];
|
|
1252
1286
|
}
|
|
1253
1287
|
}
|
|
1288
|
+
/**
|
|
1289
|
+
* bundle 记忆的附件**摘要**(列表页用):数量 / 总体积 / 前几个文件名。
|
|
1290
|
+
*
|
|
1291
|
+
* 口径与 `attachmentNamesSync`(也就是注入给模型的那份清单)逐字一致:跳过正文本体,
|
|
1292
|
+
* 只认普通文件 —— 所以页面上的数字与模型实际看到的一致,不会出现「界面说 3 个、模型只见 2 个」。
|
|
1293
|
+
* `names` 截到 ATTACHMENT_LIST_MAX:tooltip 列不下更多,要全看到编辑弹窗里去看。
|
|
1294
|
+
*
|
|
1295
|
+
* 目录读不到(索引残留了已消失的条目)→ 返回 null,让界面**什么都不显示**,
|
|
1296
|
+
* 而不是谎报「0 个附件」。
|
|
1297
|
+
*/
|
|
1298
|
+
function attachmentSummarySync(bundleDir, name) {
|
|
1299
|
+
let names;
|
|
1300
|
+
try {
|
|
1301
|
+
const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
|
|
1302
|
+
names = readdirSync(bundleDir, { withFileTypes: true })
|
|
1303
|
+
.filter((e) => e.isFile() && !docNames.has(e.name))
|
|
1304
|
+
.map((e) => e.name)
|
|
1305
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1306
|
+
}
|
|
1307
|
+
catch {
|
|
1308
|
+
return null;
|
|
1309
|
+
}
|
|
1310
|
+
let bytes = 0;
|
|
1311
|
+
for (const entry of names) {
|
|
1312
|
+
try {
|
|
1313
|
+
bytes += statSync(join(bundleDir, entry)).size;
|
|
1314
|
+
}
|
|
1315
|
+
catch { /* 读不到的条目不计体积 */ }
|
|
1316
|
+
}
|
|
1317
|
+
return { count: names.length, bytes, names: names.slice(0, ATTACHMENT_LIST_MAX) };
|
|
1318
|
+
}
|
|
1254
1319
|
/**
|
|
1255
1320
|
* 附件行(只有 bundle 记忆才有):**给目录与文件名,不给内容**。
|
|
1256
1321
|
* 附件可能是图片、二进制、大 md —— 全文注入又贵又会把段预算吃光;给路径,模型需要时自己读。
|
|
@@ -1564,12 +1629,20 @@ export function createRulesService(ctx, deps) {
|
|
|
1564
1629
|
}
|
|
1565
1630
|
}
|
|
1566
1631
|
// ── 规则文件序列化 ──────────────────────────────────────────────────────
|
|
1567
|
-
/**
|
|
1632
|
+
/**
|
|
1633
|
+
* 值含换行用 `|` 块(保留换行);否则 `key: value` 原样(parseSkillDoc 按行贪婪解析)。
|
|
1634
|
+
* 例外:多行值里有去空白后恰为 `---` 的行时改用 JSON 引号标量 —— 块标量把内容行缩进
|
|
1635
|
+
* 两格也躲不开 parseSkillDoc 的 frontmatter 结束扫描(`trim() === '---'`),描述会被
|
|
1636
|
+
* 截断、残片混进正文;引号形式是单物理行,扫描无从误判,decodeYamlScalar 无损还原
|
|
1637
|
+
* (skills 写侧的引号标量正是靠这一点免疫同一断裂)。
|
|
1638
|
+
*/
|
|
1568
1639
|
function yamlField(key, value) {
|
|
1569
1640
|
if (typeof value === 'boolean')
|
|
1570
1641
|
return [`${key}: ${value}`];
|
|
1571
1642
|
const s = String(value);
|
|
1572
1643
|
if (s.includes('\n')) {
|
|
1644
|
+
if (s.split('\n').some((line) => line.trim() === '---'))
|
|
1645
|
+
return [`${key}: ${JSON.stringify(s)}`];
|
|
1573
1646
|
return [`${key}: |`, ...s.split('\n').map((line) => ` ${line}`)];
|
|
1574
1647
|
}
|
|
1575
1648
|
return [`${key}: ${s}`];
|
|
@@ -1667,7 +1740,16 @@ export function createRulesService(ctx, deps) {
|
|
|
1667
1740
|
const index = await readIndex(stateDir);
|
|
1668
1741
|
const rules = snap.rules
|
|
1669
1742
|
.filter((r) => !groupFilter || r.group === groupFilter)
|
|
1670
|
-
.sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name))
|
|
1743
|
+
.sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name))
|
|
1744
|
+
// bundle 记忆附带附件摘要(用户裁定 2026-09-17):界面要显示「几个附件」。
|
|
1745
|
+
// 每条 bundle 一次 readdirSync + 每个附件一次 statSync —— bundle 记忆通常只有几条,
|
|
1746
|
+
// 这个开销可以忽略;flat 没有目录,直接原样返回(不加字段 = 界面不显示附件标签)。
|
|
1747
|
+
.map((r) => {
|
|
1748
|
+
if (r.form !== 'bundle')
|
|
1749
|
+
return r;
|
|
1750
|
+
const attach = attachmentSummarySync(dirname(r.path), r.name);
|
|
1751
|
+
return attach ? { ...r, attach } : r;
|
|
1752
|
+
});
|
|
1671
1753
|
const groups = groupFilter ? snap.groups.filter((g) => g.name === groupFilter) : snap.groups;
|
|
1672
1754
|
const scenes = sceneRows(snap, index);
|
|
1673
1755
|
// 场景锁定状态(v0.8):任意一个场景锁定 = 五个管理域整体冻结。界面据此禁用各页的写控件。
|
|
@@ -1846,7 +1928,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1846
1928
|
const indexForScene = await readIndex(stateDir);
|
|
1847
1929
|
// 保留场景 global 恒存在(不用先建);其余场景必须已存在——见上方注释。
|
|
1848
1930
|
if (targetGroup !== GLOBAL_SCENE && !indexForScene.scenes?.[targetGroup] && !(await pathExists(join(rulesRoot, targetGroup)))) {
|
|
1849
|
-
return fail('error.rules.sceneNotFound', `场景不存在:${targetGroup}
|
|
1931
|
+
return fail('error.rules.sceneNotFound', `场景不存在:${targetGroup}(请先在「场景」页创建该场景)`, { group: targetGroup });
|
|
1850
1932
|
}
|
|
1851
1933
|
if (!isValidGroupSegment(name))
|
|
1852
1934
|
return fail('error.rules.invalidName', `记忆名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含 / \\ < > : " | ? *、不以 . 开头)`);
|
|
@@ -2074,7 +2156,8 @@ export function createRulesService(ctx, deps) {
|
|
|
2074
2156
|
else
|
|
2075
2157
|
description = deriveDescription(newBody) || undefined;
|
|
2076
2158
|
}
|
|
2077
|
-
// 形态转换 /
|
|
2159
|
+
// 形态转换 / 改名:先建新形态文件,再把旧形态复制进回收站、最后删原件
|
|
2160
|
+
// (确保任意失败点不产生半份规则;旧形态随时可恢复,README「删除都进回收站」无例外)。
|
|
2078
2161
|
// description 写回约定:显式传 → string/null(删除);未传 → undefined(保留原字段)。
|
|
2079
2162
|
const serialize = () => {
|
|
2080
2163
|
let descArg;
|
|
@@ -2087,16 +2170,19 @@ export function createRulesService(ctx, deps) {
|
|
|
2087
2170
|
if (located.kind === 'bundle' && newForm === 'flat') {
|
|
2088
2171
|
await mkdir(join(rulesRoot, parts.group), { recursive: true });
|
|
2089
2172
|
await writeFileAtomically(join(rulesRoot, parts.group, newName + '.md'), serialize());
|
|
2173
|
+
await copyIntoMemoriesTrash(located, parts.group, parts.name);
|
|
2090
2174
|
await rm(located.entryPath, { recursive: true, force: true });
|
|
2091
2175
|
}
|
|
2092
2176
|
else if (located.kind === 'flat' && newForm === 'bundle') {
|
|
2093
2177
|
await mkdir(join(rulesRoot, parts.group, newName), { recursive: true });
|
|
2094
2178
|
await writeFileAtomically(join(rulesRoot, parts.group, newName, bundleDocName(newName)), serialize());
|
|
2179
|
+
await copyIntoMemoriesTrash(located, parts.group, parts.name);
|
|
2095
2180
|
await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
|
|
2096
2181
|
}
|
|
2097
2182
|
else if (located.kind === 'flat' && newName !== parts.name) {
|
|
2098
2183
|
// flat 改名 = 文件改名
|
|
2099
2184
|
await writeFileAtomically(join(rulesRoot, parts.group, newName + '.md'), serialize());
|
|
2185
|
+
await copyIntoMemoriesTrash(located, parts.group, parts.name);
|
|
2100
2186
|
await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
|
|
2101
2187
|
}
|
|
2102
2188
|
else {
|
|
@@ -2117,28 +2203,40 @@ export function createRulesService(ctx, deps) {
|
|
|
2117
2203
|
invalidateSnapshot();
|
|
2118
2204
|
return { ok: true, rule: await buildProjected(newId) };
|
|
2119
2205
|
}
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
2206
|
+
/**
|
|
2207
|
+
* 把条目原件复制进 memories 回收站并写 manifest,返回 trashId。只复制、不删除:
|
|
2208
|
+
* 原件的 rm 由调用方在复制成功后执行 —— 先有副本才允许删,rm 永远不会销毁唯一数据。
|
|
2209
|
+
* manifest 先于原件删除落盘:中途崩溃最坏留下「原件还在 + 一份完整回收站副本」,
|
|
2210
|
+
* 不会出现「有文件没清单」的损坏条目。
|
|
2211
|
+
*/
|
|
2212
|
+
async function copyIntoMemoriesTrash(located, group, name) {
|
|
2128
2213
|
const trashId = Date.now().toString(36) + '-' + randomUUID().slice(0, 8);
|
|
2129
2214
|
const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
|
|
2130
|
-
const manifest = { group
|
|
2215
|
+
const manifest = { group, name, form: located.kind, deletedAt: new Date().toISOString() };
|
|
2131
2216
|
await mkdir(trashDir, { recursive: true });
|
|
2132
2217
|
if (located.kind === 'bundle') {
|
|
2133
2218
|
const { cp } = await import('node:fs/promises');
|
|
2134
2219
|
await cp(located.entryPath, join(trashDir, 'bundle'), { recursive: true });
|
|
2135
|
-
await rm(located.entryPath, { recursive: true, force: true });
|
|
2136
2220
|
}
|
|
2137
2221
|
else {
|
|
2138
2222
|
await copyFile(located.docPath, join(trashDir, 'rule.md'));
|
|
2139
|
-
await rm(located.docPath, { force: true });
|
|
2140
2223
|
}
|
|
2141
2224
|
await writeFileAtomically(join(trashDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
2225
|
+
return trashId;
|
|
2226
|
+
}
|
|
2227
|
+
async function rulesRemove(args) {
|
|
2228
|
+
const id = String((args && args.id) || '');
|
|
2229
|
+
const parts = parseId(id);
|
|
2230
|
+
if (!parts)
|
|
2231
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
2232
|
+
const located = await locateRule(parts.group, parts.name);
|
|
2233
|
+
if (!located)
|
|
2234
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
2235
|
+
const trashId = await copyIntoMemoriesTrash(located, parts.group, parts.name);
|
|
2236
|
+
if (located.kind === 'bundle')
|
|
2237
|
+
await rm(located.entryPath, { recursive: true, force: true });
|
|
2238
|
+
else
|
|
2239
|
+
await rm(located.docPath, { force: true });
|
|
2142
2240
|
const index = await readIndex(stateDir);
|
|
2143
2241
|
delete index.rules[id];
|
|
2144
2242
|
await writeIndex(stateDir, index);
|
|
@@ -2383,7 +2481,7 @@ export function createRulesService(ctx, deps) {
|
|
|
2383
2481
|
return fail('error.rules.invalidArgs', '缺少参数:需要 scenes:[...](启用集合,至多一个场景)');
|
|
2384
2482
|
}
|
|
2385
2483
|
if (hasAll) {
|
|
2386
|
-
return fail('error.rules.singleSceneOnly', '除「全局」外同时只能启用一个场景:不支持"全部启用",请改用 scenes:[<场景名>] 或 scenes:[](全部关闭)');
|
|
2484
|
+
return fail('error.rules.singleSceneOnly', '除「全局」外同时只能启用一个场景:不支持"全部启用",请改用 scenes:[<场景名>] 或 scenes:[](全部关闭)', { detail: ':不支持"全部启用",请改用 scenes:[<场景名>] 或 scenes:[](全部关闭)' });
|
|
2387
2485
|
}
|
|
2388
2486
|
const index = await readIndex(stateDir);
|
|
2389
2487
|
const raw = Array.isArray(args && args.scenes) ? args.scenes : [];
|
|
@@ -2403,7 +2501,7 @@ export function createRulesService(ctx, deps) {
|
|
|
2403
2501
|
names.push(name);
|
|
2404
2502
|
}
|
|
2405
2503
|
if (names.length > 1) {
|
|
2406
|
-
return fail('error.rules.singleSceneOnly', `除「全局」外同时只能启用一个场景(收到 ${names.length} 个:${names.join('、')})`);
|
|
2504
|
+
return fail('error.rules.singleSceneOnly', `除「全局」外同时只能启用一个场景(收到 ${names.length} 个:${names.join('、')})`, { detail: `(收到 ${names.length} 个:${names.join('、')})` });
|
|
2407
2505
|
}
|
|
2408
2506
|
index.active = names;
|
|
2409
2507
|
await writeIndex(stateDir, index);
|
|
@@ -2534,7 +2632,7 @@ export function createRulesService(ctx, deps) {
|
|
|
2534
2632
|
return fail('error.rules.invalidGroup', `新场景名非法:${nextRaw}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
|
|
2535
2633
|
}
|
|
2536
2634
|
if (name === GLOBAL_SCENE)
|
|
2537
|
-
return fail('error.rules.reservedScene', '「全局」是保留场景,不可改名');
|
|
2635
|
+
return fail('error.rules.reservedScene', '「全局」是保留场景,不可改名', { name: GLOBAL_SCENE, action: 'rename', reason: '' });
|
|
2538
2636
|
if (name === SHARED_GROUP || nextRaw === SHARED_GROUP)
|
|
2539
2637
|
return fail('error.rules.invalidGroup', '_shared 是保留场景名,不可改名');
|
|
2540
2638
|
if ((index.scenes && index.scenes[nextRaw]) || (await pathExists(join(rulesRoot, nextRaw)))) {
|
|
@@ -2609,7 +2707,7 @@ export function createRulesService(ctx, deps) {
|
|
|
2609
2707
|
if (!isValidGroupPath(name))
|
|
2610
2708
|
return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
|
|
2611
2709
|
if (name === GLOBAL_SCENE || name === SHARED_GROUP)
|
|
2612
|
-
return fail('error.rules.reservedScene', '「全局 / _shared」是保留场景,不可锁定');
|
|
2710
|
+
return fail('error.rules.reservedScene', '「全局 / _shared」是保留场景,不可锁定', { name: '全局 / _shared', action: 'lock', reason: '' });
|
|
2613
2711
|
if (typeof (args && args.locked) !== 'boolean') {
|
|
2614
2712
|
return fail('error.rules.invalidArgs', '缺少参数:locked 必须是布尔值(只按传入值写入,不做"翻转"推断)');
|
|
2615
2713
|
}
|
|
@@ -2638,7 +2736,7 @@ export function createRulesService(ctx, deps) {
|
|
|
2638
2736
|
if (name === SHARED_GROUP)
|
|
2639
2737
|
return fail('error.rules.invalidGroup', `_shared 是保留场景名,不可删除`);
|
|
2640
2738
|
if (name === GLOBAL_SCENE)
|
|
2641
|
-
return fail('error.rules.reservedScene',
|
|
2739
|
+
return fail('error.rules.reservedScene', `「全局」是保留场景,不可删除(它的记忆对任何对话都生效)`, { name: GLOBAL_SCENE, action: 'delete', reason: '(它的记忆对任何对话都生效)' });
|
|
2642
2740
|
const index = await readIndex(stateDir);
|
|
2643
2741
|
// 场景不存在(既无记录也无目录)→ 明确报错,而不是假装删成功。
|
|
2644
2742
|
if (!index.scenes?.[name] && !(await pathExists(join(rulesRoot, name)))) {
|
|
@@ -2851,24 +2949,76 @@ export function createRulesService(ctx, deps) {
|
|
|
2851
2949
|
// (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
|
|
2852
2950
|
const memoryText = () => sceneMemory().text;
|
|
2853
2951
|
/**
|
|
2854
|
-
* `~/.dsh/AGENTS.md`
|
|
2952
|
+
* `~/.dsh/AGENTS.md` 正文的上限,与官方那一行的 `maxBytes` 同量级(64 KiB):超大文件按字节
|
|
2855
2953
|
* 截断并留一行标记,免得一份手写的巨型基线把上下文撑爆。
|
|
2856
2954
|
*/
|
|
2857
2955
|
const AGENTS_MD_MAX_BYTES = 65536;
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
const
|
|
2865
|
-
return
|
|
2956
|
+
/**
|
|
2957
|
+
* DSH home 的**展示形态**,规则与官方 `dsh-home-paths` 的 `dshHomeDisplay()` 一致
|
|
2958
|
+
* (`dsh-home-paths/lib/index.js:93`):默认位置显示 `~/.dsh`,被 `DSH_HOME` 指到别处时
|
|
2959
|
+
* 才显示 `$DSH_HOME`。只在给**界面**看的字符串里用 —— 读盘一律用真实路径。
|
|
2960
|
+
*/
|
|
2961
|
+
const dshHomeDisplay = () => {
|
|
2962
|
+
const home = resolveDshHome();
|
|
2963
|
+
return resolve(home) === resolve(join(homedir(), '.dsh')) ? '~/.dsh' : '$DSH_HOME';
|
|
2866
2964
|
};
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2965
|
+
/**
|
|
2966
|
+
* 当前生效的提示词:**来源文件 + 正文**,一次解析、两处出口共用(模型侧 `promptText`、
|
|
2967
|
+
* 界面清单 `promptFiles`)。`null` = 现在没有提示词。
|
|
2968
|
+
*
|
|
2969
|
+
* 为什么合并成一处:两条出口必须给出**同一个**结论(文件是哪个、正文取哪份),分开写两份
|
|
2970
|
+
* 同样的分支判断迟早分叉 —— 分叉的后果就是界面标错文件、或模型看到与文件不符的正文。
|
|
2971
|
+
* 合并前那里已经有一处小分叉:场景预设在场但正文为空时,文本退回 AGENTS.md、清单却仍报
|
|
2972
|
+
* 预设路径。现在两边同源,这类角落不可能再各说各话。
|
|
2973
|
+
*
|
|
2974
|
+
* `digest` 照抄官方 `instructionContentSha1`(`dsh-agent-instructions/lib/index.js:90`,
|
|
2975
|
+
* sha1 hex):算的是**文件内容**(AGENTS.md 取截断前的原文),是"文件这一版"的身份,
|
|
2976
|
+
* 不是"这次注入了什么";界面只拿它当悬停提示,不参与任何判定。
|
|
2977
|
+
*
|
|
2978
|
+
* `text` 是**模型侧**正文,开头一行 `来源:<展示路径>` —— 照抄官方 agent-instructions 的
|
|
2979
|
+
* 写法(`Instructions from: <path>`,`render.js` 的 `sectionText`)。模型据此知道这些规则
|
|
2980
|
+
* 写在哪个文件里:用户说"把这条记下来"时它知道该改哪份文件,而不是只能凭印象回答。
|
|
2981
|
+
*/
|
|
2982
|
+
const resolvePrompt = () => {
|
|
2983
|
+
// 场景提示词与 AGENTS.md 正文在用户眼里就是同一件事("全局提示词"):场景期间前者取代后者,
|
|
2984
|
+
// 与官方语义一致(进场景改写文件、退出恢复)。所以一个域、一份文本,取到非空的场景提示词就用它。
|
|
2985
|
+
const scene = resolveScenePreset();
|
|
2986
|
+
if (scene && scene.text.trim() !== '') {
|
|
2987
|
+
const path = `${dshHomeDisplay()}/${HUB_DIR}/${PRESETS_DIR}/${scene.presetId}/AGENTS.md`;
|
|
2988
|
+
return {
|
|
2989
|
+
path,
|
|
2990
|
+
digest: createHash('sha1').update(scene.text).digest('hex'),
|
|
2991
|
+
text: `来源:${path}\n\n${scene.text}`,
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
const raw = readGlobalAgentsMdSync();
|
|
2995
|
+
if (raw.trim() === '')
|
|
2996
|
+
return null;
|
|
2997
|
+
const path = `${dshHomeDisplay()}/AGENTS.md`;
|
|
2998
|
+
const body = Buffer.byteLength(raw, 'utf8') <= AGENTS_MD_MAX_BYTES
|
|
2999
|
+
? raw
|
|
3000
|
+
: Buffer.from(raw, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8') +
|
|
3001
|
+
`\n\n(…文件超过 ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB,已截断。)`;
|
|
3002
|
+
return { path, digest: createHash('sha1').update(raw).digest('hex'), text: `来源:${path}\n\n${body}` };
|
|
3003
|
+
};
|
|
3004
|
+
/**
|
|
3005
|
+
* 模型侧的提示词正文(`''` = 没有可注入的内容)。
|
|
3006
|
+
*
|
|
3007
|
+
* **不判"与文件重复"** —— 预设挂了官方 agent-instructions 时那份正文已经由文件送达
|
|
3008
|
+
* (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
|
|
3009
|
+
*/
|
|
3010
|
+
const promptText = () => resolvePrompt()?.text ?? '';
|
|
3011
|
+
/**
|
|
3012
|
+
* `promptText()` 的**来源文件**(同步):注入通道把它当作 instructions 形态的
|
|
3013
|
+
* `changes` 交给界面(文件清单 + 已载入/已更新),见 context-inject.ts 的 `files`。
|
|
3014
|
+
* 与文本同源(都走 `resolvePrompt`),所以两边不会分叉;`[]` = 无正文。
|
|
3015
|
+
*
|
|
3016
|
+
* 路径用**展示形态**(见 `dshHomeDisplay`):界面里官方 agent-instructions 那条行写的是
|
|
3017
|
+
* `~/.dsh/AGENTS.md`,我们写绝对路径的话,同一个界面上会出现两种风格。
|
|
3018
|
+
*/
|
|
3019
|
+
const promptFiles = () => {
|
|
3020
|
+
const resolved = resolvePrompt();
|
|
3021
|
+
return resolved === null ? [] : [{ path: resolved.path, digest: resolved.digest }];
|
|
2872
3022
|
};
|
|
2873
3023
|
/** 写操作:串行队列内执行,成功后触发 refresh(失效缓存,下一请求即生效)。 */
|
|
2874
3024
|
const runWrite = (task) => enqueueMutation(async () => {
|
|
@@ -2919,6 +3069,7 @@ export function createRulesService(ctx, deps) {
|
|
|
2919
3069
|
writeOps,
|
|
2920
3070
|
memoryText,
|
|
2921
3071
|
promptText,
|
|
3072
|
+
promptFiles,
|
|
2922
3073
|
refresh,
|
|
2923
3074
|
patchIndex,
|
|
2924
3075
|
readArchiveSlice,
|
package/lib/scene-prompt-sync.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// 六个动作会触发同步(调用方在 op 层包一层 `withSync`):
|
|
10
10
|
// ① 启用/切换场景 ② 关掉场景(恢复进场景前的基线)
|
|
11
11
|
// ③ 改场景绑定的预设 ④ 编辑"正在驱动基线的那份预设"的正文
|
|
12
|
-
// ⑤ 从回收站恢复预设(绑定重新变活) ⑥
|
|
12
|
+
// ⑤ 从回收站恢复预设(绑定重新变活) ⑥ 应用提示词(**应用别的份 = 换掉当前场景的绑定**,
|
|
13
|
+
// 改完绑定再走一次同步;见 index.ts 的 `applyPresetGuarded`)
|
|
13
14
|
//
|
|
14
15
|
// 「进场景前的基线」快照存在 hub 的 `scene-baseline.json`:进场景时记下当时的
|
|
15
16
|
// AGENTS.md 正文(以及当时匹配到的预设 id),关掉场景时**按原文**写回 ——
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
// 写失败**不改**原操作的成功结论,而是把原因放进 `error` 交给界面显示成警告。
|
|
21
22
|
//
|
|
22
23
|
// 这里同时是**三条状态的事实源**(用户实测反馈「显示 A、实际注入 B、A 还能删 B 不能删」后加的):
|
|
23
|
-
// `driver()` ——
|
|
24
|
+
// `driver()` —— 谁在驱动基线(提示词页「应用」据此判断:应用别的份 = 换掉该场景的绑定);
|
|
24
25
|
// `refs()` —— 每个预设被谁引用(删除保护的唯一依据:场景绑定 / 基线当前内容 / 退出恢复目标);
|
|
25
26
|
// `baseline()` —— 进场景前的基线快照(退出场景后要恢复的那一份)。
|
|
26
27
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
@@ -85,15 +86,6 @@ export function createScenePromptSync(deps) {
|
|
|
85
86
|
return null;
|
|
86
87
|
return { scene: current.scene, label: current.label || current.scene, presetId: current.presetId };
|
|
87
88
|
}
|
|
88
|
-
async function applyGuard(id) {
|
|
89
|
-
const current = await driver();
|
|
90
|
-
if (!current)
|
|
91
|
-
return null;
|
|
92
|
-
// 同一份 = 重新应用(把被手改的基线写回场景绑定的内容)→ 放行。
|
|
93
|
-
if (current.presetId === String(id ?? '').trim())
|
|
94
|
-
return null;
|
|
95
|
-
return current;
|
|
96
|
-
}
|
|
97
89
|
async function refs() {
|
|
98
90
|
const out = new Map();
|
|
99
91
|
const add = (id, ref) => {
|
|
@@ -197,5 +189,5 @@ export function createScenePromptSync(deps) {
|
|
|
197
189
|
const agentsMd = await sync();
|
|
198
190
|
return { ...res, agentsMd };
|
|
199
191
|
}
|
|
200
|
-
return { sync, withSync, state, driver,
|
|
192
|
+
return { sync, withSync, state, driver, refs };
|
|
201
193
|
}
|
package/lib/skills/catalog.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
//
|
|
12
12
|
// 与官方目录的两处有意差别:
|
|
13
13
|
// - 只列**当前启用且可被模型调用**的(官方只过滤可调用性,不管用户在插件页关掉了谁)。
|
|
14
|
-
// -
|
|
15
|
-
// `
|
|
14
|
+
// - 「本预设没有官方 `skill` 加载工具,要正文先用 `skill_manager_list` 取源文件路径」那句
|
|
15
|
+
// 2026-09-18 起挪进注入框架的 `how` 行(context-inject.ts 的 DOMAIN_FRAME.skills)——
|
|
16
|
+
// 本段只排版清单,标题与"怎么用"一处一个出处。
|
|
16
17
|
//
|
|
17
18
|
// 同步性:`text()` 必须同步返回(注入通道每个 step 同步取文本),而技能清单是异步读盘 →
|
|
18
19
|
// 与子智能体目录同构的 stale-while-revalidate:`text()` 返回缓存值并在超龄时后台重算,
|
|
@@ -72,13 +73,9 @@ export function renderSkillCatalog(data, maxEntries = SKILL_CATALOG_MAX_ENTRIES,
|
|
|
72
73
|
const sorted = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
73
74
|
const shown = sorted.slice(0, Math.max(0, maxEntries));
|
|
74
75
|
const lines = shown.map((row) => (row.description ? '- `' + row.name + '`: ' + row.description : '- `' + row.name + '`'));
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
'**本预设没有官方 `skill` 加载工具;要技能正文时用 `skill_manager_list` 取源文件路径再读。**',
|
|
79
|
-
'',
|
|
80
|
-
...lines,
|
|
81
|
-
];
|
|
76
|
+
// 只有清单:标题、工具名与"只给摘要、读完再用"的纪律由注入通道的框架承担
|
|
77
|
+
// (2026-09-18 起,见 context-inject.ts 的 DOMAIN_FRAME 的 `how` 行)—— 一处内容一个出处。
|
|
78
|
+
const out = [...lines];
|
|
82
79
|
const hidden = sorted.length - shown.length;
|
|
83
80
|
if (hidden > 0)
|
|
84
81
|
out.push('', `(另有 ${hidden} 个未列出,用 \`skill_manager_list\` 查。)`);
|
package/lib/skills/core.js
CHANGED
|
@@ -564,13 +564,14 @@ function notDeletableError(definition) {
|
|
|
564
564
|
*/
|
|
565
565
|
function reservedSourceError(root) {
|
|
566
566
|
const key = root && root.key ? root.key : "";
|
|
567
|
+
const origin = key === "dsh"
|
|
568
|
+
? "DSH 技能目录是默认来源,必须读取"
|
|
569
|
+
: "导入技能目录是默认来源、插件新建/导入的落点,必须读取";
|
|
567
570
|
return {
|
|
568
571
|
ok: false,
|
|
569
572
|
code: "error.source.reserved",
|
|
570
|
-
params: { root: key },
|
|
571
|
-
error:
|
|
572
|
-
? "DSH 技能目录是默认来源,必须读取:不能停用或移除(里面的技能可以删除)"
|
|
573
|
-
: "导入技能目录是默认来源、插件新建/导入的落点,必须读取:不能停用或移除(里面的技能可以删除)",
|
|
573
|
+
params: { root: key, origin },
|
|
574
|
+
error: `${origin}:不能停用或移除(里面的技能可以删除)`,
|
|
574
575
|
};
|
|
575
576
|
}
|
|
576
577
|
function rootDefinition(root) {
|
package/lib/skills/service.js
CHANGED
|
@@ -308,6 +308,33 @@ export function createSkillsService(ctx) {
|
|
|
308
308
|
const list = await projectRoots(projectOptions().projectCwds);
|
|
309
309
|
return list.find((root) => root.key === key);
|
|
310
310
|
};
|
|
311
|
+
/**
|
|
312
|
+
* **项目来源**(`project-dsh:<hash>` / `project-agents:<hash>`)能不能被启停,此前取决于
|
|
313
|
+
* 「它的工作区此刻在不在活动会话里」(`requestRoot` 的第三步)——于是场景快照恢复时工作区
|
|
314
|
+
* 早已关闭,解析落空 → `setSourceEnabled(undefined)` → 「该技能来源不允许启用或停用」→
|
|
315
|
+
* 退出模式直接失败、卡在场景里(用户 2026-09-17 报的)。
|
|
316
|
+
*
|
|
317
|
+
* key 本身就是自描述的(`<kind>:<路径哈希>`),而启停只写本地状态文件
|
|
318
|
+
* (`state.sources[key]`),一个字节都不碰源目录 —— 所以按前缀合成一个最小 definition
|
|
319
|
+
* 交给 core 即可:这个工作区下次被打开时,来源按记录的状态生效。
|
|
320
|
+
*/
|
|
321
|
+
const PROJECT_SOURCE_KEY_RE = /^project-(dsh|agents):[a-f0-9]{16}$/;
|
|
322
|
+
function projectKeyStub(key) {
|
|
323
|
+
if (!PROJECT_SOURCE_KEY_RE.test(key))
|
|
324
|
+
return undefined;
|
|
325
|
+
return {
|
|
326
|
+
key,
|
|
327
|
+
kind: key.slice(0, key.indexOf(':')),
|
|
328
|
+
scope: 'project',
|
|
329
|
+
toggleable: true,
|
|
330
|
+
mutable: false,
|
|
331
|
+
deletable: false,
|
|
332
|
+
label: key,
|
|
333
|
+
path: '',
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/** 启停来源专用的解析:静态 / 自定义 / 活动工作区都命中不了时,按 key 前缀兜底。 */
|
|
337
|
+
const requestSwitchRoot = async (key) => (await requestRoot(key)) || projectKeyStub(key);
|
|
311
338
|
const providerInvalidators = new Set();
|
|
312
339
|
const invalidateSkills = () => {
|
|
313
340
|
for (const invalidate of providerInvalidators) {
|
|
@@ -402,6 +429,8 @@ export function createSkillsService(ctx) {
|
|
|
402
429
|
'skill-source-remove', 'skill-source-restore',
|
|
403
430
|
'skill-prefer', 'skill-unprefer',
|
|
404
431
|
'skill-create', 'skill-import', 'skill-upload', 'skill-delete',
|
|
432
|
+
// 批量启停与单条启停同权:配了访问令牌的宿主必须同样要求带令牌。
|
|
433
|
+
'skill-set-all',
|
|
405
434
|
'skill-trash-restore', 'skill-trash-delete', 'skill-custom-add', 'skill-custom-remove',
|
|
406
435
|
]);
|
|
407
436
|
const ops = {
|
|
@@ -416,14 +445,39 @@ export function createSkillsService(ctx) {
|
|
|
416
445
|
// 启停(写 manager 状态,不改源文件)
|
|
417
446
|
'skill-enable': wrap((args) => write(async () => setSkillEnabled(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), true, log)), afterWrite),
|
|
418
447
|
'skill-disable': wrap((args) => write(async () => setSkillEnabled(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), false, log)), afterWrite),
|
|
419
|
-
'skill-source-enable': wrap((args) => write(async () => setSourceEnabled(await
|
|
420
|
-
'skill-source-disable': wrap((args) => write(async () => setSourceEnabled(await
|
|
448
|
+
'skill-source-enable': wrap((args) => write(async () => setSourceEnabled(await requestSwitchRoot(String(args.root || '')), true, log)), afterWrite),
|
|
449
|
+
'skill-source-disable': wrap((args) => write(async () => setSourceEnabled(await requestSwitchRoot(String(args.root || '')), false, log)), afterWrite),
|
|
421
450
|
// 移除 / 恢复来源(「不再读取这个文件夹」):只改本地状态,源目录一个字节都不动。
|
|
422
451
|
'skill-source-remove': wrap((args) => write(async () => setSourceRemoved(await requestRoot(String(args.root || '')), true, log)), afterWrite),
|
|
423
452
|
'skill-source-restore': wrap((args) => write(async () => setSourceRemoved(await requestRoot(String(args.root || '')), false, log)), afterWrite),
|
|
424
453
|
// 同名技能首选来源(手工指定哪个同名版本生效;只写本地策略)。
|
|
425
454
|
'skill-prefer': wrap((args) => write(async () => setPreferredSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), true, log)), afterWrite),
|
|
426
455
|
'skill-unprefer': wrap((args) => write(async () => setPreferredSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), false, log)), afterWrite),
|
|
456
|
+
/**
|
|
457
|
+
* 批量启停(技能页 / 记忆页那种「全选 / 取消全选」用)。
|
|
458
|
+
*
|
|
459
|
+
* 为什么要有它:前端逐条打单条 op 时,每次成功都会清状态缓存 + 重扫技能目录 +
|
|
460
|
+
* 广播 agent-preset 事件,几十上百条就是几十上百轮全量重扫。这里一次写锁走完、
|
|
461
|
+
* 只在最后失效一次。逐条仍复用单条写通道(不另写一份策略逻辑)。
|
|
462
|
+
*/
|
|
463
|
+
'skill-set-all': wrap((args) => write(async () => {
|
|
464
|
+
const items = Array.isArray(args && args.items) ? args.items : [];
|
|
465
|
+
const enabled = (args && args.enabled) === true;
|
|
466
|
+
const failed = [];
|
|
467
|
+
let changed = 0;
|
|
468
|
+
for (const item of items) {
|
|
469
|
+
const rootKey = String((item && item.root) || '');
|
|
470
|
+
const name = String((item && item.name) || '');
|
|
471
|
+
if (!rootKey || !name)
|
|
472
|
+
continue;
|
|
473
|
+
const res = await setSkillEnabled(await requestRoot(rootKey), name, enabled, log);
|
|
474
|
+
if (res && res.ok === false)
|
|
475
|
+
failed.push({ root: rootKey, name, error: String(res.error || '') });
|
|
476
|
+
else
|
|
477
|
+
changed++;
|
|
478
|
+
}
|
|
479
|
+
return { enabled, changed, failed };
|
|
480
|
+
}), afterWrite),
|
|
427
481
|
// 创建 / 导入 / 删除 / 回收站
|
|
428
482
|
// 创建默认落 hub(`tool-management/skills/`):UI 不传 root 时走这里;
|
|
429
483
|
// core.js 的 createSkill 默认值是第二道保险(同口径)。官方 `~/.dsh/skills/` 仍作为来源列出。
|