dsh-plugin-tool-management 0.5.1 → 0.7.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 +131 -0
- package/README.md +185 -238
- package/README_EN.md +185 -304
- package/cordis.patch.yml +9 -55
- package/docs/images/1/345/234/272/346/231/257.png +0 -0
- package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
- package/docs/images/2MCP.png +0 -0
- package/docs/images/2MCP_en.png +0 -0
- package/docs/images/3/346/212/200/350/203/275.png +0 -0
- package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
- package/docs/images/6/350/256/260/345/277/206.png +0 -0
- package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
- package/docs/images/7/344/274/232/350/257/235.png +0 -0
- package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
- package/docs/images/8/345/205/274/345/256/271.png +0 -0
- package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
- package/docs/update.md +55 -0
- package/lib/agents-md/preset-id.js +49 -0
- package/lib/agents-md/service.js +180 -57
- package/lib/client.js +4710 -3560
- package/lib/compat/preset-reach.js +425 -0
- package/lib/compat/probe.js +665 -0
- package/lib/history/bridge.js +293 -0
- package/lib/history/workspace.js +498 -52
- package/lib/http-fence.js +94 -0
- package/lib/hub.js +160 -2
- package/lib/index.js +759 -157
- package/lib/mcp/override-blocks.js +195 -0
- package/lib/rules/provider.js +3 -3
- package/lib/rules/service.js +342 -31
- package/lib/scene-prompt-sync.js +112 -0
- package/lib/skills/core.js +123 -35
- package/lib/skills/service.js +9 -1
- package/lib/subagents/service.js +197 -23
- package/lib/subagents/tools.js +8 -2
- package/package.json +6 -3
- package/screenshots.json +10 -9
- package/docs/Changelog.md +0 -517
- package/docs/images/MCP.png +0 -0
- package/docs/images//344/274/232/350/257/235.png +0 -0
- package/docs/images//345/234/272/346/231/257.png +0 -0
- package/docs/images//345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
- package/docs/images//346/212/200/350/203/275.png +0 -0
- package/docs/images//346/217/220/347/244/272/350/257/215.png +0 -0
- package/docs/images//350/256/260/345/277/206.png +0 -0
package/lib/rules/service.js
CHANGED
|
@@ -32,7 +32,9 @@ import { basename, dirname, join, resolve } from 'node:path';
|
|
|
32
32
|
import { parseSkillDoc, resolveDshHome, unquote } from '../skills/core.js';
|
|
33
33
|
import { expandUploads, planMemoryImport } from '../imports/upload.js';
|
|
34
34
|
import { createRuleProviderRegistrar } from './provider.js';
|
|
35
|
+
import { normalizePresetId } from '../agents-md/preset-id.js';
|
|
35
36
|
import { normalizeArchive, memoryAllowed } from './archive.js';
|
|
37
|
+
import { listTrashEntries, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
|
|
36
38
|
// ── 常量 ───────────────────────────────────────────────────────────────────
|
|
37
39
|
const MAX_SOURCE_DEPTH = 64; // 与 core.js 一致
|
|
38
40
|
const MAX_DIRECTORIES = 2000; // 目录预算
|
|
@@ -336,6 +338,11 @@ function parseSceneEntry(raw) {
|
|
|
336
338
|
out.label = obj.label;
|
|
337
339
|
if (typeof obj.description === 'string' && obj.description !== '')
|
|
338
340
|
out.description = obj.description;
|
|
341
|
+
if (typeof obj.prompt === 'string') {
|
|
342
|
+
const prompt = normalizeScenePromptId(obj.prompt);
|
|
343
|
+
if (prompt)
|
|
344
|
+
out.prompt = prompt;
|
|
345
|
+
}
|
|
339
346
|
if (typeof obj.order === 'number' && Number.isFinite(obj.order))
|
|
340
347
|
out.order = obj.order;
|
|
341
348
|
if (typeof obj.createdAt === 'string' && obj.createdAt !== '')
|
|
@@ -457,10 +464,20 @@ function sceneRecordOf(name, entry) {
|
|
|
457
464
|
name,
|
|
458
465
|
...(name === GLOBAL_SCENE ? { label: e.label || GLOBAL_SCENE_LABEL } : (e.label ? { label: e.label } : {})),
|
|
459
466
|
...(e.description ? { description: e.description } : {}),
|
|
467
|
+
...(e.prompt ? { prompt: e.prompt } : {}),
|
|
460
468
|
order: e.order ?? (name === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER),
|
|
461
469
|
...(e.createdAt ? { createdAt: e.createdAt } : {}),
|
|
462
470
|
};
|
|
463
471
|
}
|
|
472
|
+
/** 提示词预设 id 的口径与 agents-md 服务**同源**(用户裁定:id 什么都能写)。 */
|
|
473
|
+
/** 校验场景要绑定的提示词预设 id;返回 `''` 表示解绑,`null` 表示非法。 */
|
|
474
|
+
function normalizeScenePromptId(value) {
|
|
475
|
+
const id = String(value ?? '').trim();
|
|
476
|
+
if (id === '')
|
|
477
|
+
return '';
|
|
478
|
+
const result = normalizePresetId(id);
|
|
479
|
+
return result.ok ? result.id : null;
|
|
480
|
+
}
|
|
464
481
|
/** 路径是否存在(不跟随符号链接;用于迁移前置判断)。 */
|
|
465
482
|
async function pathExists(path) {
|
|
466
483
|
try {
|
|
@@ -561,12 +578,14 @@ function normalizeActive(raw) {
|
|
|
561
578
|
return out;
|
|
562
579
|
}
|
|
563
580
|
/**
|
|
564
|
-
*
|
|
565
|
-
* - `index.active`
|
|
566
|
-
* - `index.active`
|
|
581
|
+
* 活动场景解析(用户裁定 2026-09-15:**除「全局」外同时只能启用一个场景**):
|
|
582
|
+
* - `index.active` 为数组(新写入的唯一形态)→ 至多一个非保留场景启用;
|
|
583
|
+
* - `index.active` 缺失 / null → 历史默认("全部场景启用");**不再产生**新值,
|
|
584
|
+
* 新建场景时会被收敛成显式数组(见 `collapseActiveForNewScene`),
|
|
585
|
+
* 因此"新建即启用"不会发生;存量数据仍按老语义读,避免升级后注入范围突变。
|
|
567
586
|
* - `_shared` 恒常启用(公共基线),不受开关影响
|
|
568
587
|
* - 保留场景 `global` 恒常启用:它的记忆对任何对话都成立
|
|
569
|
-
* 缺失 rules-index.json
|
|
588
|
+
* 缺失 rules-index.json 一律按默认值运行,不抛错(§9.3)。
|
|
570
589
|
* 场景生效与否只由 index.active 决定(场景档案/模式也写这一份)。
|
|
571
590
|
*/
|
|
572
591
|
function resolveActiveScenes(index, knownScenes) {
|
|
@@ -577,6 +596,30 @@ function resolveActiveScenes(index, knownScenes) {
|
|
|
577
596
|
active.add(GLOBAL_SCENE);
|
|
578
597
|
return { active, mode };
|
|
579
598
|
}
|
|
599
|
+
/** 当前启用的**非保留**场景(单选模型下至多一个;多个时按场景顺序取第一个)。 */
|
|
600
|
+
function enabledSceneOf(index) {
|
|
601
|
+
const names = Object.keys(index.scenes || {});
|
|
602
|
+
const { active } = resolveActiveScenes(index, names);
|
|
603
|
+
const enabled = names
|
|
604
|
+
.filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
|
|
605
|
+
.sort((a, b) => sceneOrderOf(index, a) - sceneOrderOf(index, b) || a.localeCompare(b));
|
|
606
|
+
return enabled[0] ?? null;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* 新建场景前把历史默认(`active = null` = 全部启用)收敛成显式数组,保证
|
|
610
|
+
* **新场景默认不启动**、且收敛后仍满足"至多一个非保留场景启用":
|
|
611
|
+
* - 已存在其它非保留场景 → 取顺序第一个作为启用场景(其余收敛掉,返回 `collapsed: true`)
|
|
612
|
+
* - 不存在 → 空数组(什么都不启用)
|
|
613
|
+
* @returns 是否发生了收敛(供 UI 如实提示)。
|
|
614
|
+
*/
|
|
615
|
+
function collapseActiveForNewScene(index) {
|
|
616
|
+
if (normalizeActive(index.active) !== null)
|
|
617
|
+
return false;
|
|
618
|
+
const names = Object.keys(index.scenes || {}).filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE);
|
|
619
|
+
const first = names.sort((a, b) => sceneOrderOf(index, a) - sceneOrderOf(index, b) || a.localeCompare(b))[0];
|
|
620
|
+
index.active = first === undefined ? [] : [first];
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
580
623
|
/** 场景名 = 分组路径的第一段(`web/frontend` 属于场景 `web`)。 */
|
|
581
624
|
function sceneOf(group) {
|
|
582
625
|
const idx = String(group || '').indexOf('/');
|
|
@@ -1054,6 +1097,122 @@ export function createRulesService(ctx, deps) {
|
|
|
1054
1097
|
sceneCache = { signature: probe.signature, value };
|
|
1055
1098
|
return value;
|
|
1056
1099
|
}
|
|
1100
|
+
// ── 场景提示词段(绑定预设正文;只读,绝不写 ~/.dsh/AGENTS.md)──────────────
|
|
1101
|
+
//
|
|
1102
|
+
// 语义(用户裁定 2026-09-15):
|
|
1103
|
+
// - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `agents-md/<id>/AGENTS.md`);
|
|
1104
|
+
// - 除保留场景 `global` 外**同时只能启用一个场景**,所以同一时刻至多一份提示词在场;
|
|
1105
|
+
// - 没有启用其它场景时才轮到 `global` 自己的绑定(它恒常生效,覆盖"永远在场的提示词");
|
|
1106
|
+
// - 预设文件不存在 / 未绑定 → 返回 `''`(renderPrompt 会删掉空段,不产生空标题)。
|
|
1107
|
+
//
|
|
1108
|
+
// 为什么不用 fs.watch 也不用每请求读盘:段契约要求**同步返回**且逐字节稳定,
|
|
1109
|
+
// 因此按 `mtimeMs + size` 做一次 stat 缓存 —— 命中时零读盘,外部编辑器改了预设
|
|
1110
|
+
// 也会在下一个请求自动刷新(与记忆段的"两相扫描"同一哲学)。
|
|
1111
|
+
const promptFileCache = new Map();
|
|
1112
|
+
/** 读一个预设正文(同步、带 stat 指纹缓存);不存在返回 `''`。 */
|
|
1113
|
+
function readPresetTextSync(id) {
|
|
1114
|
+
const file = join(stateDir, 'agents-md', id, 'AGENTS.md');
|
|
1115
|
+
let key;
|
|
1116
|
+
try {
|
|
1117
|
+
const st = statSync(file);
|
|
1118
|
+
if (!st.isFile())
|
|
1119
|
+
return '';
|
|
1120
|
+
key = `${st.mtimeMs}:${st.size}`;
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
promptFileCache.delete(id);
|
|
1124
|
+
return '';
|
|
1125
|
+
}
|
|
1126
|
+
const cached = promptFileCache.get(id);
|
|
1127
|
+
if (cached && cached.key === key)
|
|
1128
|
+
return cached.text;
|
|
1129
|
+
let text = '';
|
|
1130
|
+
try {
|
|
1131
|
+
text = readFileSync(file, 'utf8');
|
|
1132
|
+
}
|
|
1133
|
+
catch {
|
|
1134
|
+
return '';
|
|
1135
|
+
}
|
|
1136
|
+
promptFileCache.set(id, { key, text });
|
|
1137
|
+
return text;
|
|
1138
|
+
}
|
|
1139
|
+
/** 预设文件是否存在(同步;绑定前校验用,避免悬空绑定)。 */
|
|
1140
|
+
function presetExistsSync(id) {
|
|
1141
|
+
try {
|
|
1142
|
+
return statSync(join(stateDir, 'agents-md', id, 'AGENTS.md')).isFile();
|
|
1143
|
+
}
|
|
1144
|
+
catch {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* 读当前 `~/.dsh/AGENTS.md`(同步、带 stat 指纹缓存)。
|
|
1150
|
+
* 用途只有一个:**去重**——绑定的预设与文件内容逐字节相同时不再注入第二遍正文。
|
|
1151
|
+
*/
|
|
1152
|
+
let globalAgentsCache = null;
|
|
1153
|
+
function readGlobalAgentsMdSync() {
|
|
1154
|
+
const file = join(resolveDshHome(), 'AGENTS.md');
|
|
1155
|
+
let key;
|
|
1156
|
+
try {
|
|
1157
|
+
const st = statSync(file);
|
|
1158
|
+
if (!st.isFile())
|
|
1159
|
+
return '';
|
|
1160
|
+
key = `${st.mtimeMs}:${st.size}`;
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
globalAgentsCache = null;
|
|
1164
|
+
return '';
|
|
1165
|
+
}
|
|
1166
|
+
if (globalAgentsCache && globalAgentsCache.key === key)
|
|
1167
|
+
return globalAgentsCache.text;
|
|
1168
|
+
let text = '';
|
|
1169
|
+
try {
|
|
1170
|
+
text = readFileSync(file, 'utf8');
|
|
1171
|
+
}
|
|
1172
|
+
catch {
|
|
1173
|
+
return '';
|
|
1174
|
+
}
|
|
1175
|
+
globalAgentsCache = { key, text };
|
|
1176
|
+
return text;
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有则 `''`。
|
|
1180
|
+
* 绑定内容与 `~/.dsh/AGENTS.md` 相同时标记 `duplicate` 且返回空段 —— 那份正文已经在
|
|
1181
|
+
* 基线里了,再注入一遍只会把同样的内容塞进上下文两次。
|
|
1182
|
+
*/
|
|
1183
|
+
function scenePrompt() {
|
|
1184
|
+
const index = readIndexSync(stateDir);
|
|
1185
|
+
const names = Object.keys(index.scenes || {});
|
|
1186
|
+
const { active } = resolveActiveScenes(index, names);
|
|
1187
|
+
const globalText = readGlobalAgentsMdSync().trim();
|
|
1188
|
+
/** 绑定 → 投影;与文件一致时是 duplicate(不注入),否则原样注入。 */
|
|
1189
|
+
const project = (scene, id) => {
|
|
1190
|
+
const text = readPresetTextSync(id);
|
|
1191
|
+
if (text.trim() === '')
|
|
1192
|
+
return { scene, presetId: id, text: '', missing: true };
|
|
1193
|
+
if (globalText !== '' && text.trim() === globalText)
|
|
1194
|
+
return { scene, presetId: id, text: '', missing: false, duplicate: true };
|
|
1195
|
+
return { scene, presetId: id, text, missing: false };
|
|
1196
|
+
};
|
|
1197
|
+
// 单选:取唯一一个非保留启用场景(resolveActiveScenes 已保证 ≤1,这里仍做确定性排序兜底)。
|
|
1198
|
+
const enabled = names
|
|
1199
|
+
.filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
|
|
1200
|
+
.sort((a, b) => sceneOrderOf(index, a) - sceneOrderOf(index, b) || a.localeCompare(b));
|
|
1201
|
+
for (const scene of enabled) {
|
|
1202
|
+
const id = index.scenes?.[scene]?.prompt;
|
|
1203
|
+
if (!id)
|
|
1204
|
+
continue;
|
|
1205
|
+
const projected = project(scene, id);
|
|
1206
|
+
// 绑了但预设为空/已删 → 继续找下一个(不注入空段)。
|
|
1207
|
+
if (projected.missing)
|
|
1208
|
+
continue;
|
|
1209
|
+
return projected;
|
|
1210
|
+
}
|
|
1211
|
+
const globalId = index.scenes?.[GLOBAL_SCENE]?.prompt;
|
|
1212
|
+
if (globalId)
|
|
1213
|
+
return project(GLOBAL_SCENE, globalId);
|
|
1214
|
+
return { scene: null, presetId: null, text: '', missing: false };
|
|
1215
|
+
}
|
|
1057
1216
|
const providerInvalidators = new Set();
|
|
1058
1217
|
/** 通知已注册的 provider 提示词已变化(best-effort)。 */
|
|
1059
1218
|
const invalidateProviders = () => {
|
|
@@ -1205,6 +1364,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1205
1364
|
}
|
|
1206
1365
|
const known = [...names];
|
|
1207
1366
|
const { active } = resolveActiveScenes(index, known);
|
|
1367
|
+
const enabledScene = enabledSceneOf(index);
|
|
1208
1368
|
return known
|
|
1209
1369
|
.map((name) => ({
|
|
1210
1370
|
name,
|
|
@@ -1215,6 +1375,9 @@ export function createRulesService(ctx, deps) {
|
|
|
1215
1375
|
shared: name === SHARED_GROUP,
|
|
1216
1376
|
global: name === GLOBAL_SCENE,
|
|
1217
1377
|
description: index.scenes?.[name]?.description || '',
|
|
1378
|
+
prompt: index.scenes?.[name]?.prompt || '',
|
|
1379
|
+
// 单选模型下"能不能点开":已被别的场景占用时,这个开关要置灰。
|
|
1380
|
+
selectable: name !== SHARED_GROUP && name !== GLOBAL_SCENE && (enabledScene === null || enabledScene === name),
|
|
1218
1381
|
}))
|
|
1219
1382
|
.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
|
|
1220
1383
|
}
|
|
@@ -1229,6 +1392,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1229
1392
|
const groups = groupFilter ? snap.groups.filter((g) => g.name === groupFilter) : snap.groups;
|
|
1230
1393
|
const scenes = sceneRows(snap, index);
|
|
1231
1394
|
const projection = sceneMemory();
|
|
1395
|
+
const promptProjection = scenePrompt();
|
|
1232
1396
|
return {
|
|
1233
1397
|
ok: true,
|
|
1234
1398
|
rules,
|
|
@@ -1237,7 +1401,17 @@ export function createRulesService(ctx, deps) {
|
|
|
1237
1401
|
// 场景 = 显式记录(含保留场景 global 与尚无记忆的空场景);active = 是否参与注入。
|
|
1238
1402
|
scenes,
|
|
1239
1403
|
activeMode: normalizeActive(index.active) === null ? 'all' : 'custom',
|
|
1404
|
+
// 单选模型:当前启用的那个场景(null = 只留 `_shared` 与 `global`)。
|
|
1405
|
+
activeScene: enabledSceneOf(index),
|
|
1240
1406
|
sceneMemory: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, truncated: projection.truncated, dropped: projection.dropped },
|
|
1407
|
+
// 当前生效的场景提示词(绑定的预设正文;与 ~/.dsh/AGENTS.md 一致时不重复注入)。
|
|
1408
|
+
scenePrompt: {
|
|
1409
|
+
scene: promptProjection.scene,
|
|
1410
|
+
presetId: promptProjection.presetId,
|
|
1411
|
+
missing: promptProjection.missing,
|
|
1412
|
+
duplicate: promptProjection.duplicate === true,
|
|
1413
|
+
bytes: Buffer.byteLength(promptProjection.text, 'utf8'),
|
|
1414
|
+
},
|
|
1241
1415
|
// 路径供 UI 显示「文件在哪」;不再让界面硬编码 ~/.dsh/scene-memory。
|
|
1242
1416
|
paths: { memories: rulesRoot, scenes: scenesRoot, hub: stateDir },
|
|
1243
1417
|
stats: {
|
|
@@ -1874,9 +2048,16 @@ export function createRulesService(ctx, deps) {
|
|
|
1874
2048
|
const parts = parseId(id);
|
|
1875
2049
|
if (!parts)
|
|
1876
2050
|
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
2051
|
+
// `enabled` 必须显式给。缺省时旧实现回落到"沿用当前值",却照样刷新
|
|
2052
|
+
// updatedAt 并返回 ok:true —— 调用方按 toggle(翻转)理解时会以为自己改了
|
|
2053
|
+
// 状态,实际什么都没改(界面不受影响:它一直显式传值)。与 rulesSetActive
|
|
2054
|
+
// 同一条口径:参数缺失就明确拒绝,不猜。
|
|
2055
|
+
if (typeof (args && args.enabled) !== 'boolean') {
|
|
2056
|
+
return fail('error.rules.invalidArgs', '缺少参数:enabled 必须是布尔值(toggle 只按传入值写入,不做"翻转"推断)');
|
|
2057
|
+
}
|
|
1877
2058
|
const index = await readIndex(stateDir);
|
|
1878
2059
|
const idxEntry = index.rules[id] || {};
|
|
1879
|
-
const enabled = args
|
|
2060
|
+
const enabled = args.enabled === true;
|
|
1880
2061
|
index.rules[id] = { ...idxEntry, enabled, updatedAt: new Date().toISOString() };
|
|
1881
2062
|
await writeIndex(stateDir, index);
|
|
1882
2063
|
invalidateSnapshot();
|
|
@@ -1884,35 +2065,45 @@ export function createRulesService(ctx, deps) {
|
|
|
1884
2065
|
return { ok: true, rule: await buildProjected(id) };
|
|
1885
2066
|
}
|
|
1886
2067
|
/**
|
|
1887
|
-
* 设置"启用场景"
|
|
1888
|
-
* - `args.
|
|
1889
|
-
*
|
|
2068
|
+
* 设置"启用场景"(全局持久化,切换后**下一个请求即生效**,§4 对照表)。
|
|
2069
|
+
* - `args.scenes` 数组 → 启用集合;**至多一个非保留场景**(用户裁定:除「全局」外
|
|
2070
|
+
* 同时只能启用一个),`[]` = 全部关闭(只留恒常的 `_shared` 与 `global`)
|
|
2071
|
+
* - `args.all === true` → 旧的"全部启用"形态,与新模型冲突,明确拒绝(不猜)
|
|
1890
2072
|
* 场景名按放宽后的规则校验;不存在的场景名也允许保存(目录随后创建即可生效)。
|
|
1891
2073
|
*/
|
|
1892
2074
|
async function rulesSetActive(args) {
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
2075
|
+
// 参数必须显式二选一。旧实现把「没传参数」「传了未知参数名」都落进 else 分支、
|
|
2076
|
+
// 用空数组覆盖 active,于是 `rules-set-active {}` 会静默把模式切成 custom 且零激活场景
|
|
2077
|
+
// (用户以为自己只是"没改动",实际已经改了注入范围)。这里显式拒绝。
|
|
2078
|
+
const hasAll = !!(args && args.all === true);
|
|
2079
|
+
const hasScenes = Array.isArray(args && args.scenes);
|
|
2080
|
+
if (!hasAll && !hasScenes) {
|
|
2081
|
+
return fail('error.rules.invalidArgs', '缺少参数:需要 scenes:[...](启用集合,至多一个场景)');
|
|
1896
2082
|
}
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
seen.add(name);
|
|
1912
|
-
names.push(name);
|
|
2083
|
+
if (hasAll) {
|
|
2084
|
+
return fail('error.rules.singleSceneOnly', '除「全局」外同时只能启用一个场景:不支持"全部启用",请改用 scenes:[<场景名>] 或 scenes:[](全部关闭)');
|
|
2085
|
+
}
|
|
2086
|
+
const index = await readIndex(stateDir);
|
|
2087
|
+
const raw = Array.isArray(args && args.scenes) ? args.scenes : [];
|
|
2088
|
+
const names = [];
|
|
2089
|
+
const seen = new Set();
|
|
2090
|
+
for (const item of raw) {
|
|
2091
|
+
const name = String(item == null ? '' : item).trim();
|
|
2092
|
+
// 恒常启用的保留场景不入显式集合:`_shared`(公共基线)与 `global`(「全局」)。
|
|
2093
|
+
if (name === '' || name === SHARED_GROUP || name === GLOBAL_SCENE)
|
|
2094
|
+
continue;
|
|
2095
|
+
if (!isValidGroupPath(name)) {
|
|
2096
|
+
return fail('error.rules.invalidGroup', `场景名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
|
|
1913
2097
|
}
|
|
1914
|
-
|
|
2098
|
+
if (seen.has(name))
|
|
2099
|
+
continue;
|
|
2100
|
+
seen.add(name);
|
|
2101
|
+
names.push(name);
|
|
1915
2102
|
}
|
|
2103
|
+
if (names.length > 1) {
|
|
2104
|
+
return fail('error.rules.singleSceneOnly', `除「全局」外同时只能启用一个场景(收到 ${names.length} 个:${names.join('、')})`);
|
|
2105
|
+
}
|
|
2106
|
+
index.active = names;
|
|
1916
2107
|
await writeIndex(stateDir, index);
|
|
1917
2108
|
invalidateSnapshot();
|
|
1918
2109
|
invalidateProviders();
|
|
@@ -1942,6 +2133,10 @@ export function createRulesService(ctx, deps) {
|
|
|
1942
2133
|
if (description.length > MAX_DESCRIPTION_LENGTH) {
|
|
1943
2134
|
return fail('error.rules.descriptionTooLong', `场景描述过长(≤${MAX_DESCRIPTION_LENGTH} 字符)`);
|
|
1944
2135
|
}
|
|
2136
|
+
const prompt = args && args.prompt !== undefined ? normalizeScenePromptId(args.prompt) : undefined;
|
|
2137
|
+
if (prompt === null) {
|
|
2138
|
+
return fail('error.rules.invalidGroup', `提示词预设 id 非法:${String(args.prompt)}(仅小写字母/数字/连字符,且不能是备份槽)`);
|
|
2139
|
+
}
|
|
1945
2140
|
await ensureLayout();
|
|
1946
2141
|
try {
|
|
1947
2142
|
await mkdir(join(rulesRoot, name), { recursive: true });
|
|
@@ -1950,6 +2145,9 @@ export function createRulesService(ctx, deps) {
|
|
|
1950
2145
|
return fail('error.rules.ioFailed', `创建场景目录失败:${message(e)}`);
|
|
1951
2146
|
}
|
|
1952
2147
|
const index = await readIndex(stateDir);
|
|
2148
|
+
// **新场景默认不启动**:先把历史默认(active=null=全部启用)收敛成显式集合,
|
|
2149
|
+
// 新建的这个自然不在其中;收敛后仍是"至多一个场景启用"。
|
|
2150
|
+
const collapsed = collapseActiveForNewScene(index);
|
|
1953
2151
|
if (!index.scenes)
|
|
1954
2152
|
index.scenes = {};
|
|
1955
2153
|
const prev = index.scenes[name] || {};
|
|
@@ -1957,6 +2155,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1957
2155
|
...prev,
|
|
1958
2156
|
...(label !== '' && name !== GLOBAL_SCENE ? { label } : {}),
|
|
1959
2157
|
...(description !== '' ? { description } : {}),
|
|
2158
|
+
...(prompt !== undefined && prompt !== '' ? { prompt } : {}),
|
|
1960
2159
|
order: prev.order ?? DEFAULT_GROUP_ORDER,
|
|
1961
2160
|
createdAt: prev.createdAt ?? new Date().toISOString(),
|
|
1962
2161
|
};
|
|
@@ -1966,10 +2165,10 @@ export function createRulesService(ctx, deps) {
|
|
|
1966
2165
|
await writeIndex(stateDir, index);
|
|
1967
2166
|
invalidateSnapshot();
|
|
1968
2167
|
invalidateProviders();
|
|
1969
|
-
return { ok: true, scene: sceneRecordOf(name, next) };
|
|
2168
|
+
return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
|
|
1970
2169
|
}
|
|
1971
2170
|
/**
|
|
1972
|
-
* 更新场景记录(描述 / 显示名 /
|
|
2171
|
+
* 更新场景记录(描述 / 显示名 / 顺序 / 绑定的提示词预设)。记忆文件不动。
|
|
1973
2172
|
* 这是「场景有描述」的写入口——旧版场景只有目录名,没有可编辑元数据。
|
|
1974
2173
|
*/
|
|
1975
2174
|
async function rulesUpdateScene(args) {
|
|
@@ -1993,6 +2192,20 @@ export function createRulesService(ctx, deps) {
|
|
|
1993
2192
|
else
|
|
1994
2193
|
next.description = description;
|
|
1995
2194
|
}
|
|
2195
|
+
if (args && args.prompt !== undefined) {
|
|
2196
|
+
const prompt = normalizeScenePromptId(args.prompt);
|
|
2197
|
+
if (prompt === null) {
|
|
2198
|
+
return fail('error.rules.invalidGroup', `提示词预设 id 非法:${String(args.prompt)}(仅小写字母/数字/连字符,且不能是备份槽)`);
|
|
2199
|
+
}
|
|
2200
|
+
// 绑定前校验预设确实存在:宁可现在拒绝,也不要留一个永远注入不出东西的悬空绑定。
|
|
2201
|
+
if (prompt !== '' && !presetExistsSync(prompt)) {
|
|
2202
|
+
return fail('error.rules.notFound', `提示词预设不存在:${prompt}`);
|
|
2203
|
+
}
|
|
2204
|
+
if (prompt === '')
|
|
2205
|
+
delete next.prompt;
|
|
2206
|
+
else
|
|
2207
|
+
next.prompt = prompt;
|
|
2208
|
+
}
|
|
1996
2209
|
if (args && args.label !== undefined && name !== GLOBAL_SCENE) {
|
|
1997
2210
|
const label = String(args.label).trim();
|
|
1998
2211
|
if (label === '')
|
|
@@ -2047,6 +2260,15 @@ export function createRulesService(ctx, deps) {
|
|
|
2047
2260
|
if (index.mode?.scene === name) {
|
|
2048
2261
|
return fail('error.rules.sceneInMode', `场景「${name}」正处在当前模式,请先退出模式再删除`);
|
|
2049
2262
|
}
|
|
2263
|
+
// 场景本体(记录 + 档案)**先移入回收站**,再从索引里摘掉——这样「删除场景」
|
|
2264
|
+
// 是可恢复的。记忆目录必须是空的(上面已拦),所以没有正文需要搬。
|
|
2265
|
+
const trashed = await moveToTrash('scenes', name, [], {
|
|
2266
|
+
record: (index.scenes && index.scenes[name]) || null,
|
|
2267
|
+
archive: (index.archives && index.archives[name]) || null,
|
|
2268
|
+
});
|
|
2269
|
+
if (trashed.ok === false) {
|
|
2270
|
+
return fail('error.rules.ioFailed', `移入回收站失败:${trashed.error}`);
|
|
2271
|
+
}
|
|
2050
2272
|
try {
|
|
2051
2273
|
// fs.rm 删目录必须 recursive(即使已确认它是空的),否则报 EISDIR。
|
|
2052
2274
|
await rm(join(rulesRoot, name), { recursive: true, force: true });
|
|
@@ -2072,8 +2294,91 @@ export function createRulesService(ctx, deps) {
|
|
|
2072
2294
|
await writeIndex(stateDir, index);
|
|
2073
2295
|
invalidateSnapshot();
|
|
2074
2296
|
invalidateProviders();
|
|
2297
|
+
return { ok: true, name, trashId: trashed.id };
|
|
2298
|
+
}
|
|
2299
|
+
/** 场景回收站列表(只读):`<hub>/trash/scenes-trash/<id>/manifest.json`。 */
|
|
2300
|
+
async function sceneTrashList() {
|
|
2301
|
+
return { ok: true, trash: await listTrashEntries('scenes') };
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* 从回收站恢复一个场景:重建记忆目录 + 写回记录与档案。
|
|
2305
|
+
* 同名场景已存在时**拒绝**(绝不覆盖既有场景)。
|
|
2306
|
+
*/
|
|
2307
|
+
async function sceneTrashRestore(args) {
|
|
2308
|
+
const id = String((args && args.id) || '').trim();
|
|
2309
|
+
const entry = await readTrashEntry('scenes', id);
|
|
2310
|
+
if (!entry)
|
|
2311
|
+
return fail('error.rules.notFound', `回收站条目不存在:${id}`);
|
|
2312
|
+
const name = String(entry.name || '').trim();
|
|
2313
|
+
if (!isValidGroupSegment(name))
|
|
2314
|
+
return fail('error.rules.invalidGroup', `回收站里的场景名非法:${name || '(空)'}`);
|
|
2315
|
+
const index = await readIndex(stateDir);
|
|
2316
|
+
if (index.scenes?.[name])
|
|
2317
|
+
return fail('error.rules.invalidGroup', `无法恢复,同名场景已存在:${name}`);
|
|
2318
|
+
const dir = join(rulesRoot, name);
|
|
2319
|
+
let entries = [];
|
|
2320
|
+
try {
|
|
2321
|
+
entries = await readdir(dir);
|
|
2322
|
+
}
|
|
2323
|
+
catch {
|
|
2324
|
+
entries = [];
|
|
2325
|
+
}
|
|
2326
|
+
if (entries.filter((n) => !n.startsWith('.')).length > 0) {
|
|
2327
|
+
return fail('error.rules.sceneNotEmpty', `无法恢复,记忆目录「${name}」里已有内容,请先处理`);
|
|
2328
|
+
}
|
|
2329
|
+
const data = (entry.data || {});
|
|
2330
|
+
try {
|
|
2331
|
+
await mkdir(dir, { recursive: true });
|
|
2332
|
+
}
|
|
2333
|
+
catch (e) {
|
|
2334
|
+
return fail('error.rules.ioFailed', `重建场景目录失败:${message(e)}`);
|
|
2335
|
+
}
|
|
2336
|
+
if (data.record && typeof data.record === 'object')
|
|
2337
|
+
index.scenes = { ...(index.scenes || {}), [name]: data.record };
|
|
2338
|
+
if (data.archive && typeof data.archive === 'object')
|
|
2339
|
+
index.archives = { ...(index.archives || {}), [name]: data.archive };
|
|
2340
|
+
await writeIndex(stateDir, index);
|
|
2341
|
+
await purgeTrashEntry('scenes', id);
|
|
2342
|
+
invalidateSnapshot();
|
|
2343
|
+
invalidateProviders();
|
|
2075
2344
|
return { ok: true, name };
|
|
2076
2345
|
}
|
|
2346
|
+
/** 永久删除一条场景回收站条目。 */
|
|
2347
|
+
async function sceneTrashDelete(args) {
|
|
2348
|
+
const id = String((args && args.id) || '').trim();
|
|
2349
|
+
const gone = await purgeTrashEntry('scenes', id);
|
|
2350
|
+
if (!gone)
|
|
2351
|
+
return fail('error.rules.notFound', `回收站条目不存在:${id}`);
|
|
2352
|
+
return { ok: true, id };
|
|
2353
|
+
}
|
|
2354
|
+
/**
|
|
2355
|
+
* 提示词预设改名后**同步场景绑定**:把所有 `scenes[].prompt === from` 改成 `to`。
|
|
2356
|
+
* 由 AGENTS.md 预设库的改名路径调用——绑定存在 `rules-index.json` 里,预设库
|
|
2357
|
+
* 自己看不到它,不叫这一声改名就会留下悬空绑定(场景卡片显示「预设不存在」)。
|
|
2358
|
+
* @returns 改了几个场景。
|
|
2359
|
+
*/
|
|
2360
|
+
async function rulesRebindPrompt(args) {
|
|
2361
|
+
const from = String((args && args.from) || '').trim();
|
|
2362
|
+
const to = String((args && args.to) || '').trim();
|
|
2363
|
+
if (!from || !to)
|
|
2364
|
+
return fail('error.rules.invalidArgs', '需要 from 与 to 两个预设 id');
|
|
2365
|
+
const index = await readIndex(stateDir);
|
|
2366
|
+
const scenes = index.scenes || {};
|
|
2367
|
+
let changed = 0;
|
|
2368
|
+
for (const [name, entry] of Object.entries(scenes)) {
|
|
2369
|
+
if (entry && entry.prompt === from) {
|
|
2370
|
+
scenes[name] = { ...entry, prompt: to };
|
|
2371
|
+
changed++;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
if (changed) {
|
|
2375
|
+
index.scenes = scenes;
|
|
2376
|
+
await writeIndex(stateDir, index);
|
|
2377
|
+
invalidateSnapshot();
|
|
2378
|
+
invalidateProviders();
|
|
2379
|
+
}
|
|
2380
|
+
return { ok: true, from, to, changed };
|
|
2381
|
+
}
|
|
2077
2382
|
async function rulesSetIndex(args) {
|
|
2078
2383
|
const id = String((args && args.id) || '');
|
|
2079
2384
|
const parts = parseId(id);
|
|
@@ -2130,8 +2435,10 @@ export function createRulesService(ctx, deps) {
|
|
|
2130
2435
|
// index.ts 从本集合派生,勿在宿主端另抄一份。
|
|
2131
2436
|
const writeOps = new Set([
|
|
2132
2437
|
'rules-create', 'rules-update', 'rules-remove', 'rules-restore', 'rules-toggle', 'rules-import',
|
|
2133
|
-
'rules-set-index', 'rules-set-active', 'rules-create-scene', 'rules-update-scene', 'rules-remove-scene',
|
|
2438
|
+
'rules-set-index', 'rules-set-active', 'rules-create-scene', 'rules-update-scene', 'rules-remove-scene', 'rules-rebind-prompt',
|
|
2134
2439
|
'rules-attach', 'rules-detach', 'rules-trash-remove',
|
|
2440
|
+
// 场景回收站(与记忆回收站 rules-trash-* 分开:那套管记忆正文,这套管场景记录与档案)
|
|
2441
|
+
'scene-trash-restore', 'scene-trash-delete',
|
|
2135
2442
|
]);
|
|
2136
2443
|
const ops = {
|
|
2137
2444
|
'rules-list': (args) => rulesList(args || {}),
|
|
@@ -2153,6 +2460,10 @@ export function createRulesService(ctx, deps) {
|
|
|
2153
2460
|
'rules-create-scene': (args) => runWrite(() => rulesCreateScene(args || {})),
|
|
2154
2461
|
'rules-update-scene': (args) => runWrite(() => rulesUpdateScene(args || {})),
|
|
2155
2462
|
'rules-remove-scene': (args) => runWrite(() => rulesRemoveScene(args || {})),
|
|
2463
|
+
'scene-trash-list': () => sceneTrashList(),
|
|
2464
|
+
'scene-trash-restore': (args) => runWrite(() => sceneTrashRestore(args || {})),
|
|
2465
|
+
'scene-trash-delete': (args) => runWrite(() => sceneTrashDelete(args || {})),
|
|
2466
|
+
'rules-rebind-prompt': (args) => runWrite(() => rulesRebindPrompt(args || {})),
|
|
2156
2467
|
};
|
|
2157
2468
|
const service = {
|
|
2158
2469
|
ops,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// dsh-plugin-tool-management —— 场景绑定提示词 ⇄ 全局基线(`~/.dsh/AGENTS.md`)同步。
|
|
2
|
+
//
|
|
3
|
+
// 用户裁定(2026-09-15):「切换场景,对应的提示词直接把 AGENTS.md 直接修改」。
|
|
4
|
+
// 因此场景绑定的提示词不再作为 systemPrompt 段注入,而是**真写全局基线文件**,
|
|
5
|
+
// 与「提示词」页的「应用」走同一条路(覆盖前多代备份到 `agents-md/__last-applied__/`)。
|
|
6
|
+
//
|
|
7
|
+
// 四个动作会触发同步(调用方在 op 层包一层 `withSync`):
|
|
8
|
+
// ① 启用/切换场景 ② 关掉场景(恢复进场景前的基线)
|
|
9
|
+
// ③ 改场景绑定的预设 ④ 编辑"正在驱动基线的那份预设"的正文
|
|
10
|
+
//
|
|
11
|
+
// 「进场景前的基线」快照存在 hub 的 `scene-baseline.json`:进场景时记下当时的
|
|
12
|
+
// AGENTS.md 正文(以及当时匹配到的预设 id),关掉场景时**按原文**写回 ——
|
|
13
|
+
// 那份内容可能是用户手写的,不一定对应任何预设。恢复后把快照清空,避免之后再关一次
|
|
14
|
+
// 场景时复活旧基线。
|
|
15
|
+
//
|
|
16
|
+
// 幂等与如实:文件已经是那份(`duplicate`)时不写盘;没有场景驱动、也没有快照时什么都不做;
|
|
17
|
+
// 写失败**不改**原操作的成功结论,而是把原因放进 `error` 交给界面显示成警告。
|
|
18
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { dirname } from 'node:path';
|
|
20
|
+
export function createScenePromptSync(deps) {
|
|
21
|
+
const warn = (message) => { try {
|
|
22
|
+
deps.logger?.warn?.(message);
|
|
23
|
+
}
|
|
24
|
+
catch { /* ignore */ } };
|
|
25
|
+
async function readBaseline() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await readFile(deps.baselineFile, 'utf8');
|
|
28
|
+
const obj = JSON.parse(raw);
|
|
29
|
+
if (!obj || typeof obj !== 'object')
|
|
30
|
+
return null;
|
|
31
|
+
return {
|
|
32
|
+
v: 1,
|
|
33
|
+
at: Number.isFinite(obj.at) ? Number(obj.at) : 0,
|
|
34
|
+
presetId: typeof obj.presetId === 'string' ? obj.presetId : null,
|
|
35
|
+
content: typeof obj.content === 'string' ? obj.content : null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function writeBaseline(next) {
|
|
43
|
+
try {
|
|
44
|
+
await mkdir(dirname(deps.baselineFile), { recursive: true });
|
|
45
|
+
await writeFile(deps.baselineFile, JSON.stringify(next), 'utf8');
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
warn(`scene-prompt: could not persist baseline: ${String(e)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function state() {
|
|
52
|
+
try {
|
|
53
|
+
const r = await deps.rules.ops['rules-list']({});
|
|
54
|
+
const sp = r && r.ok ? r.scenePrompt : null;
|
|
55
|
+
if (!sp)
|
|
56
|
+
return null;
|
|
57
|
+
return {
|
|
58
|
+
scene: sp.scene ? String(sp.scene) : null,
|
|
59
|
+
presetId: sp.presetId ? String(sp.presetId) : null,
|
|
60
|
+
missing: sp.missing === true,
|
|
61
|
+
duplicate: sp.duplicate === true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
warn(`scene-prompt: probe failed: ${String(e)}`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function sync() {
|
|
70
|
+
const current = await state();
|
|
71
|
+
if (!current)
|
|
72
|
+
return { unchanged: true };
|
|
73
|
+
const driving = current.scene !== null && !current.missing && current.presetId !== null;
|
|
74
|
+
if (driving) {
|
|
75
|
+
if (current.duplicate)
|
|
76
|
+
return { applied: current.presetId, unchanged: true };
|
|
77
|
+
// 进场景的第一笔写入之前,先记下当时的基线(已有快照就不覆盖:连续切场景时
|
|
78
|
+
// 基线始终是"进场景之前"那一份)。
|
|
79
|
+
const baseline = await readBaseline();
|
|
80
|
+
if (!baseline || baseline.content === null) {
|
|
81
|
+
const cur = await deps.agentsMd.getCurrent();
|
|
82
|
+
await writeBaseline({
|
|
83
|
+
v: 1,
|
|
84
|
+
at: Date.now(),
|
|
85
|
+
presetId: cur && cur.ok && cur.presetId ? String(cur.presetId) : null,
|
|
86
|
+
content: cur && cur.ok && cur.exists ? String(cur.content) : null,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const res = await deps.agentsMd.apply(current.presetId);
|
|
90
|
+
if (res && res.ok === false)
|
|
91
|
+
return { error: String(res.error || '写入 AGENTS.md 失败') };
|
|
92
|
+
return { applied: current.presetId };
|
|
93
|
+
}
|
|
94
|
+
// 没有场景驱动 → 如果刚从驱动态退出,把进场景前的基线写回去。
|
|
95
|
+
const baseline = await readBaseline();
|
|
96
|
+
if (!baseline || baseline.content === null)
|
|
97
|
+
return { unchanged: true };
|
|
98
|
+
const res = await deps.agentsMd.restore(baseline.content);
|
|
99
|
+
if (res && res.ok === false)
|
|
100
|
+
return { error: String(res.error || '恢复 AGENTS.md 失败') };
|
|
101
|
+
await writeBaseline({ v: 1, at: Date.now(), presetId: null, content: null });
|
|
102
|
+
return { restored: true };
|
|
103
|
+
}
|
|
104
|
+
async function withSync(res) {
|
|
105
|
+
const anyRes = res;
|
|
106
|
+
if (!anyRes || anyRes.ok === false)
|
|
107
|
+
return res;
|
|
108
|
+
const agentsMd = await sync();
|
|
109
|
+
return { ...res, agentsMd };
|
|
110
|
+
}
|
|
111
|
+
return { sync, withSync, state };
|
|
112
|
+
}
|