dsh-plugin-tool-management 0.19.1 → 0.20.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 +915 -829
- package/README.md +237 -254
- package/README_EN.md +234 -223
- 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.gif +0 -0
- package/docs/update.md +32 -30
- package/lib/client.js +9480 -6970
- package/lib/compat/preset-reach.js +5 -2
- package/lib/context-inject-frame.js +13 -8
- package/lib/hub.js +104 -11
- package/lib/index.js +72 -24
- package/lib/mcp/manager.js +45 -13
- package/lib/mcp/secret-guard.js +2 -2
- package/lib/mcp/state-section.js +1 -1
- package/lib/memories/archive-engine.js +4 -5
- package/lib/memories/constants.js +11 -4
- package/lib/memories/service.js +6 -1
- package/lib/op-registry.js +13 -11
- package/lib/ops/memory.js +42 -4
- package/lib/ops/scene-records.js +43 -9
- package/lib/ops/sessions.js +67 -46
- package/lib/ops/trash.js +107 -8
- package/lib/request-gate.js +9 -2
- package/lib/scene-prompt-sync.js +48 -23
- package/lib/sessions/history.js +32 -22
- package/lib/sessions/workspace.js +0 -10
- package/lib/skills/catalog.js +17 -5
- package/lib/skills/core.js +189 -7
- package/lib/skills/frontmatter.js +31 -0
- package/lib/skills/manager-state.js +73 -3
- package/lib/skills/service.js +53 -13
- package/lib/skills/skill-roots.js +54 -4
- package/lib/skills/skill-trash.js +118 -12
- package/lib/skills/upload-pipeline.js +110 -26
- package/lib/subagents/catalog.js +9 -5
- package/lib/subagents/service.js +44 -6
- package/lib/subagents/tools.js +45 -15
- package/lib/tool-table-switch.js +6 -6
- package/lib/tools/deps.js +43 -0
- package/lib/tools/mcp.js +74 -27
- package/lib/tools/memory.js +49 -9
- package/lib/tools/prompt.js +38 -9
- package/lib/tools/remove.js +206 -0
- package/lib/tools/scene.js +155 -32
- package/lib/tools/skills.js +162 -55
- package/lib/tools/subagent.js +65 -31
- package/lib/tools/table.js +39 -12
- package/lib/trash-id.js +16 -0
- package/lib/trash-retention.js +3 -3
- package/package.json +115 -114
- package/lib/mcp/inspect-probe.js +0 -199
|
@@ -12,8 +12,8 @@ import { promises as fs } from "node:fs";
|
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
13
|
import { unzipSync } from "fflate";
|
|
14
14
|
import { isSameOrDescendant } from "../paths.js";
|
|
15
|
-
import { parseSkillDoc, toKebab } from "./frontmatter.js";
|
|
16
|
-
import { attachCode, codedError, entryPath, lstatOrNull, MAX_ENTRY_NAME_LENGTH, renameWithRetry, } from "./core-util.js";
|
|
15
|
+
import { parseSkillDoc, rewriteDeclaredName, toKebab } from "./frontmatter.js";
|
|
16
|
+
import { attachCode, codedError, entryPath, lstatOrNull, MAX_ENTRY_NAME_LENGTH, renameWithRetry, temporaryPath, } from "./core-util.js";
|
|
17
17
|
import { analyzeSource, collectCandidates, MAX_SOURCE_DEPTH, preflightCandidates, replaceWithCopy, } from "./import-pipeline.js";
|
|
18
18
|
import { managerHomePath, resolvedPath, skillCreateRootPath } from "./skill-roots.js";
|
|
19
19
|
export const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -28,6 +28,37 @@ export async function pathsOverlap(a, b) {
|
|
|
28
28
|
const right = await resolvedPath(b);
|
|
29
29
|
return isSameOrDescendant(left, right) || isSameOrDescendant(right, left);
|
|
30
30
|
}
|
|
31
|
+
/** 目标根下已存在的条目路径:目录 `<name>` 或文件 `<name>.md`,两者都可能占。 */
|
|
32
|
+
async function existingEntryPaths(root, name) {
|
|
33
|
+
const paths = [join(root, name), join(root, `${name}.md`)];
|
|
34
|
+
const existing = [];
|
|
35
|
+
for (const path of paths) {
|
|
36
|
+
try {
|
|
37
|
+
await fs.stat(path);
|
|
38
|
+
existing.push(path);
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
}
|
|
42
|
+
return existing;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 退号取名:`foo` → `foo-1` → `foo-2`…(与 skill-trash 的 nextFreeSkillName、
|
|
46
|
+
* 界面 `uniqueName` 的 'dash' 档同一口径)。占用表 = 磁盘现有条目 + 本批次已定名
|
|
47
|
+
* (`claimed`:防先退号出来的名字又被同批后续候选占用)。候选本身不合法
|
|
48
|
+
* (entryPath 为 null,如超长)时直接放弃 —— 再往后试也不会变合法。
|
|
49
|
+
*/
|
|
50
|
+
async function nextFreeImportName(root, base, claimed) {
|
|
51
|
+
for (let i = 1; i <= 999; i += 1) {
|
|
52
|
+
const candidate = `${base}-${i}`;
|
|
53
|
+
if (claimed.has(candidate))
|
|
54
|
+
continue;
|
|
55
|
+
if (entryPath(root, candidate) === null)
|
|
56
|
+
return null;
|
|
57
|
+
if ((await existingEntryPaths(root, candidate)).length === 0)
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
31
62
|
export async function importSkill(source, log, options = {}) {
|
|
32
63
|
const targetRoot = skillCreateRootPath();
|
|
33
64
|
const conflict = options.conflict === "overwrite" ? "overwrite" : "skip";
|
|
@@ -88,6 +119,9 @@ export async function importSkill(source, log, options = {}) {
|
|
|
88
119
|
entryPath(targetRoot, candidate.kebab) !== null)
|
|
89
120
|
nameCount.set(candidate.kebab, (nameCount.get(candidate.kebab) || 0) + 1);
|
|
90
121
|
}
|
|
122
|
+
// 本批次已定名的集合(含退号名):批量内同名由上方的 nameCount 先拒掉,这里防的是
|
|
123
|
+
// 退号名与后续候选原名的交叉占用(`foo` 退号成 `foo-1`,同批又来个 `foo-1`)。
|
|
124
|
+
const claimed = new Set();
|
|
91
125
|
function failureResult() {
|
|
92
126
|
return {
|
|
93
127
|
ok: false,
|
|
@@ -122,31 +156,39 @@ export async function importSkill(source, log, options = {}) {
|
|
|
122
156
|
});
|
|
123
157
|
continue;
|
|
124
158
|
}
|
|
159
|
+
const existing = await existingEntryPaths(targetRoot, c.kebab);
|
|
160
|
+
let renamedFrom;
|
|
161
|
+
if (existing.length) {
|
|
162
|
+
// 上传导入撞名一律退号新增(用户 2026-10-15):旧行为是静默跳过,结果里只多
|
|
163
|
+
// 一行"已跳过",用户想要的那份根本没进来。`skill-import`(目录导入)不带
|
|
164
|
+
// dedupe,仍是 skip / overwrite 二选一。
|
|
165
|
+
if (options.dedupe !== true) {
|
|
166
|
+
conflicts.push({
|
|
167
|
+
name: c.kebab,
|
|
168
|
+
source: c.source,
|
|
169
|
+
isDir: c.isDir,
|
|
170
|
+
paths: existing,
|
|
171
|
+
});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const free = await nextFreeImportName(targetRoot, c.kebab, claimed);
|
|
175
|
+
if (!free) {
|
|
176
|
+
failed.push({
|
|
177
|
+
source: c.source,
|
|
178
|
+
error: `同名条目太多,无法生成退号名称: ${c.kebab}`,
|
|
179
|
+
code: "error.import.renameExhausted",
|
|
180
|
+
params: { name: c.kebab },
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
renamedFrom = c.kebab;
|
|
185
|
+
c.kebab = free;
|
|
186
|
+
}
|
|
125
187
|
const dest = c.isDir
|
|
126
188
|
? join(targetRoot, c.kebab)
|
|
127
189
|
: join(targetRoot, `${c.kebab}.md`);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
join(targetRoot, `${c.kebab}.md`),
|
|
131
|
-
];
|
|
132
|
-
const existing = [];
|
|
133
|
-
for (const path of paths) {
|
|
134
|
-
try {
|
|
135
|
-
await fs.stat(path);
|
|
136
|
-
existing.push(path);
|
|
137
|
-
}
|
|
138
|
-
catch { }
|
|
139
|
-
}
|
|
140
|
-
if (existing.length) {
|
|
141
|
-
conflicts.push({
|
|
142
|
-
name: c.kebab,
|
|
143
|
-
source: c.source,
|
|
144
|
-
isDir: c.isDir,
|
|
145
|
-
paths: existing,
|
|
146
|
-
});
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
pending.push({ name: c.kebab, source: c.source, isDir: c.isDir, dest });
|
|
190
|
+
claimed.add(c.kebab);
|
|
191
|
+
pending.push({ name: c.kebab, source: c.source, isDir: c.isDir, dest, renamedFrom });
|
|
150
192
|
}
|
|
151
193
|
if (pending.length === 0 && conflicts.length === 0)
|
|
152
194
|
return failureResult();
|
|
@@ -164,6 +206,31 @@ export async function importSkill(source, log, options = {}) {
|
|
|
164
206
|
for (const p of pending) {
|
|
165
207
|
try {
|
|
166
208
|
const warnings = await replaceWithCopy(p.source, p.dest, p.isDir);
|
|
209
|
+
// 退号落盘后把声明名改成新名字:`canonicalSkillName` 取的是 frontmatter 的
|
|
210
|
+
// `name:`,只改目录会"看着叫 foo-1、其实还是 foo"—— 两份同名技能在「同名
|
|
211
|
+
// 首选」里被并成一组,而那颗开关挑的是来源,按下去什么都不会变(skill-trash.ts
|
|
212
|
+
// 的 retitleRestoredSkill 同一理由)。返回 null(没有 `name:` 行)是良性的:
|
|
213
|
+
// 声明名为空时 canonicalSkillName 回落到条目名,退号目录名就是它的名字。
|
|
214
|
+
// 写盘失败记一条 warning,不阻断导入 —— 技能已按退号名落盘。
|
|
215
|
+
if (p.renamedFrom) {
|
|
216
|
+
try {
|
|
217
|
+
const docPath = p.isDir ? join(p.dest, "SKILL.md") : p.dest;
|
|
218
|
+
const doc = await fs.readFile(docPath, "utf8");
|
|
219
|
+
const content = rewriteDeclaredName(doc, p.name);
|
|
220
|
+
if (content !== null) {
|
|
221
|
+
const temp = temporaryPath(docPath, "retitle");
|
|
222
|
+
await fs.writeFile(temp, content, "utf8");
|
|
223
|
+
await renameWithRetry(temp, docPath);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
warnings.push({
|
|
228
|
+
code: "warning.retitleFailed",
|
|
229
|
+
params: { path: p.dest },
|
|
230
|
+
error: String(e && e.message ? e.message : e),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
167
234
|
imported.push({ name: p.name, overwritten: false, warnings });
|
|
168
235
|
if (log)
|
|
169
236
|
log("import", `导入 ${p.source} -> ${p.dest}`);
|
|
@@ -337,8 +404,25 @@ export async function prepareUploadedSource(sessionRoot, input) {
|
|
|
337
404
|
await writeUploadedEntries(contentRoot, input && input.entries);
|
|
338
405
|
const rootSkill = join(contentRoot, "SKILL.md");
|
|
339
406
|
const rootSkillStat = await lstatOrNull(rootSkill);
|
|
340
|
-
if (!rootSkillStat || !rootSkillStat.isFile())
|
|
341
|
-
|
|
407
|
+
if (!rootSkillStat || !rootSkillStat.isFile()) {
|
|
408
|
+
// 没有根级 SKILL.md:剩下的合法形态只有两种 —— 根级 .md(单文件技能)或子目录
|
|
409
|
+
// 带 SKILL.md(批量),与 importSkill 的 collectCandidates 收录口径一一对应。
|
|
410
|
+
// 两者都不占,这个包就打不开:此时说清缺的是 SKILL.md、以及是哪个包(用户
|
|
411
|
+
// 2026-10-15:老报错是 importSkill 的空来源错误,带着 uuid 临时目录路径、落在
|
|
412
|
+
// 通用「文件要求」段,认不出是谁、也看不出缺什么)。
|
|
413
|
+
const items = await fs.readdir(contentRoot, { withFileTypes: true });
|
|
414
|
+
for (const it of items) {
|
|
415
|
+
if (it.isFile() && it.name.toLowerCase().endsWith(".md"))
|
|
416
|
+
return contentRoot;
|
|
417
|
+
if (it.isDirectory()) {
|
|
418
|
+
const st = await lstatOrNull(join(contentRoot, it.name, "SKILL.md"));
|
|
419
|
+
if (st && st.isFile())
|
|
420
|
+
return contentRoot;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const label = String((input && input.name) || "").trim() || "上传内容";
|
|
424
|
+
throw codedError(`${label} 中未找到 SKILL.md:需放在根目录,或放在某个子目录下`, "error.upload.noSkillMd", { name: label });
|
|
425
|
+
}
|
|
342
426
|
const doc = parseSkillDoc(await fs.readFile(rootSkill, "utf8"));
|
|
343
427
|
const fallback = String((input && input.name) || "uploaded-skill")
|
|
344
428
|
.replace(/\.zip$/i, "")
|
package/lib/subagents/catalog.js
CHANGED
|
@@ -80,8 +80,10 @@ export function createSubagentCatalog(deps, opts = {}) {
|
|
|
80
80
|
let allowed = [];
|
|
81
81
|
let loadedAt = Number.NEGATIVE_INFINITY;
|
|
82
82
|
let inflight = null;
|
|
83
|
-
//
|
|
84
|
-
//
|
|
83
|
+
// 渲染结果按**深度 + 尾句前提**缓存:过滤只依赖深度这一个整数(实际出现的深度就 0/1/2 三档),
|
|
84
|
+
// 而尾句点不点名 `subagent_manager_list` 取决于**这个 agent 手里有没有它**(2026-10-14
|
|
85
|
+
// 审查 P1-8:此前只按深度缓存,于是尾句用的是第一个来问的 agent 的答案)。
|
|
86
|
+
// 两维都是小枚举,于是"每个 step 同步渲染"退化成一次查表。重算成功时清空。
|
|
85
87
|
const rendered = new Map();
|
|
86
88
|
const recompute = async () => {
|
|
87
89
|
try {
|
|
@@ -102,11 +104,13 @@ export function createSubagentCatalog(deps, opts = {}) {
|
|
|
102
104
|
if (now() - loadedAt > ttlMs)
|
|
103
105
|
void revalidate();
|
|
104
106
|
const depth = subagentDepthOf(agent);
|
|
105
|
-
const
|
|
107
|
+
const visible = deps.listToolVisible ? deps.listToolVisible(agent) : true;
|
|
108
|
+
const key = depth + '|' + (visible ? '1' : '0');
|
|
109
|
+
const cached = rendered.get(key);
|
|
106
110
|
if (cached !== undefined)
|
|
107
111
|
return cached;
|
|
108
|
-
const out = renderSubagentCatalog(allowed.filter((p) => catalogInjectedAt(p, depth)), maxEntries, maxDescription,
|
|
109
|
-
rendered.set(
|
|
112
|
+
const out = renderSubagentCatalog(allowed.filter((p) => catalogInjectedAt(p, depth)), maxEntries, maxDescription, visible);
|
|
113
|
+
rendered.set(key, out);
|
|
110
114
|
return out;
|
|
111
115
|
},
|
|
112
116
|
catalogVisibleAt: (depth) => allowed.some((p) => catalogInjectedAt(p, depth)),
|
package/lib/subagents/service.js
CHANGED
|
@@ -196,6 +196,24 @@ export function createSubagentService(ctx, opts) {
|
|
|
196
196
|
: { ...res, stateSyncError: reason };
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* 目标目录里第一个空出来的退号名:`写作助手` → `写作助手(1)` → `写作助手(2)`…
|
|
201
|
+
* (全角括号,与界面 `uniqueName` 同一写法 —— 人设名就是文件名,不做任何归一化,
|
|
202
|
+
* `validPersonaName` 也放行全角括号)。
|
|
203
|
+
*
|
|
204
|
+
* 候选本身不合法(超长 / 空)时直接放弃:再往后试也不会变合法。
|
|
205
|
+
*/
|
|
206
|
+
async function nextFreePersonaName(name) {
|
|
207
|
+
for (let i = 1; i <= 999; i += 1) {
|
|
208
|
+
const candidate = `${name}(${i})`;
|
|
209
|
+
if (!validPersonaName(candidate))
|
|
210
|
+
return null;
|
|
211
|
+
const taken = await readFile(join(dir, candidate + '.md'), 'utf8').then(() => true).catch(() => false);
|
|
212
|
+
if (!taken)
|
|
213
|
+
return candidate;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
199
217
|
/** list() 输出统一附上 enabled:文件缺失 → 全 true;否则按集合。 */
|
|
200
218
|
function withEnabled(docs, set) {
|
|
201
219
|
if (set === null)
|
|
@@ -361,11 +379,30 @@ export function createSubagentService(ctx, opts) {
|
|
|
361
379
|
return { ok: false, error: `回收站条目不存在: ${id}` };
|
|
362
380
|
if (!validPersonaName(entry.name))
|
|
363
381
|
return { ok: false, error: `回收站里的人设名不合法: ${entry.name}` };
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
382
|
+
/*
|
|
383
|
+
* 同名先问一句(用户 2026-10-14):撞名时**默认仍拒绝**(绝不覆盖),但把候选名字一起
|
|
384
|
+
* 回(`params.next`),界面弹窗才能如实写出"确认后会叫 `写作助手(1)`";用户确认后带
|
|
385
|
+
* `dedupe: true` 重来,这一次真的按退号的名字落盘。
|
|
386
|
+
*
|
|
387
|
+
* `dedupe` 缺省即旧行为:这条 op 目前只有界面在用,但默认值必须是"拒" —— 一个默认
|
|
388
|
+
* 静默改名的恢复口,早晚会有人踩。
|
|
389
|
+
*/
|
|
390
|
+
let name = entry.name;
|
|
391
|
+
const exists = await readFile(join(dir, name + '.md'), 'utf8').then(() => true).catch(() => false);
|
|
392
|
+
if (exists) {
|
|
393
|
+
const next = await nextFreePersonaName(name);
|
|
394
|
+
if (args?.dedupe !== true || !next) {
|
|
395
|
+
// 专用错误码(四个回收站统一):界面按它认出"这是撞名"并弹确认窗。
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
error: `已存在同名人设: ${name}`,
|
|
399
|
+
code: 'error.dup.conflict',
|
|
400
|
+
params: { name, ...(next ? { next } : {}) },
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
name = next;
|
|
404
|
+
}
|
|
405
|
+
const target = join(dir, name + '.md');
|
|
369
406
|
try {
|
|
370
407
|
await mkdir(dir, { recursive: true });
|
|
371
408
|
await moveOutOfTrash('subagents', id, 'persona.md', target);
|
|
@@ -375,8 +412,9 @@ export function createSubagentService(ctx, opts) {
|
|
|
375
412
|
}
|
|
376
413
|
await purgeTrashEntry('subagents', id);
|
|
377
414
|
// v0.8.5:回收站恢复默认不启动(与新建/导入同口径)。
|
|
415
|
+
// 启停名单必须按**最终落盘的名字**写:退号恢复时它不是回收站里的原名。
|
|
378
416
|
cache = null;
|
|
379
|
-
return withEnabledNote({ ok: true, name: entry.name }, () => materializeEnabledExcluding([
|
|
417
|
+
return withEnabledNote({ ok: true, name, renamed: name !== entry.name }, () => materializeEnabledExcluding([name]));
|
|
380
418
|
},
|
|
381
419
|
'subagent-trash-delete': async (args) => {
|
|
382
420
|
const id = String((args && args.id) || '').trim();
|
package/lib/subagents/tools.js
CHANGED
|
@@ -15,11 +15,18 @@ export function text(v) {
|
|
|
15
15
|
* 绑定的人设启用,见 archive-engine 的 applySubagentSwitches)。
|
|
16
16
|
* ② 场景绑定校验(设计 §3.3):启用场景的 subagents 并集;空并集 = 全部可用。
|
|
17
17
|
* 之前只有②:全局默认所有子智能体都进上下文,没有开关可言。
|
|
18
|
+
*
|
|
19
|
+
* `includeUnavailable`(2026-10-14 对抗性审查 P1-7)**只给 `subagent_manager_list` 的
|
|
20
|
+
* `all=true` 用**,两道闸一起放开:停用的人设此前**在任何地方都查不到** —— 注入目录只列可用的,
|
|
21
|
+
* `subagent_manager_switch` 又默认关着,于是模型既发现不了它、也就无从提示用户去启用。
|
|
22
|
+
* 另外三个 `*_list`(MCP / 技能 / 记忆)早就有 `all=true` 这个出口,人设是缺的那一个。
|
|
23
|
+
* 注意放开的只是**可见性**:`subagent_manager_run` 仍然按完整两道闸判"能不能委派",
|
|
24
|
+
* 列出来 ≠ 可委派(回执里那句「人设不可用」照旧成立)。
|
|
18
25
|
*/
|
|
19
|
-
export async function filterBySceneBinding(docs, enabledSceneLists) {
|
|
20
|
-
const live = docs.filter((d) => d.enabled !== false);
|
|
26
|
+
export async function filterBySceneBinding(docs, enabledSceneLists, includeUnavailable = false) {
|
|
27
|
+
const live = includeUnavailable ? docs : docs.filter((d) => d.enabled !== false);
|
|
21
28
|
const bound = new Set(enabledSceneLists.flat());
|
|
22
|
-
if (!bound.size)
|
|
29
|
+
if (includeUnavailable || !bound.size)
|
|
23
30
|
return { allowed: live, reason: null };
|
|
24
31
|
const allowed = live.filter((d) => bound.has(d.name));
|
|
25
32
|
return { allowed, reason: allowed.length ? null : `当前启用场景的子智能体绑定: ${[...bound].join('、')}` };
|
|
@@ -27,19 +34,32 @@ export async function filterBySceneBinding(docs, enabledSceneLists) {
|
|
|
27
34
|
export function defineSubagentManagerListTool(subagents) {
|
|
28
35
|
return {
|
|
29
36
|
name: 'subagent_manager_list',
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
// `depth-filtered` 删掉(2026-10-15 注入文本审查):注入深度是**注入侧**的实现细节,
|
|
38
|
+
// 模型看到的是"目录里有哪些人设",`catalogDepth` 那个词对它没有任何可操作性。
|
|
39
|
+
description: 'List personas (pre-configured subagent profiles) with their descriptions. The「当前子智能体」reminder carries a view of the same catalog; call this for the always-current full list, or pass all=true to include personas that are disabled or outside the active scene.',
|
|
40
|
+
parameters: {
|
|
41
|
+
all: { type: 'boolean', description: 'Include disabled personas and ones outside the active scene (default false). Listed ≠ delegable: `run` still refuses those.' },
|
|
42
|
+
},
|
|
32
43
|
output: {
|
|
33
44
|
schema: { type: 'string' },
|
|
34
45
|
render: (_a, v) => text(String(v)),
|
|
35
46
|
},
|
|
36
|
-
async execute(
|
|
37
|
-
const
|
|
47
|
+
async execute(args, exec) {
|
|
48
|
+
const includeUnavailable = Boolean(args?.all === true);
|
|
49
|
+
const docs = await subagents.list();
|
|
50
|
+
const { allowed, reason } = await filterBySceneBinding(docs, await subagents.sceneLists(), includeUnavailable);
|
|
38
51
|
// 这里**不再按深度过滤**(2026-09-17 用户裁定,方案 A):`catalogDepth` 只决定常驻目录
|
|
39
52
|
// 注入到哪些会话,与"能不能委派"无关 —— 委派由官方决定(`dsh-tool-subagent` 默认能嵌套
|
|
40
53
|
// 到 3 层)。此前按深度过滤等于把可用人设藏起来:目录不注入时模型只能靠这个工具查,
|
|
41
54
|
// 而工具又不列全,结果是"明明能委派却查不到人设"。
|
|
42
|
-
|
|
55
|
+
// `all=true` 时**标出哪些不可用**:只列名字的话,模型照样分不清哪个能直接委派
|
|
56
|
+
// (与技能列表标 `shadowed-by:` 同一条理由 —— 列出来不等于能用)。
|
|
57
|
+
const lines = allowed.map((p) => '- ' + p.name + ' — ' + (p.description || '(无描述)') +
|
|
58
|
+
(p.continuable === true ? CONTINUABLE_MARK : '') +
|
|
59
|
+
(includeUnavailable ? (p.enabled === false ? '(已停用,需先启用)' : '') : ''));
|
|
60
|
+
// 默认口径下有多少个没列出来(停用的 + 不在启用场景里的):不报的话,模型看到一份短名单
|
|
61
|
+
// 会当成"本机只有这些人设"。数量照报,与另外三个 list 的表头同一口径。
|
|
62
|
+
const hiddenCount = includeUnavailable ? 0 : docs.length - allowed.length;
|
|
43
63
|
let notice = '';
|
|
44
64
|
try {
|
|
45
65
|
notice = typeof subagents.noticeFor === 'function' ? await subagents.noticeFor(exec) : '';
|
|
@@ -47,7 +67,9 @@ export function defineSubagentManagerListTool(subagents) {
|
|
|
47
67
|
catch {
|
|
48
68
|
notice = '';
|
|
49
69
|
}
|
|
50
|
-
|
|
70
|
+
const header = '人设子智能体(' + allowed.length + ')' +
|
|
71
|
+
(hiddenCount > 0 ? '(另有 ' + hiddenCount + ' 个停用或不在当前场景,传 all=true 查看)' : '') + ':\n';
|
|
72
|
+
return header + (lines.join('\n') || '(无)') + (reason ? '\n注意:' + reason : '') + notice;
|
|
51
73
|
},
|
|
52
74
|
};
|
|
53
75
|
}
|
|
@@ -69,8 +91,13 @@ export function defineSubagentManagerRunTool(subagents) {
|
|
|
69
91
|
// `how` 行(context-inject.ts 的 `DOMAIN_FRAME.subagents`)已经逐字说过一遍,而两份都在
|
|
70
92
|
// 每轮上下文里 = 同一件事付两次 token。留注入那份(它出现在"正在选工具"的那一刻)。
|
|
71
93
|
// 代价如实记下:子智能体域被关掉、或走压制型预设时上下文里没有那条 how 行,模型只剩本
|
|
72
|
-
// 描述与 `agent`
|
|
73
|
-
//
|
|
94
|
+
// 描述与 `agent` 参数说明 —— 够它认出这条是带人设的委派通道,但"没有人设贴合时才用官方
|
|
95
|
+
// 那两个"这层分界就没人说了。
|
|
96
|
+
//
|
|
97
|
+
// 2026-10-14(对抗性审查 P0-2):`subagent_manager_list` 已移出出厂默认隐藏名单,所以
|
|
98
|
+
// `agent` 参数说明可以点名它(此前只能写"Persona name.",因为那条工具默认不在模型手里)。
|
|
99
|
+
// 注释里那句 "Persona name from subagent_manager_list" 从 2026-09-23 起就与代码逐字不符,
|
|
100
|
+
// 现在代码终于跟上了。
|
|
74
101
|
//
|
|
75
102
|
// 2026-09-23(用户裁定):本工具的 `task` / `inherit` 两个参数说明与上面的 Modes 段
|
|
76
103
|
// **逐字重复**,而工具表每轮都发 —— 同一件事付两次 token。实测 `subagent_manager_run`
|
|
@@ -78,11 +105,14 @@ export function defineSubagentManagerRunTool(subagents) {
|
|
|
78
105
|
// 最贵的单个参数。收完两处重复后目标 ≈325 tok。判据是"删掉的那句在描述里已经有了吗":
|
|
79
106
|
// Modes 段留着(它是模型的入口),参数说明只留"这一段独有的信息"。
|
|
80
107
|
// `inherit` 那句"轮中委派拿不到当前轮"必须留 —— 它是这条参数唯一会让人写错 task 的地方。
|
|
81
|
-
description: 'Run a named persona as a subagent: it gets the persona as its own system prompt, works on `task`, and returns only its final output. A persona marked「允许追问」instead returns an agent id and runs in the background — continue it with `send_message`.\n\nModes: by default a fresh child that cannot see this conversation, so `task` must be self-contained. With `inherit: true` it also gets this conversation\'s **finished** turns (like the host\'s `subagent_fork`) — the current turn is never included, so a mid-turn hand-off still needs a self-contained `task`.\n\n`task` = the goal plus the context it needs; leave method and output format to the persona.\n\nUse it when the work matches a persona in the
|
|
108
|
+
description: 'Run a named persona as a subagent: it gets the persona as its own system prompt, works on `task`, and returns only its final output. A persona marked「允许追问」instead returns an agent id and runs in the background — continue it with `send_message`.\n\nModes: by default a fresh child that cannot see this conversation, so `task` must be self-contained. With `inherit: true` it also gets this conversation\'s **finished** turns (like the host\'s `subagent_fork`) — the current turn is never included, so a mid-turn hand-off still needs a self-contained `task`.\n\n`task` = the goal plus the context it needs; leave method and output format to the persona.\n\nUse it when the work matches a persona in the「当前子智能体」reminder (a review, an investigation, a piece of writing) and the detail should not sit in your own context. Not for reading a file (Read), finding a definition (Grep/Glob), or touching two or three files.',
|
|
82
109
|
parameters: {
|
|
83
|
-
agent: { type: 'string', required: true, description: 'Persona name.' },
|
|
84
|
-
|
|
85
|
-
|
|
110
|
+
agent: { type: 'string', required: true, description: 'Persona name — see the「当前子智能体」reminder, or list personas with subagent_manager_list.' },
|
|
111
|
+
// `task` 的说明只留"这是什么"(2026-10-15 注入文本审查):上面描述里那句
|
|
112
|
+
// "leave method and output format to the persona." 此前在这里**逐字重复**了一遍
|
|
113
|
+
// (57 字符),而模型填这个参数时两处都在手上 —— 同一件事付两次 token。
|
|
114
|
+
task: { type: 'string', required: true, description: 'The task for the subagent.' },
|
|
115
|
+
inherit: { type: 'boolean', description: 'Inherit this conversation\'s finished turns (current turn excluded). Default false.' },
|
|
86
116
|
},
|
|
87
117
|
output: {
|
|
88
118
|
schema: { type: 'string' },
|
package/lib/tool-table-switch.js
CHANGED
|
@@ -3,8 +3,8 @@ import { addPreset, buildToolTableReport, DEFAULT_HIDDEN_TOOLS, dropPreset, migr
|
|
|
3
3
|
export function createToolTableSwitch(deps) {
|
|
4
4
|
// ---------- 模型工具表开关(侧车 `tool-table.json`,界面在「兼容」页)----------
|
|
5
5
|
// 工具表按**每个请求**付钱:20 个工具的整份定义合计 ≈3,769 tok 每轮都在。关掉某几个,
|
|
6
|
-
// 它们整份不进请求(实测口径与取舍见 src/tools/table.ts
|
|
7
|
-
// (20
|
|
6
|
+
// 它们整份不进请求(实测口径与取舍见 src/tools/table.ts 的文件头)。**出厂就已经关了十四条**
|
|
7
|
+
// (20 条里实发 6 条)—— 名单、判据与代价在 `DEFAULT_HIDDEN_TOOLS`,只在这个人从没记过
|
|
8
8
|
// 选择时铺(见下面的 `toolTableSettingsFrom`)。
|
|
9
9
|
//
|
|
10
10
|
// 为什么放在目录之前:两个目录的「用 `X` 查」提示要跟着这份设置变(工具关掉后那句话
|
|
@@ -33,8 +33,8 @@ export function createToolTableSwitch(deps) {
|
|
|
33
33
|
// 0.14.0 旧工具名迁移:读侧翻译一次并**回写盘**(幂等)。不迁移的话,用户"关掉了某条"
|
|
34
34
|
// 的意图会在新名字上静默失效 —— 那条工具照旧每轮发出去,而界面上看不出来。
|
|
35
35
|
// 写失败不回滚本次读取:内存里已经是迁移后的值,下次读会再试一次。
|
|
36
|
-
// 侧车不存在(`readJsonFile` 给 null)= 用户从没记过选择 ⇒
|
|
37
|
-
// 实发
|
|
36
|
+
// 侧车不存在(`readJsonFile` 给 null)= 用户从没记过选择 ⇒ 用**出厂默认**(十四条不发,
|
|
37
|
+
// 实发 6 条;判据与代价见 tools/table.ts 的 DEFAULT_HIDDEN_TOOLS)。存过盘的
|
|
38
38
|
// 一律照盘上那份,包括显式的 `hidden: []` —— 那是"我全都要",不能被默认值盖掉。
|
|
39
39
|
const migrated = migrateLegacyToolNames(toolTableSettingsFrom(raw));
|
|
40
40
|
const value = migrated.settings;
|
|
@@ -59,7 +59,7 @@ export function createToolTableSwitch(deps) {
|
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
61
|
* 同步快照(热路径用:工具门禁每次调用、注入通道每个 step 都要问一句)。还没加载时先给
|
|
62
|
-
* **空集**并异步预热 ——
|
|
62
|
+
* **空集**并异步预热 —— 空集不是出厂默认(出厂关着十四条),是刻意的:预热前的这一瞬与
|
|
63
63
|
* 用户的选择可能不一致,但方向必须是安全的那一侧(不隐藏任何东西,绝不因为读盘慢而让
|
|
64
64
|
* 工具凭空消失)。
|
|
65
65
|
*/
|
|
@@ -71,7 +71,7 @@ export function createToolTableSwitch(deps) {
|
|
|
71
71
|
return toolTableCache.off;
|
|
72
72
|
}
|
|
73
73
|
const toolTableRows = () => [...toolTableSizes].map(([name, bytes]) => ({ name, bytes }));
|
|
74
|
-
const toolTableReport = (hidden = [...toolTableHidden()]) => buildToolTableReport(toolTableRows(), hidden);
|
|
74
|
+
const toolTableReport = (hidden = [...toolTableHidden()]) => buildToolTableReport(toolTableRows(), hidden, deps.carrierHiddenTools ? deps.carrierHiddenTools() : []);
|
|
75
75
|
/** 落盘 + 同步内存缓存(三条写路径共用:改勾选 / 存方案 / 删方案)。 */
|
|
76
76
|
async function writeToolTableSettings(next) {
|
|
77
77
|
await deps.ensurePaths();
|
package/lib/tools/deps.js
CHANGED
|
@@ -21,3 +21,46 @@ export function toolNameList(list) {
|
|
|
21
21
|
const head = list.slice(0, TOOL_NAME_LIST_CAP).join('、');
|
|
22
22
|
return list.length > TOOL_NAME_LIST_CAP ? head + ' 等 ' + list.length + ' 项' : head;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* 按名字找技能落在哪个来源(root)。
|
|
26
|
+
*
|
|
27
|
+
* 为什么需要它(2026-10-15 smoke-test P0-1):`skill-create` 的默认落点是 **hub**
|
|
28
|
+
* (`tool-management/skills/`,与界面「创建技能」一致),而 `skill-enable` / `skill-disable` /
|
|
29
|
+
* `skill-prefer` / `skill-delete` 这些 op 的默认 root 是 **dsh**。于是「建完就启用」这条
|
|
30
|
+
* 最自然的链路必然报「技能不存在」—— 同一个动作在工具侧落 hub、在开关侧查 dsh。
|
|
31
|
+
* 默认值改哪边都不对(面板显式传 root,两边都依赖自己的默认),所以判定放到工具侧:
|
|
32
|
+
* **没给 root 就在全部来源里按名字找胜出副本**,找到哪个就用哪个。
|
|
33
|
+
*
|
|
34
|
+
* 选取口径与 `skill_manager_read` 同一套(`markWinners` 已标出影子副本):先取没被
|
|
35
|
+
* `shadowedBy` 盖住的那一份(同名组里胜出的只有一个),都没有就取第一个。
|
|
36
|
+
*/
|
|
37
|
+
export async function locateSkillRoot(skillsOps, name) {
|
|
38
|
+
const wanted = String(name || '').trim();
|
|
39
|
+
if (wanted === '')
|
|
40
|
+
return { ok: true, root: null, roots: [] };
|
|
41
|
+
let state;
|
|
42
|
+
try {
|
|
43
|
+
state = await skillsOps['skill-state']({});
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return { ok: false, root: null, roots: [] };
|
|
47
|
+
}
|
|
48
|
+
if (!state || state.ok === false)
|
|
49
|
+
return { ok: false, root: null, roots: [] };
|
|
50
|
+
const roots = [];
|
|
51
|
+
const hits = [];
|
|
52
|
+
for (const root of (state.data && state.data.roots) || []) {
|
|
53
|
+
roots.push(String(root.key || ''));
|
|
54
|
+
for (const skill of root.skills || []) {
|
|
55
|
+
// 与 list / read / save 同一套名字口径:`declaredName || name`,再比目录名。
|
|
56
|
+
if (String(skill.declaredName || skill.name || '') !== wanted && String(skill.name || '') !== wanted)
|
|
57
|
+
continue;
|
|
58
|
+
hits.push({
|
|
59
|
+
rootKey: String(root.key || ''),
|
|
60
|
+
shadowedBy: skill.shadowedBy && skill.shadowedBy.root ? String(skill.shadowedBy.root) : '',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const winner = hits.find((hit) => hit.shadowedBy === '') || hits[0];
|
|
65
|
+
return { ok: true, root: winner ? winner.rootKey : null, roots };
|
|
66
|
+
}
|