dsh-plugin-tool-management 0.18.5 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +254 -221
- package/README_EN.md +223 -224
- 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/images/logo.png +0 -0
- package/docs/images/promo-cover-EN.png +0 -0
- package/docs/images/promo-cover.png +0 -0
- package/docs/update.md +28 -500
- package/lib/client.js +1857 -913
- package/lib/context-inject-frame.js +13 -0
- package/lib/context-inject-select.js +4 -3
- package/lib/context-inject.js +4 -3
- package/lib/index.js +100 -19
- package/lib/op-registry.js +16 -1
- package/lib/ops/candidates.js +1 -1
- package/lib/ops/prompts.js +64 -7
- package/lib/ops/trash-retention.js +55 -0
- package/lib/prompts/service.js +191 -25
- package/lib/scene-prompt-sync.js +1 -1
- package/lib/scenes/candidates.js +162 -69
- package/lib/settings-sidecars.js +2 -1
- package/lib/subagents/catalog.js +2 -2
- package/lib/subagents/persona-parse.js +36 -1
- package/lib/subagents/persona-write.js +7 -1
- package/lib/subagents/run-provider.js +55 -21
- package/lib/subagents/service.js +3 -3
- package/lib/subagents/tools.js +17 -2
- package/lib/tools/scene.js +4 -1
- package/lib/tools/subagent.js +13 -1
- package/lib/trash-retention.js +256 -0
- package/package.json +1 -1
package/lib/prompts/service.js
CHANGED
|
@@ -52,6 +52,28 @@ const LAST_APPLIED_ID = LAST_APPLIED_PRESET_ID;
|
|
|
52
52
|
* `isDirectory()` 过滤天然把它排除在预设之外。
|
|
53
53
|
*/
|
|
54
54
|
const APPLIED_FILE = '__applied__.json';
|
|
55
|
+
/**
|
|
56
|
+
* 「应用前 AGENTS.md 根本不存在」这个**事实**的标记文件,与备份内容同放在 `__last-applied__/`。
|
|
57
|
+
*
|
|
58
|
+
* 为什么需要它(0.19.1 修,用户 2026-10-10 反馈「全局提示词取消应用,AGENTS.md 还是存在」):
|
|
59
|
+
* `backupGlobal()` 原先在 `current === null` 时直接 `return false` —— **一个字节都不写**。
|
|
60
|
+
* 于是"应用前没有这个文件"在磁盘上没留下任何痕迹,`unapply()` 便永远读不到备份、恒判
|
|
61
|
+
* `noBackup`,取消应用**永久关不掉**;而界面给出的出路「删除这份预设」根本不成立
|
|
62
|
+
* (`remove()` 只把预设目录移进回收站,不碰 AGENTS.md)。出路不成立 = 死路。
|
|
63
|
+
*
|
|
64
|
+
* 有了它,「取消应用」才是「应用」的**真逆操作**:
|
|
65
|
+
* 应用前有文件 → 写回那份内容;应用前没文件 → 把文件删掉。
|
|
66
|
+
* 与内容槽 `AGENTS.md` **同时只应有一个**:两个都在时 `unapplyState()` 优先判 `'restore'`,
|
|
67
|
+
* 就会把一份不相干的内容写进用户的文件。
|
|
68
|
+
*
|
|
69
|
+
* **但"只能有一个"不等于"每次写都刷掉另一个"**(0.19.1 再修,见 `backupGlobal` 的注释):
|
|
70
|
+
* 这两个槽位合起来记的是**链起点**,链走到中途(应用 a 之后又应用 b)不许动它 ——
|
|
71
|
+
* 原先 `backupGlobal` 每写一笔都顺手 `rm` 掉另一个槽,于是「应用 a → 应用 b」这一步就把
|
|
72
|
+
* "起点不存在"永久抹掉了,取消应用再也回不到"没有文件"。
|
|
73
|
+
* 放在 `__last-applied__/` 里是安全的 —— 该目录已被 `list()` 按 `LAST_APPLIED_ID` 排除,
|
|
74
|
+
* 放什么进去都不会被当成预设。
|
|
75
|
+
*/
|
|
76
|
+
const ABSENT_FILE = 'absent.json';
|
|
55
77
|
/** 新建预设的初始正文(空模板);用户也可以直接粘贴自己的内容。 */
|
|
56
78
|
const BLANK_TEMPLATE = '# AGENTS.md\n\n(DSH 全局指令基线预设,待编辑)\n';
|
|
57
79
|
/** 全局 AGENTS.md 的备份代际上限:与 mcpm patch 的 KEEP_PATCH_BACKUPS 同纪律。 */
|
|
@@ -106,29 +128,34 @@ export function createPromptsService(_ctx, deps) {
|
|
|
106
128
|
}
|
|
107
129
|
catch { /* 描述写不进去不该让保存失败 */ }
|
|
108
130
|
}
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
131
|
+
// 幂等初始化:**只在库目录本身不存在时**把全局 AGENTS.md 拷成 default
|
|
132
|
+
// 预设(首次运行引导)。
|
|
133
|
+
//
|
|
134
|
+
// 判据为什么是「目录在不在」而不是「目录里有没有预设」(0.19.1 修):后者把
|
|
135
|
+
// 「用户把预设删光了」误判成「首次运行的空库」,于是 list() / getCurrent() 每次都重新
|
|
136
|
+
// 播种 —— 用户删掉最后一个预设、刷新列表,同名 default 又回来了(内容 = 当前
|
|
137
|
+
// AGENTS.md,所以 md5 相同),删除看起来「不持久」。库目录一旦存在就说明这个库已经
|
|
138
|
+
// 被初始化过;空目录是用户的决定,不是我们的空库。
|
|
139
|
+
//
|
|
140
|
+
// 只在**目录不存在**时播种还有一层必要:库目录由 create / apply / 备份 / 应用记录
|
|
141
|
+
// 各自按需创建,所以「已存在」这个信号在任何一次真实使用之后都成立;反过来,
|
|
142
|
+
// 全新安装时没有任何一处会先建出这个目录(hub 布局迁移只搬已存在的东西),
|
|
143
|
+
// 首次运行引导因此仍然会发生。
|
|
112
144
|
async function ensureInit() {
|
|
113
|
-
const [presetsDir, globalPath] = await Promise.all([deps.presetsDir, deps.getGlobalAgentsMdPath()]);
|
|
114
|
-
let existing = [];
|
|
115
145
|
try {
|
|
116
|
-
|
|
146
|
+
await stat(deps.presetsDir);
|
|
147
|
+
return; // 库目录已存在(哪怕空)→ 不插手
|
|
117
148
|
}
|
|
118
|
-
catch { /*
|
|
119
|
-
const hasPreset = existing.some((e) => e.isDirectory() && isValidPresetId(e.name) && e.name !== LAST_APPLIED_ID);
|
|
120
|
-
if (hasPreset)
|
|
121
|
-
return;
|
|
149
|
+
catch { /* 库目录不存在 → 真·首次运行,继续播种 */ }
|
|
122
150
|
let globalContent;
|
|
123
151
|
try {
|
|
124
|
-
globalContent = await readFile(
|
|
152
|
+
globalContent = await readFile(await deps.getGlobalAgentsMdPath(), 'utf8');
|
|
125
153
|
}
|
|
126
154
|
catch {
|
|
127
155
|
return;
|
|
128
156
|
}
|
|
129
157
|
try {
|
|
130
|
-
|
|
131
|
-
const defaultDir = join(presetsDir, 'default');
|
|
158
|
+
const defaultDir = join(deps.presetsDir, 'default');
|
|
132
159
|
await mkdir(defaultDir, { recursive: true });
|
|
133
160
|
await writeFile(join(defaultDir, FILENAME), globalContent, 'utf8');
|
|
134
161
|
}
|
|
@@ -263,19 +290,52 @@ export function createPromptsService(_ctx, deps) {
|
|
|
263
290
|
return { ok: true, id: targetId, ...(targetId === safeId ? {} : { renamedFrom: safeId }) };
|
|
264
291
|
}
|
|
265
292
|
/**
|
|
266
|
-
* 覆盖写全局 `~/.dsh/AGENTS.md`
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
293
|
+
* 覆盖写全局 `~/.dsh/AGENTS.md` 前的备份。
|
|
294
|
+
*
|
|
295
|
+
* **两个槽位(内容槽 `AGENTS.md` / `ABSENT_FILE`)合起来记的是「链起点」** —— 即
|
|
296
|
+
* "连续应用/取消往复"这一整串动作**开始之前**的文件状态。所以**只在链起点写一次**:
|
|
297
|
+
* 一旦记下,后面每一笔写入都原样保留它,直到链结束(取消应用 / 写回基线)才清掉。
|
|
298
|
+
*
|
|
299
|
+
* 为什么不是"每笔覆盖"(0.19.1 修,用户 2026-10-10 报:「原本没有 AGENTS.md,新建 a、b
|
|
300
|
+
* 两份提示词,启动 a,再启动 b;不启动 a 也不启动 b,`~/.dsh/AGENTS.md` 没有消失」):
|
|
301
|
+
* ① 起点 文件不存在 槽位=空
|
|
302
|
+
* ② 应用 a 文件="# a" 槽位=absent("应用前不存在")
|
|
303
|
+
* ③ 应用 b 文件="# b" 槽位=**被刷成 "# a"** ← 每笔覆盖把"起点不存在"永久抹掉了
|
|
304
|
+
* ④ 取消应用 文件="# a" 槽位="# a" ← 回不到"没有文件",两颗开关还互相点亮
|
|
305
|
+
* ⑤ 此后无论点多少次,文件只在 "# a" / "# b" 之间来回,**永远回不到起点**。
|
|
306
|
+
*
|
|
307
|
+
* 与场景基线同一条口径:`scene-prompt-sync` 的 `writeBaseline` 也是「已有快照就不覆盖」
|
|
308
|
+
* (连续切场景时基线始终是"进场景之前"那一份)。
|
|
309
|
+
*
|
|
310
|
+
* **多代 `.bak-<stamp>` 快照照旧每笔都写**:它记的不是链起点,而是"每一笔被覆盖前的内容"
|
|
311
|
+
* (审计 / 抢救用),两者互不干扰 —— 链起点保证"退得回起点",多代快照保证"中途每一份都还
|
|
312
|
+
* 在磁盘上"。**必须留多代**:只有单个槽位时,连续写两次就会把用户原始手写内容永久覆盖掉,
|
|
313
|
+
* 而「应用」只是点两下按钮。
|
|
314
|
+
*
|
|
315
|
+
* @returns 是否真的备份了内容(`current` 非空才为 `true`)。文件不存在时返回 `false`,但
|
|
316
|
+
* **不再是"什么都不做"** —— 见 `ABSENT_FILE` 的注释:那个"不存在"本身就是链起点状态。
|
|
270
317
|
*/
|
|
271
318
|
async function backupGlobal(current) {
|
|
272
|
-
if (current === null)
|
|
273
|
-
return false;
|
|
274
319
|
const backupDir = join(deps.presetsDir, LAST_APPLIED_ID);
|
|
275
320
|
await mkdir(backupDir, { recursive: true });
|
|
321
|
+
// 还没有链起点 → 现在记下它;记过就绝不再动(这正是 bug 的修法)。
|
|
322
|
+
if (await unapplyState() === null) {
|
|
323
|
+
if (current === null) {
|
|
324
|
+
// 链起点 = "文件不存在"。写不进去不该让「应用」失败 —— 代价只是取消应用退回"关不掉"。
|
|
325
|
+
try {
|
|
326
|
+
await writeFile(join(backupDir, ABSENT_FILE), JSON.stringify({ at: new Date().toISOString() }, null, 2) + '\n', 'utf8');
|
|
327
|
+
}
|
|
328
|
+
catch { /* 见上 */ }
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
await writeFile(join(backupDir, FILENAME), current, 'utf8');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (current === null)
|
|
335
|
+
return false;
|
|
336
|
+
// 审计快照:每一笔被覆盖前的内容都留一代(链起点不受它影响)。
|
|
276
337
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
277
338
|
await writeFile(join(backupDir, `${FILENAME}.bak-${stamp}`), current, 'utf8');
|
|
278
|
-
await writeFile(join(backupDir, FILENAME), current, 'utf8');
|
|
279
339
|
try {
|
|
280
340
|
const gens = (await readdir(backupDir)).filter((n) => n.startsWith(FILENAME + '.bak-')).sort();
|
|
281
341
|
for (const stale of gens.slice(0, Math.max(0, gens.length - KEEP_GLOBAL_BACKUPS))) {
|
|
@@ -285,6 +345,15 @@ export function createPromptsService(_ctx, deps) {
|
|
|
285
345
|
catch { /* 轮转失败不影响本次写入 */ }
|
|
286
346
|
return true;
|
|
287
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* 链结束(取消应用 / 写回基线)时清掉链起点 —— 下一笔「应用」重新开一条链。
|
|
350
|
+
* 与场景退出后 `writeBaseline({presetId: null, content: null})` 把快照清空是同一个口径。
|
|
351
|
+
*/
|
|
352
|
+
async function clearChainStart() {
|
|
353
|
+
const dir = join(deps.presetsDir, LAST_APPLIED_ID);
|
|
354
|
+
await rm(join(dir, FILENAME), { force: true });
|
|
355
|
+
await rm(join(dir, ABSENT_FILE), { force: true });
|
|
356
|
+
}
|
|
288
357
|
async function readGlobal() {
|
|
289
358
|
try {
|
|
290
359
|
return await readFile(await deps.getGlobalAgentsMdPath(), 'utf8');
|
|
@@ -332,8 +401,9 @@ export function createPromptsService(_ctx, deps) {
|
|
|
332
401
|
}
|
|
333
402
|
/**
|
|
334
403
|
* 按**原文**写回全局基线(不经过任何预设)。
|
|
335
|
-
*
|
|
336
|
-
*
|
|
404
|
+
* 用途:① 场景切换的「关掉场景 → 恢复进场景之前的基线」——那份内容可能是用户手写的,
|
|
405
|
+
* 不一定对应任何预设;② 「取消应用」的 `'restore'` 分支。
|
|
406
|
+
* 备份纪律与 `apply` 完全相同;写回即**链结束**,链起点一并清掉(见 `clearChainStart`)。
|
|
337
407
|
*/
|
|
338
408
|
async function restore(content) {
|
|
339
409
|
const text = String(content ?? '');
|
|
@@ -347,18 +417,109 @@ export function createPromptsService(_ctx, deps) {
|
|
|
347
417
|
}
|
|
348
418
|
// 基线内容可能来自用户手写(场景退出恢复)→ 不绑定任何预设。
|
|
349
419
|
await writeAppliedId(null);
|
|
420
|
+
await clearChainStart();
|
|
350
421
|
return { ok: true, backedUp };
|
|
351
422
|
}
|
|
423
|
+
/**
|
|
424
|
+
* 「链起点是什么状态」—— 界面据此决定「取消应用」可不可点、点下去会发生什么。
|
|
425
|
+
* `'restore'` → 起点有一份内容,取消应用 = 把它写回去;
|
|
426
|
+
* `'remove'` → 起点 AGENTS.md 根本不存在,取消应用 = 删掉文件;
|
|
427
|
+
* `null` → 没有链起点(从没「应用」过,或链已结束),关不掉。
|
|
428
|
+
*
|
|
429
|
+
* 两个槽位同时存在只可能是手改磁盘造成的(`backupGlobal` 记链起点时只会写其中一个),
|
|
430
|
+
* 那种情况优先 `'restore'`:**写回内容比删文件保守**,猜错时用户丢的是"文件里的旧内容"
|
|
431
|
+
* 而不是整个文件。
|
|
432
|
+
*/
|
|
433
|
+
async function unapplyState() {
|
|
434
|
+
const dir = join(deps.presetsDir, LAST_APPLIED_ID);
|
|
435
|
+
try {
|
|
436
|
+
if ((await stat(join(dir, FILENAME))).isFile())
|
|
437
|
+
return 'restore';
|
|
438
|
+
}
|
|
439
|
+
catch { /* 没有内容槽 */ }
|
|
440
|
+
try {
|
|
441
|
+
if ((await stat(join(dir, ABSENT_FILE))).isFile())
|
|
442
|
+
return 'remove';
|
|
443
|
+
}
|
|
444
|
+
catch { /* 没有"不存在"标记 */ }
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* 「取消应用」= **回到链起点**:把这一串应用/取消往复开始之前的文件状态恢复出来。
|
|
449
|
+
*
|
|
450
|
+
* 语义边界(写清楚是因为它决定了开关什么时候可关):
|
|
451
|
+
* - 恢复的是**链起点**,不是"上一步"。连续应用过 A、B 时,取消应用直接回到"应用 A 之前"
|
|
452
|
+
* —— 文件不存在就删掉文件、有一份手写内容就写回那份内容(0.19.1 修,见 `backupGlobal`)。
|
|
453
|
+
* 想从 B 切回 A 就点 A 的开关,效果等价,不必先退回 A 再取消。
|
|
454
|
+
* - **没有链起点就拒绝**(`noBackup`)。没有链起点只有一种成因:AGENTS.md 的当前内容不是
|
|
455
|
+
* 由「应用」写进去的(首次打开提示词页时播种出来的 `default` 就是这样,它内容与 AGENTS.md
|
|
456
|
+
* 相同、显示「生效中」,却从未发生过写入)。此时"起点"无从谈起,写空文件等于抹掉用户的
|
|
457
|
+
* 全局指令 —— 所以宁可拒绝,让界面去说清「要让它不再生效,请删除这份预设或应用别的预设」。
|
|
458
|
+
*
|
|
459
|
+
* `'restore'` 分支复用 `restore()`:备份纪律与「应用」完全一致。`'remove'` 分支(起点文件
|
|
460
|
+
* 不存在)先 `backupGlobal(prev)` 把当前内容存进多代快照,所以删掉的内容仍然拿得回来
|
|
461
|
+
* (`__last-applied__/AGENTS.md.bak-*`),可撤销性与另一支对称。
|
|
462
|
+
* 两支结束都清链起点(`'restore'` 在 `restore()` 里清)—— 链到此为止。
|
|
463
|
+
*/
|
|
464
|
+
async function unapply() {
|
|
465
|
+
const mode = await unapplyState();
|
|
466
|
+
if (mode === null) {
|
|
467
|
+
return {
|
|
468
|
+
ok: false,
|
|
469
|
+
noBackup: true,
|
|
470
|
+
error: '没有可恢复的「链起点」:__last-applied__ 里既没有备份内容,也没有「起点不存在」的标记(AGENTS.md 当前内容不是由「应用」写进去的)。',
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
if (mode === 'remove') {
|
|
474
|
+
const prev = await readGlobal();
|
|
475
|
+
const backedUp = await backupGlobal(prev);
|
|
476
|
+
try {
|
|
477
|
+
await rm(await deps.getGlobalAgentsMdPath(), { force: true });
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
return { ok: false, error: '删除全局 AGENTS.md 失败:' + message(e) };
|
|
481
|
+
}
|
|
482
|
+
await writeAppliedId(null);
|
|
483
|
+
await clearChainStart();
|
|
484
|
+
return { ok: true, backedUp, removed: true };
|
|
485
|
+
}
|
|
486
|
+
let content;
|
|
487
|
+
try {
|
|
488
|
+
content = await readFile(join(deps.presetsDir, LAST_APPLIED_ID, FILENAME), 'utf8');
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
// `unapplyState()` 刚判过它存在,这里读不到只能是并发下被清掉(或权限问题)。
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
noBackup: true,
|
|
495
|
+
error: '没有可恢复的「链起点内容」:__last-applied__ 里没有备份(AGENTS.md 当前内容不是由「应用」写进去的)。',
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
return restore(content);
|
|
499
|
+
}
|
|
352
500
|
async function getCurrent() {
|
|
353
501
|
try {
|
|
354
502
|
await ensureInit();
|
|
355
503
|
const [globalPath, presetsDir] = await Promise.all([deps.getGlobalAgentsMdPath(), deps.presetsDir]);
|
|
504
|
+
// `lastApplied` 与 `presetId` 是**两个不同的事实**,删除保护要的是前者:
|
|
505
|
+
// `presetId` = 内容逐字节相同(谁的内容此刻躺在文件里)
|
|
506
|
+
// `lastApplied`= 最近一次「应用」写进去的是谁(用户**主动做过这个选择**)
|
|
507
|
+
// 两者在"首次打开提示词页时自动播种出来的 default"上分道扬镳:它内容与 AGENTS.md
|
|
508
|
+
// 相同(presetId 命中),却从未发生过写入(lastApplied 为 null)。那份副本删掉完全
|
|
509
|
+
// 合理 —— 拦它反而会变成死路(取消应用也关不掉)。见 `ops/prompts.ts` 的删除保护。
|
|
510
|
+
const lastApplied = await readAppliedId();
|
|
511
|
+
// 与内容一起报「链起点是什么状态」—— 界面用它决定那颗「生效中」的开关点下去是
|
|
512
|
+
// 「取消应用」还是「说明为什么关不掉」,以及取消应用的确认弹窗要说"写回内容"还是
|
|
513
|
+
// **"删掉文件"**(后者是不可逆感很强的动作,必须提前说清,不能点下去才知道)。
|
|
514
|
+
const unapplyMode = await unapplyState();
|
|
515
|
+
const unapplyAvailable = unapplyMode !== null;
|
|
516
|
+
const unapplyRemoves = unapplyMode === 'remove';
|
|
356
517
|
let content;
|
|
357
518
|
try {
|
|
358
519
|
content = await readFile(globalPath, 'utf8');
|
|
359
520
|
}
|
|
360
521
|
catch {
|
|
361
|
-
return { ok: true, content: '', presetId: null, exists: false };
|
|
522
|
+
return { ok: true, content: '', presetId: null, lastApplied, exists: false, unapplyAvailable, unapplyRemoves };
|
|
362
523
|
}
|
|
363
524
|
const globalHash = createHash('sha256').update(content).digest('hex');
|
|
364
525
|
let presetId = null;
|
|
@@ -382,7 +543,7 @@ export function createPromptsService(_ctx, deps) {
|
|
|
382
543
|
break;
|
|
383
544
|
}
|
|
384
545
|
}
|
|
385
|
-
return { ok: true, content, presetId, exists: true };
|
|
546
|
+
return { ok: true, content, presetId, lastApplied, exists: true, unapplyAvailable, unapplyRemoves };
|
|
386
547
|
}
|
|
387
548
|
catch (e) {
|
|
388
549
|
return { ok: false, error: message(e) };
|
|
@@ -409,6 +570,11 @@ export function createPromptsService(_ctx, deps) {
|
|
|
409
570
|
const moved = await moveToTrash('prompts', safeId, [{ from: dir, dest: 'preset' }]);
|
|
410
571
|
if (moved.ok === false)
|
|
411
572
|
return { ok: false, error: '移入回收站失败:' + moved.error };
|
|
573
|
+
// 删掉的正是「最近一次应用」的那一份 → 记录跟着清掉。与 `update()` 改名时跟着改记录对称。
|
|
574
|
+
// 不清的后果:之后再新建一个同 id 的预设,界面会凭这条陈旧记录错误地标上「最近一次应用」
|
|
575
|
+
// (`list()` 只做 `appliedId === id` 的比对,认不出「这是另一个预设」)。
|
|
576
|
+
if (await readAppliedId() === safeId)
|
|
577
|
+
await writeAppliedId(null);
|
|
412
578
|
return { ok: true, id: safeId };
|
|
413
579
|
}
|
|
414
580
|
async function trashList() {
|
|
@@ -462,5 +628,5 @@ export function createPromptsService(_ctx, deps) {
|
|
|
462
628
|
await writeDescription(safeId, description);
|
|
463
629
|
return { ok: true, id: safeId };
|
|
464
630
|
}
|
|
465
|
-
return { list, read, create, update, apply, restore, getCurrent, remove, trashList, trashRestore, trashDelete, importPreset };
|
|
631
|
+
return { list, read, create, update, apply, unapply, restore, getCurrent, remove, trashList, trashRestore, trashDelete, importPreset };
|
|
466
632
|
}
|
package/lib/scene-prompt-sync.js
CHANGED
|
@@ -156,7 +156,7 @@ export function createScenePromptSync(deps) {
|
|
|
156
156
|
if (!cur || cur.ok !== true)
|
|
157
157
|
complete = false;
|
|
158
158
|
else if (cur.presetId)
|
|
159
|
-
add(String(cur.presetId), { kind: 'file' });
|
|
159
|
+
add(String(cur.presetId), { kind: 'file', applied: cur.lastApplied === cur.presetId });
|
|
160
160
|
}
|
|
161
161
|
catch (e) {
|
|
162
162
|
complete = false;
|
package/lib/scenes/candidates.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// 三组候选,各自的失效模式不同:
|
|
8
8
|
// * **技能 / 记忆 / 场景**(skillRows / memoryCandidates / memorySceneCandidates)——
|
|
9
9
|
// 直接读各自 service 的 op,无缓存;
|
|
10
|
-
// * **预设工具**(presetToolCandidates / presetToolNames)——
|
|
11
|
-
//
|
|
10
|
+
// * **预设工具**(presetToolCandidates / presetToolNames)—— 每次枚举要为每个预设取一次
|
|
11
|
+
// 官方 `acquireScope` 租约 + 读一次 `tools.schemas()`,所以按 PRESET_ENUM_CACHE_MS 缓存;
|
|
12
12
|
// * **模型**(modelCandidates)—— 只读宿主 LLM 目录,**不发网络请求**。
|
|
13
13
|
//
|
|
14
14
|
// 依赖全部经 deps 显式传入。注意 \`toolKeyParts\` 留在 index.ts:场景档案引擎也在用它,
|
|
@@ -28,7 +28,7 @@ import { injectionFactsOf, readCompositionFacts, readCompositionText } from '../
|
|
|
28
28
|
const MODEL_REASONING_TIMEOUT_MS = 10_000;
|
|
29
29
|
export function createCandidates(deps) {
|
|
30
30
|
// 外部能力一次解构成局部名:块内代码逐字搬来,保持原样最不容易出错。
|
|
31
|
-
const { get, tools, mcp, skillsService, memoriesService, toolKeyParts } = deps;
|
|
31
|
+
const { get, tools, mcp, skillsService, memoriesService, toolKeyParts, selfToolNames } = deps;
|
|
32
32
|
const ctx = { get };
|
|
33
33
|
// Agent 预设名单(@deepseek-ai/dsh-agent-presets):同样是可选服务,按需取用。
|
|
34
34
|
// 读它只为了回答"当前预设下本插件注入的东西到不到得了模型"——只读,不挂载任何预设。
|
|
@@ -195,31 +195,25 @@ export function createCandidates(deps) {
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
/**
|
|
198
|
-
* 工具候选(人设的「工具白名单 /
|
|
199
|
-
*
|
|
200
|
-
* 只列当前会话的工具会让换预设后的子代理启动失败(官方 `toolFilter` 对未知名直接拒绝)。
|
|
201
|
-
* 同时标注 `current`:当前会话可见的工具(其余只是「本预设可用,当前会话看不到」)。
|
|
198
|
+
* 工具候选(人设的「工具白名单 / 黑名单」选择器):一行一个 Agent 预设,各带**它自己的**
|
|
199
|
+
* 工具清单与归属组。
|
|
202
200
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
201
|
+
* 为什么按预设切而不是给一份并集:人设可能在任意预设下被子代理复用,而界面一行只对那个
|
|
202
|
+
* 预设生效,所以勾出来的名字必须属于那一行。见 `presetToolView`(判据与 0.19.0 修掉的
|
|
203
|
+
* 那条断链)。
|
|
204
|
+
*
|
|
205
|
+
* 0.19.0 删掉了原来那份「全体并集」(`tools`,含 `presets` / `current` 两个字段):整条链
|
|
206
|
+
* 上零读取点(客户端只读 `presets`),而它是同一份数据的第二个真相 —— 从 `presets[].tools`
|
|
207
|
+
* 直接推得出来,每次调用白背 ≈2KB 载荷。
|
|
205
208
|
*/
|
|
206
|
-
// 两个预设枚举缓存(全体候选 / 单个预设)共用这个 TTL
|
|
207
|
-
//
|
|
209
|
+
// 两个预设枚举缓存(全体候选 / 单个预设)共用这个 TTL:枚举要一次 acquireScope 租约 +
|
|
210
|
+
// 一次 schemas(),与内容变化无关,所以只要别刷得太勤就行。
|
|
208
211
|
const PRESET_ENUM_CACHE_MS = 60_000;
|
|
209
212
|
let presetToolsCache = null;
|
|
210
213
|
async function presetToolCandidates() {
|
|
211
214
|
if (presetToolsCache && Date.now() - presetToolsCache.at < PRESET_ENUM_CACHE_MS)
|
|
212
215
|
return presetToolsCache.value;
|
|
213
|
-
const byName = new Map();
|
|
214
216
|
const presets = [];
|
|
215
|
-
// 当前会话的可见工具(用于标 current);拿不到就全部按「非当前」处理。
|
|
216
|
-
const currentNames = new Set();
|
|
217
|
-
try {
|
|
218
|
-
const schemas = await tools.schemas();
|
|
219
|
-
for (const s of schemas || [])
|
|
220
|
-
currentNames.add(String(s.name));
|
|
221
|
-
}
|
|
222
|
-
catch { /* 无 live 工具 → current 全 false */ }
|
|
223
217
|
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
224
218
|
if (agentPresets && typeof agentPresets.list === 'function') {
|
|
225
219
|
try {
|
|
@@ -230,9 +224,10 @@ export function createCandidates(deps) {
|
|
|
230
224
|
const id = String((p && p.id) || '');
|
|
231
225
|
if (!id)
|
|
232
226
|
continue;
|
|
227
|
+
const view = await presetToolView(id);
|
|
233
228
|
// MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
|
|
234
229
|
// 子代理照样能用当前在跑的 MCP —— 那份名单在 decideToolFilter 里运行时并进白名单。
|
|
235
|
-
const names =
|
|
230
|
+
const names = view.names.filter((name) => !name.startsWith(MCP_TOOL_PREFIX));
|
|
236
231
|
presets.push({
|
|
237
232
|
id,
|
|
238
233
|
name: String((p && (p.name || p.id)) || id),
|
|
@@ -242,65 +237,150 @@ export function createCandidates(deps) {
|
|
|
242
237
|
trust: String((p && p.trust) || ''),
|
|
243
238
|
broken: typeof (p && p.broken) === 'string',
|
|
244
239
|
tools: names,
|
|
240
|
+
// 与 tools 同序裁剪:被 MCP 前缀筛掉的名字不留在归属表里(这张表只描述列出来的那些)。
|
|
241
|
+
toolGroups: names.reduce((acc, name) => {
|
|
242
|
+
acc[name] = view.origins[name] ?? 'other';
|
|
243
|
+
return acc;
|
|
244
|
+
}, {}),
|
|
245
245
|
});
|
|
246
|
-
for (const name of names) {
|
|
247
|
-
const rec = byName.get(name) || { name, presets: new Set(), current: false };
|
|
248
|
-
rec.presets.add(id);
|
|
249
|
-
byName.set(name, rec);
|
|
250
|
-
}
|
|
251
246
|
}
|
|
252
247
|
}
|
|
253
|
-
catch { /* 预设服务不可用 →
|
|
248
|
+
catch { /* 预设服务不可用 → 一行都不给(界面显示读不到,不假装空) */ }
|
|
254
249
|
}
|
|
255
|
-
|
|
256
|
-
for (const name of currentNames) {
|
|
257
|
-
if (name.startsWith(MCP_TOOL_PREFIX))
|
|
258
|
-
continue;
|
|
259
|
-
const rec = byName.get(name) || { name, presets: new Set(), current: false };
|
|
260
|
-
byName.set(name, rec);
|
|
261
|
-
}
|
|
262
|
-
for (const rec of byName.values())
|
|
263
|
-
rec.current = currentNames.has(rec.name);
|
|
264
|
-
const value = {
|
|
265
|
-
tools: [...byName.values()]
|
|
266
|
-
.map((r) => ({ name: r.name, presets: [...r.presets].sort(), current: r.current }))
|
|
267
|
-
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
268
|
-
// 保持 roster 顺序(官方的展示顺序),前端四行直接照用。
|
|
269
|
-
presets,
|
|
270
|
-
};
|
|
250
|
+
const value = { presets };
|
|
271
251
|
presetToolsCache = { at: Date.now(), value };
|
|
272
252
|
return value;
|
|
273
253
|
}
|
|
254
|
+
/** 官方包的作用域前缀。只用来判「这个预设的行是不是全部来自官方」,不做任何工具名匹配。 */
|
|
255
|
+
const OFFICIAL_PACKAGE_PREFIX = '@deepseek-ai/';
|
|
274
256
|
/**
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* `
|
|
257
|
+
* 一个预设自己的工具视图 + 三组归属。
|
|
258
|
+
*
|
|
259
|
+
* 归属只能做到「层」粒度,这是**官方的限制而不是设计选择**:`ToolSchema` 只有
|
|
260
|
+
* `{ name, description, parameters, deferLoading? }`,没有归属字段;`ToolRuntime` 的公开面
|
|
261
|
+
* (`register` / `restrict` / `guard` / `get` / `schemas` / `presentAs`)里也没有「这个工具是谁
|
|
262
|
+
* 注册的」(`view` 是 private);`tools/change` 是**无载荷** emit,只报"变了";宿主侧的
|
|
263
|
+
* `dsh-host-plugin-inventory` 自述 "No layer attribution",且 Remote-only、进程内 `ctx.get`
|
|
264
|
+
* 拿不到。所以判据全部是集合运算,不出现任何工具名或包名的字面量:
|
|
265
|
+
*
|
|
266
|
+
* G = tools.schemas() 宿主平面(global 层)
|
|
267
|
+
* P = tools.schemas(acquireScope(id).key) 该预设视图 = G ∪ 该预设自己挂的包
|
|
268
|
+
* Δ = P − G 该预设自带的工具
|
|
269
|
+
* O = 本插件注册漏斗登记的名字 插件工具 = O ∩ P
|
|
270
|
+
*
|
|
271
|
+
* 「官方」在这里是**该预设自带的**,不是「DeepSeek 出的」:宿主平面上的官方工具(如
|
|
272
|
+
* `load_workspace_dependencies`)会落在「其他」。这是刻意的 —— 宁可说「不知道」,不假装是
|
|
273
|
+
* 官方;要认出来只能靠一份名字对照表,那会同时丢掉「零硬编码」与「随官方更新而更新」。
|
|
274
|
+
* 预设里混挂了第三方行时 Δ 无法再拆(包粒度已知、工具粒度未知),此时整份落「其他」。
|
|
279
275
|
*/
|
|
280
|
-
const
|
|
281
|
-
async function
|
|
282
|
-
const hit =
|
|
276
|
+
const presetViewCache = new Map();
|
|
277
|
+
async function presetToolView(id) {
|
|
278
|
+
const hit = presetViewCache.get(id);
|
|
283
279
|
if (hit && Date.now() - hit.at < PRESET_ENUM_CACHE_MS)
|
|
284
|
-
return hit.
|
|
280
|
+
return hit.view;
|
|
281
|
+
// 宿主平面:一次读、所有预设共用。读不到 → 空集,于是 Δ = P,全部落「其他」而不是误判成官方。
|
|
282
|
+
const hostNames = new Set();
|
|
283
|
+
try {
|
|
284
|
+
for (const s of (await tools.schemas()) || []) {
|
|
285
|
+
const name = String(s?.name || '');
|
|
286
|
+
if (name)
|
|
287
|
+
hostNames.add(name);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch { /* 读不到宿主平面 → Δ 退化成 P,全部落「其他」 */ }
|
|
285
291
|
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
286
|
-
|
|
292
|
+
// 官方预设服务的公开面里,取"某个预设的 standing scope key"的只有 `acquireScope(id)`:
|
|
293
|
+
// 返回 `{ key: ScopeKey } & AsyncDisposable`,契约是**读完就 dispose**(内部 `retain()` 会
|
|
294
|
+
// `generation.users++`,不还这份 generation 永远回收不掉)。它**不挂载**预设:`retain` 对
|
|
295
|
+
// 未激活/损坏的预设直接抛 `RemoteError` —— 于是"读不到"与"没有工具"被分开了,后者不会
|
|
296
|
+
// 再冒充前者。此前的 `standingKeyFor` 在官方包里根本不存在(`@deepseek-ai` 全树 0 命中),
|
|
297
|
+
// 那条 `typeof` 守卫恒为假,等于一直在读全局视图。
|
|
298
|
+
const canScope = !!(agentPresets && typeof agentPresets.acquireScope === 'function');
|
|
299
|
+
let names = [];
|
|
300
|
+
if (canScope) {
|
|
301
|
+
let lease;
|
|
302
|
+
try {
|
|
303
|
+
lease = await agentPresets.acquireScope(id);
|
|
304
|
+
names = ((await tools.schemas(lease && lease.key)) || [])
|
|
305
|
+
.map((s) => String(s?.name || ''))
|
|
306
|
+
.filter(Boolean);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
names = [];
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
// 还租约。dispose 自己幂等且吞异常,这里再包一层只为不让"还租约"失败盖掉真正的读失败。
|
|
313
|
+
try {
|
|
314
|
+
if (lease && typeof lease[Symbol.asyncDispose] === 'function')
|
|
315
|
+
await lease[Symbol.asyncDispose]();
|
|
316
|
+
}
|
|
317
|
+
catch { /* 已经还过 */ }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
// 老宿主没有 `acquireScope`:退回宿主平面(= 0.19.0 之前的可见行为),并且**什么都不认成
|
|
322
|
+
// 官方**。这比列一份空的强(用户至少还看得见、还能勾),也比拿全局清单冒充"该预设自己的"诚实。
|
|
323
|
+
names = [...hostNames];
|
|
324
|
+
}
|
|
325
|
+
// 本插件自报的名字。读不到 → 空集,于是本插件的工具落「其他」而不是凭空消失。
|
|
326
|
+
const self = new Set();
|
|
287
327
|
try {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
: undefined;
|
|
328
|
+
for (const name of selfToolNames())
|
|
329
|
+
self.add(String(name));
|
|
291
330
|
}
|
|
292
|
-
catch {
|
|
293
|
-
|
|
331
|
+
catch { /* 读不到自报名单 */ }
|
|
332
|
+
const shipped = canScope ? await presetIsShipped(id) : false;
|
|
333
|
+
const origins = {};
|
|
334
|
+
for (const name of names) {
|
|
335
|
+
if (self.has(name))
|
|
336
|
+
origins[name] = 'plugin';
|
|
337
|
+
// 只有该预设的行**全部**来自官方包时才敢把差集叫「官方」;混挂第三方行时整份落「其他」。
|
|
338
|
+
else if (shipped && !hostNames.has(name))
|
|
339
|
+
origins[name] = 'official';
|
|
340
|
+
else
|
|
341
|
+
origins[name] = 'other';
|
|
294
342
|
}
|
|
295
|
-
|
|
343
|
+
const view = { names, origins };
|
|
344
|
+
presetViewCache.set(id, { at: Date.now(), view });
|
|
345
|
+
return view;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* 该预设的行是不是**全部**来自 `@deepseek-ai/`(出厂四预设都是)。
|
|
349
|
+
*
|
|
350
|
+
* 读 `compositionInventory()`(官方公开方法,"Read plugin rows without creating an Agent")。
|
|
351
|
+
* 它是**包**粒度、不是工具粒度 —— 这正是本节开头那段限制的具体形状:拿得到"这个预设挂了
|
|
352
|
+
* 哪些包",拿不到"哪个包贡献了哪个工具"。所以只能整份判,判不了就整份落「其他」。
|
|
353
|
+
*
|
|
354
|
+
* 任何一步失败都返回 `false`(= 不认成官方),不抛。
|
|
355
|
+
*/
|
|
356
|
+
const presetShippedCache = new Map();
|
|
357
|
+
async function presetIsShipped(id) {
|
|
358
|
+
const hit = presetShippedCache.get(id);
|
|
359
|
+
if (hit && Date.now() - hit.at < PRESET_ENUM_CACHE_MS)
|
|
360
|
+
return hit.shipped;
|
|
361
|
+
let shipped = false;
|
|
296
362
|
try {
|
|
297
|
-
|
|
363
|
+
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
364
|
+
if (agentPresets && typeof agentPresets.compositionInventory === 'function') {
|
|
365
|
+
const list = await agentPresets.compositionInventory();
|
|
366
|
+
const row = (list || []).filter((p) => p && String(p.id) === id)[0];
|
|
367
|
+
const rows = row && Array.isArray(row.rows) ? row.rows : [];
|
|
368
|
+
shipped = rows.length > 0 && rows.every((r) => String((r && r.moduleName) || '').startsWith(OFFICIAL_PACKAGE_PREFIX));
|
|
369
|
+
}
|
|
298
370
|
}
|
|
299
371
|
catch {
|
|
300
|
-
|
|
372
|
+
shipped = false;
|
|
301
373
|
}
|
|
302
|
-
|
|
303
|
-
return
|
|
374
|
+
presetShippedCache.set(id, { at: Date.now(), shipped });
|
|
375
|
+
return shipped;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* 单个预设的工具名(含 MCP 与宿主平面的工具,因为子代理的可见集合是
|
|
379
|
+
* "宿主平面 ∪ 该预设"的并集)。`subagent_manager_run` 每次委派都要用它校验名单,所以
|
|
380
|
+
* 按 id 缓存 PRESET_ENUM_CACHE_MS。只取名字 —— 归属那份走 `presetToolView`。
|
|
381
|
+
*/
|
|
382
|
+
async function presetToolNames(id) {
|
|
383
|
+
return (await presetToolView(id)).names;
|
|
304
384
|
}
|
|
305
385
|
/** 预设名单的名字投影(只读 roster,不挂载任何预设):场景页显示模式名用。 */
|
|
306
386
|
async function presetNames() {
|
|
@@ -318,32 +398,45 @@ export function createCandidates(deps) {
|
|
|
318
398
|
}
|
|
319
399
|
}
|
|
320
400
|
/**
|
|
321
|
-
*
|
|
322
|
-
*
|
|
401
|
+
* 模型候选(人设的「模型」与「模型来源」两个下拉):宿主已注册的 provider + 各 provider
|
|
402
|
+
* 能宣告的模型。只读宿主 LLM 目录,**不发起网络请求**(`discoverModels` 会打端点,这里不用);
|
|
323
403
|
* 拿不到就返回空列表,UI 退回手填(跨来源模型如 sensenova 的手工条目仍需手填兜底)。
|
|
324
|
-
*
|
|
404
|
+
*
|
|
405
|
+
* 两份投影、用途不同:
|
|
406
|
+
* · `models` 是扁平列表 —— DSH 的模型路由是 (provider, model) 一对,两个键必须同时给;
|
|
407
|
+
* · `providers` 是去重后的来源清单 —— 「模型来源」下拉要用它。
|
|
408
|
+
*
|
|
409
|
+
* `providers` 不能由 `models` 去重反推:适配器不提供 `listModels` 时该 provider 一个模型
|
|
410
|
+
* 都报不出来(官方 LlmAdapter 里它是可选项),而"来源有、目录里没模型"恰恰是最需要手填
|
|
411
|
+
* 模型 id 的场景 —— 由 models 反推会让这类 provider 在来源下拉里整个消失。
|
|
325
412
|
*/
|
|
326
413
|
async function modelCandidates() {
|
|
327
414
|
const llm = (typeof ctx.get === 'function' ? ctx.get('llm') : undefined);
|
|
328
415
|
if (!llm || typeof llm.listProviders !== 'function')
|
|
329
|
-
return { models: [] };
|
|
416
|
+
return { models: [], providers: [] };
|
|
330
417
|
let list = [];
|
|
331
418
|
try {
|
|
332
419
|
list = llm.listProviders() || [];
|
|
333
420
|
}
|
|
334
421
|
catch {
|
|
335
|
-
return { models: [] };
|
|
422
|
+
return { models: [], providers: [] };
|
|
336
423
|
}
|
|
337
424
|
const models = [];
|
|
425
|
+
const providers = [];
|
|
338
426
|
const seen = new Set();
|
|
427
|
+
const seenProvider = new Set();
|
|
339
428
|
for (const p of list) {
|
|
340
429
|
const provider = String((p && (p.id || p.provider)) || '');
|
|
341
430
|
if (!provider)
|
|
342
431
|
continue;
|
|
343
432
|
const providerName = String((p && (p.name || p.displayName)) || provider);
|
|
433
|
+
if (!seenProvider.has(provider)) {
|
|
434
|
+
seenProvider.add(provider);
|
|
435
|
+
providers.push({ id: provider, name: providerName });
|
|
436
|
+
}
|
|
344
437
|
let discovered = [];
|
|
345
438
|
try {
|
|
346
|
-
// 适配器可选提供 listModels(官方 LlmAdapter 契约);未提供时该 provider
|
|
439
|
+
// 适配器可选提供 listModels(官方 LlmAdapter 契约);未提供时该 provider 只有来源那一项。
|
|
347
440
|
if (typeof llm.listModels === 'function')
|
|
348
441
|
discovered = (await llm.listModels(provider)) || [];
|
|
349
442
|
}
|
|
@@ -359,7 +452,7 @@ export function createCandidates(deps) {
|
|
|
359
452
|
models.push({ provider, providerName, id, name: String((m && m.name) || id) });
|
|
360
453
|
}
|
|
361
454
|
}
|
|
362
|
-
return { models };
|
|
455
|
+
return { models, providers };
|
|
363
456
|
}
|
|
364
457
|
/**
|
|
365
458
|
* 某个 (provider, model) 的思考强度档位。见 `Candidates.modelReasoning` 的注释。
|
package/lib/settings-sidecars.js
CHANGED
|
@@ -14,7 +14,8 @@ export function createSettingsSidecars(deps) {
|
|
|
14
14
|
// 注入设置(侧车 `inject-settings.json`,界面在「兼容」页):
|
|
15
15
|
// - underSuppressingPresets:压制型预设(persona complete / 关运行时上下文,如极简)
|
|
16
16
|
// 下是否仍然注入。默认 false = 跟随预设。
|
|
17
|
-
// - domains
|
|
17
|
+
// - domains:各域开关(任何预设下都生效;界面六个勾选。深度判据不在这里 —— 见
|
|
18
|
+
// `InjectDomain.applicableTo`:场景与记忆只在顶层,子智能体目录按人设 catalogDepth)。
|
|
18
19
|
// 注入器每个 step 都要同步读一次设置 → 走 TTL 缓存;未加载时先给默认值并异步预热。
|
|
19
20
|
const INJECT_SETTINGS_FILE = 'inject-settings.json';
|
|
20
21
|
const INJECT_SETTINGS_TTL_MS = 3000;
|