dsh-plugin-tool-management 0.8.0 → 0.9.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 +67 -1
- package/README.md +53 -48
- package/README_EN.md +47 -44
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +39 -0
- package/lib/agents-md/preset-id.js +1 -1
- package/lib/agents-md/service.js +46 -9
- package/lib/approval-policy.js +1 -1
- package/lib/client.js +431 -135
- package/lib/compat/preset-reach.js +155 -93
- package/lib/compat/probe.js +6 -4
- package/lib/context-inject.js +304 -0
- package/lib/hub.js +155 -13
- package/lib/imports/upload.js +34 -14
- package/lib/index.js +870 -226
- package/lib/mcp/state-section.js +46 -15
- package/lib/rules/archive-engine.js +182 -57
- package/lib/rules/archive.js +57 -1
- package/lib/rules/service.js +386 -156
- package/lib/scene-prompt-sync.js +101 -12
- package/lib/skills/catalog.js +125 -0
- package/lib/skills/core.js +38 -4
- package/lib/skills/service.js +36 -10
- package/lib/subagents/catalog.js +15 -5
- package/lib/subagents/service.js +39 -28
- package/lib/subagents/tools.js +18 -13
- package/package.json +14 -9
- package/screenshots.json +8 -8
- 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/lib/prompt-sections.js +0 -148
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// src/context-inject.ts —— 模型可见信息的注入通道(本插件唯一的注入实现)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么有这条通道(2026-09-16 用户裁定):
|
|
4
|
+
// 插件此前用 `systemPrompt.section()` 把信息送进系统提示词,但那条通道会被 persona
|
|
5
|
+
// `complete: true` 的预设整段压掉(极简等),模型什么都看不到;换成官方的
|
|
6
|
+
// `systemPrompt.context()` 也不行 —— 极简同时写着 `includeRuntimeContext: false`,
|
|
7
|
+
// 运行时上下文快照照样被压。
|
|
8
|
+
// 官方自己不受影响的三件事走的都是第三条路:**在每一步请求开始前,往本轮消息里插一条
|
|
9
|
+
// 合成的 user 消息**(`agent/pre-step` 瀑布)——
|
|
10
|
+
// · skill-catalog(@deepseek-ai/dsh-tool-skill)
|
|
11
|
+
// · ~/.dsh/AGENTS.md(@deepseek-ai/dsh-agent-instructions)
|
|
12
|
+
// · 时间上下文(@deepseek-ai/dsh-time-context)
|
|
13
|
+
// 这条通道没有任何预设开关能关掉,是"任何预设都到得了"的唯一选择。
|
|
14
|
+
//
|
|
15
|
+
// 形态(2026-09-16 第二版,用户裁定):**每个域一条自己的消息**,不再是一条大快照 ——
|
|
16
|
+
// 与官方 skill-catalog 同款:各自的来源 kind(轨迹里各自一行、各显各的名字)、各自的
|
|
17
|
+
// form、各自的去重。好处是"只改了一个域就只重发那一条";代价是进场景这类多域同时变的
|
|
18
|
+
// 时刻会一次发几条(每条带一句自己的引导语)。五个域与轨迹行名:
|
|
19
|
+
// memory → scene-memory-manager-catalog(场景和记忆)
|
|
20
|
+
// mcp → mcp-manager-catalog
|
|
21
|
+
// skills → skill-manager-catalog(不叫 skill-catalog:那是官方那条行的名字)
|
|
22
|
+
// subagents → subagent-manager-catalog
|
|
23
|
+
// prompt → prompt-manager-catalog
|
|
24
|
+
// 来源**不带 `plugin` 字段**:轨迹行标签的规则是「已知 kind 用自己的名字,插件来源用插件 id,
|
|
25
|
+
// 其余用裸 kind」,不带 plugin 才能让每行显示成自己的 kind(官方 agent-instructions /
|
|
26
|
+
// skill-catalog 同样只带 kind)。
|
|
27
|
+
//
|
|
28
|
+
// 本模块的实现口径:
|
|
29
|
+
// - 每个 step 同步算一遍各域文本(各域自带 SWR/stat 缓存,**绝不在这里读盘**);
|
|
30
|
+
// - 与会话**可见表面上**该域最新的己方消息逐字节比对:一样就不发(零 token、零重复),
|
|
31
|
+
// 不一样才追加一条新消息;被压缩移出表面后比对不到 → 自动重发(与 skill-catalog、
|
|
32
|
+
// agent-instructions 同款做法:两者都直接扫 `session.surface.nodes`);
|
|
33
|
+
// - 某域曾经注入过、现在没有内容(域被关掉或正文清空)→ 发一条该域的「已清空」,
|
|
34
|
+
// 免得旧目录继续被当成现状。
|
|
35
|
+
//
|
|
36
|
+
// 注入永远不能让这一步失败:任何异常都在监听器里吞掉、原样返回 decision。
|
|
37
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
38
|
+
import { SessionSeq } from '@deepseek-ai/dsh-session';
|
|
39
|
+
/**
|
|
40
|
+
* 权威域顺序(界面勾选、注入消息先后都按它)。
|
|
41
|
+
* 场景和记忆排第一:它是"当前模式"的框架,先给框架再给内容。
|
|
42
|
+
*/
|
|
43
|
+
export const INJECT_DOMAIN_KEYS = ['memory', 'mcp', 'skills', 'subagents', 'prompt'];
|
|
44
|
+
/**
|
|
45
|
+
* 域 → 消息来源 kind(轨迹行标签,也是去重时的身份)。
|
|
46
|
+
*
|
|
47
|
+
* 命名对齐官方 `*-catalog` 风格与本插件的工具族(`*_manager_*`);改名等于换身份,
|
|
48
|
+
* 旧消息会被当成"不在上下文里"而重发一次,所以这几个字符串是稳定契约。
|
|
49
|
+
*/
|
|
50
|
+
export const INJECT_KIND_OF = {
|
|
51
|
+
memory: 'scene-memory-manager-catalog',
|
|
52
|
+
mcp: 'mcp-manager-catalog',
|
|
53
|
+
skills: 'skill-manager-catalog',
|
|
54
|
+
subagents: 'subagent-manager-catalog',
|
|
55
|
+
prompt: 'prompt-manager-catalog',
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* 「官方已经送得到就别再送」的域 → 对应预设事实的键。
|
|
59
|
+
*
|
|
60
|
+
* 这两个域的内容官方也有:提示词(AGENTS.md 正文;场景期间换成场景提示词)由
|
|
61
|
+
* `dsh-agent-instructions` 连着文件一起送,技能目录由 `dsh-tool-skill` 送。预设挂得到官方
|
|
62
|
+
* 那条行时本插件不重复注入(否则同一段正文进上下文两次);挂不到(极简两行都没挂)时才
|
|
63
|
+
* 由本插件兜底 —— 于是"极简也能选择是否注入"成立,而普通预设下不会出现两份。
|
|
64
|
+
*/
|
|
65
|
+
const CARRIER_FACT_OF = {
|
|
66
|
+
prompt: 'carriesAgentsMd',
|
|
67
|
+
skills: 'carriesSkillCatalog',
|
|
68
|
+
};
|
|
69
|
+
export const DEFAULT_INJECT_SETTINGS = {
|
|
70
|
+
underSuppressingPresets: false,
|
|
71
|
+
domains: { memory: true, mcp: true, skills: true, subagents: true, prompt: true },
|
|
72
|
+
};
|
|
73
|
+
/** 把任意输入夹成合法设置(缺项/类型不对一律退回默认;默认从不阻止注入)。 */
|
|
74
|
+
export function normalizeInjectSettings(raw) {
|
|
75
|
+
const obj = (raw && typeof raw === 'object' ? raw : {});
|
|
76
|
+
const domainsRaw = (obj.domains && typeof obj.domains === 'object' ? obj.domains : {});
|
|
77
|
+
const domains = {};
|
|
78
|
+
for (const key of INJECT_DOMAIN_KEYS) {
|
|
79
|
+
domains[key] = typeof domainsRaw[key] === 'boolean' ? domainsRaw[key] : DEFAULT_INJECT_SETTINGS.domains[key];
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
underSuppressingPresets: typeof obj.underSuppressingPresets === 'boolean'
|
|
83
|
+
? obj.underSuppressingPresets
|
|
84
|
+
: DEFAULT_INJECT_SETTINGS.underSuppressingPresets,
|
|
85
|
+
domains,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 纯函数:按设置 + 预设事实挑出本次要注入的域。
|
|
90
|
+
*
|
|
91
|
+
* 规则(设计 2026-09-16):
|
|
92
|
+
* - 域开关关掉的、文本为空的,都不进;
|
|
93
|
+
* - **压制型预设**且没开「仍然注入」→ 一个都不进(跟随预设);
|
|
94
|
+
* - 有官方载体的两个域(提示词 / 技能目录):预设挂得到官方那条行时不进
|
|
95
|
+
* —— 官方自己会送,再注入一遍只会重复;预设事实读不到(`undefined`)时按"已承载"处理
|
|
96
|
+
* (宁可与现状一致,也不制造重复);
|
|
97
|
+
* - 同一段文本出现两次只保留前一个(跨域保险)。
|
|
98
|
+
*/
|
|
99
|
+
export function selectInjections(domains, settings, facts) {
|
|
100
|
+
const suppressing = facts !== undefined && facts.suppressing;
|
|
101
|
+
if (suppressing && !settings.underSuppressingPresets)
|
|
102
|
+
return [];
|
|
103
|
+
const out = [];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
for (const domain of domains) {
|
|
106
|
+
if (settings.domains[domain.key] !== true)
|
|
107
|
+
continue;
|
|
108
|
+
const carrier = CARRIER_FACT_OF[domain.key];
|
|
109
|
+
// 官方载体已挂(或读不到预设、只能按已挂算)→ 不重复送;只有明确"没挂"才兜底。
|
|
110
|
+
if (carrier !== undefined && facts?.[carrier] !== false)
|
|
111
|
+
continue;
|
|
112
|
+
let text = '';
|
|
113
|
+
try {
|
|
114
|
+
text = String(domain.text() ?? '');
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
text = '';
|
|
118
|
+
}
|
|
119
|
+
if (text.trim() === '')
|
|
120
|
+
continue;
|
|
121
|
+
const fingerprint = text.trim();
|
|
122
|
+
if (seen.has(fingerprint))
|
|
123
|
+
continue;
|
|
124
|
+
seen.add(fingerprint);
|
|
125
|
+
out.push({ key: domain.key, name: domain.name, label: domain.label, form: domain.form, text });
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 一条注入消息的正文(纯函数):instructions 域原样送(对齐官方 AGENTS.md 那条行),其余带一行引导语。
|
|
131
|
+
*
|
|
132
|
+
* 引导语只有**一句话**(用户裁定 2026-09-16:五个域各发各的以后,原来那四行说明在每条消息里
|
|
133
|
+
* 重复一遍太冗余):说清"这是什么 + 取代谁"就够 —— 域的名字、怎么查细节(`*_manager_list`
|
|
134
|
+
* 就在模型自己的工具表里)都不必在这里再说一遍。
|
|
135
|
+
*/
|
|
136
|
+
export function renderDomainText(section) {
|
|
137
|
+
if (section.form === 'instructions')
|
|
138
|
+
return section.text;
|
|
139
|
+
return [
|
|
140
|
+
'<system-reminder>',
|
|
141
|
+
`以下是本机插件的${section.label}(取代本次会话中更早的同类内容)。`,
|
|
142
|
+
'</system-reminder>',
|
|
143
|
+
].join('\n') + '\n\n' + section.text;
|
|
144
|
+
}
|
|
145
|
+
/** 「已清空」通知正文:某个域曾经注入过、现在没有内容时发一条(纯函数,测试用)。 */
|
|
146
|
+
export function clearedDomainText(key, label) {
|
|
147
|
+
return [
|
|
148
|
+
'<system-reminder>',
|
|
149
|
+
`dsh-plugin-tool-management:本插件的${label}已清空,本次会话中此前注入的同类内容不再有效。`,
|
|
150
|
+
'</system-reminder>',
|
|
151
|
+
].join('\n');
|
|
152
|
+
}
|
|
153
|
+
/** 取一条消息的正文(只认单 text 块;形如官方 RuntimeContextProjection.textOf)。 */
|
|
154
|
+
function textOf(message) {
|
|
155
|
+
const content = message?.content;
|
|
156
|
+
if (!Array.isArray(content) || content.length !== 1)
|
|
157
|
+
return undefined;
|
|
158
|
+
const block = content[0];
|
|
159
|
+
return block && block.type === 'text' && typeof block.text === 'string' ? block.text : undefined;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* 会话**可见表面上**每个域最新一条己方注入的正文。
|
|
163
|
+
*
|
|
164
|
+
* 可见表面 = `session.surface.nodes`(dsh-session 的公开面,官方 skill-catalog 与
|
|
165
|
+
* agent-instructions 都直接扫它):一条注入若已被压缩移出表面,说明它不在模型上下文里
|
|
166
|
+
* → 该域不在 map 里 → 该重发。表面接口整个缺失时退回扫事件流(按"仍可见"处理,
|
|
167
|
+
* 宁可与现状一致,也不制造每步重发)。
|
|
168
|
+
*/
|
|
169
|
+
function newestDomainTexts(agent) {
|
|
170
|
+
const out = new Map();
|
|
171
|
+
const session = agent?.session;
|
|
172
|
+
if (!session || typeof session.seq !== 'number' || typeof session.eventAt !== 'function')
|
|
173
|
+
return out;
|
|
174
|
+
const keyOfKind = new Map(INJECT_DOMAIN_KEYS.map((key) => [INJECT_KIND_OF[key], key]));
|
|
175
|
+
const scan = (seq) => {
|
|
176
|
+
let event;
|
|
177
|
+
try {
|
|
178
|
+
event = session.eventAt(SessionSeq(seq));
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
if (!event || event.type !== 'user/message')
|
|
184
|
+
return false;
|
|
185
|
+
const source = event.data && event.data.source;
|
|
186
|
+
const key = source && typeof source.kind === 'string' ? keyOfKind.get(source.kind) : undefined;
|
|
187
|
+
if (key === undefined || out.has(key))
|
|
188
|
+
return false;
|
|
189
|
+
const text = textOf(event.data);
|
|
190
|
+
if (text === undefined)
|
|
191
|
+
return false;
|
|
192
|
+
out.set(key, text);
|
|
193
|
+
return out.size === INJECT_DOMAIN_KEYS.length;
|
|
194
|
+
};
|
|
195
|
+
const nodes = session.surface && Array.isArray(session.surface.nodes) ? session.surface.nodes : undefined;
|
|
196
|
+
if (nodes !== undefined) {
|
|
197
|
+
const seqs = [];
|
|
198
|
+
for (const node of nodes) {
|
|
199
|
+
const n = Number(node);
|
|
200
|
+
if (Number.isFinite(n))
|
|
201
|
+
seqs.push(n);
|
|
202
|
+
}
|
|
203
|
+
seqs.sort((a, b) => b - a); // 从新到旧(表面自身的顺序不保证)
|
|
204
|
+
for (const seq of seqs)
|
|
205
|
+
if (scan(seq))
|
|
206
|
+
break;
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
for (let seq = session.seq - 1; seq >= 0; seq -= 1)
|
|
210
|
+
if (scan(seq))
|
|
211
|
+
break;
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* 注册 `agent/pre-step` 注入监听;返回清理函数。
|
|
216
|
+
*
|
|
217
|
+
* 宿主平面注册即可覆盖所有 agent(含子智能体、含任何预设)——dsh-scope 的
|
|
218
|
+
* `scopeTarget` 过滤对没有 scope 标记的 ctx 直接放行,官方 time-context 就是这么挂的。
|
|
219
|
+
*/
|
|
220
|
+
export function createContextInjector(deps) {
|
|
221
|
+
const log = (message) => {
|
|
222
|
+
try {
|
|
223
|
+
(deps.logger ?? ((m) => console.error('[dsh-plugin-tool-management] ' + m)))(message);
|
|
224
|
+
}
|
|
225
|
+
catch { /* ignore */ }
|
|
226
|
+
};
|
|
227
|
+
const ctx = deps.ctx;
|
|
228
|
+
if (!ctx || typeof ctx.on !== 'function')
|
|
229
|
+
return { dispose: () => { } };
|
|
230
|
+
const stop = ctx.on('agent/pre-step', async (payload, next) => {
|
|
231
|
+
const decision = await next();
|
|
232
|
+
try {
|
|
233
|
+
if (!decision || decision.kind === 'reject')
|
|
234
|
+
return decision;
|
|
235
|
+
const agent = payload && payload.agent;
|
|
236
|
+
if (!agent)
|
|
237
|
+
return decision;
|
|
238
|
+
// 空 turn 不注入(官方 dsh-agent-instructions 同款守卫):step 1 且一条消息都没有时,
|
|
239
|
+
// 这一步本来就该原地结束(宿主随后把 turn 判为 completed)。此时注入会把空 turn
|
|
240
|
+
// 变成一次真实的模型请求 —— 凭空烧一次调用。
|
|
241
|
+
if (Number(payload.step) === 1 && messagesOf(decision).length === 0)
|
|
242
|
+
return decision;
|
|
243
|
+
const domains = deps.domains();
|
|
244
|
+
const sections = selectInjections(domains, deps.settings(), await deps.factsFor(agent));
|
|
245
|
+
const visible = newestDomainTexts(agent);
|
|
246
|
+
const additions = [];
|
|
247
|
+
const published = new Set();
|
|
248
|
+
for (const section of sections) {
|
|
249
|
+
published.add(section.key);
|
|
250
|
+
const text = renderDomainText(section);
|
|
251
|
+
if (visible.get(section.key) === text)
|
|
252
|
+
continue;
|
|
253
|
+
additions.push(domainMessage(section, text));
|
|
254
|
+
}
|
|
255
|
+
// 曾经注入过、这一轮没有内容的域 → 一条「已清空」;从没注入过的域什么都不用说。
|
|
256
|
+
const labelOf = new Map(domains.map((domain) => [domain.key, domain.label]));
|
|
257
|
+
for (const key of INJECT_DOMAIN_KEYS) {
|
|
258
|
+
if (published.has(key))
|
|
259
|
+
continue;
|
|
260
|
+
const previous = visible.get(key);
|
|
261
|
+
if (previous === undefined)
|
|
262
|
+
continue;
|
|
263
|
+
const label = labelOf.get(key) ?? key;
|
|
264
|
+
if (previous === clearedDomainText(key, label))
|
|
265
|
+
continue;
|
|
266
|
+
additions.push(clearedMessage(key, label));
|
|
267
|
+
}
|
|
268
|
+
if (additions.length === 0)
|
|
269
|
+
return decision;
|
|
270
|
+
return { ...decision, messages: [...messagesOf(decision), ...additions] };
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
// 注入是尽力而为:任何异常都不能把这一步弄失败。
|
|
274
|
+
log('context injection failed: ' + String((error && error.message) || error));
|
|
275
|
+
return decision;
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
return {
|
|
279
|
+
dispose: () => { try {
|
|
280
|
+
if (typeof stop === 'function')
|
|
281
|
+
stop();
|
|
282
|
+
}
|
|
283
|
+
catch { /* ignore */ } },
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function messagesOf(decision) {
|
|
287
|
+
const messages = decision && decision.messages;
|
|
288
|
+
return Array.isArray(messages) ? messages : [];
|
|
289
|
+
}
|
|
290
|
+
function domainMessage(section, text) {
|
|
291
|
+
const source = { kind: INJECT_KIND_OF[section.key], form: section.form };
|
|
292
|
+
if (section.form === 'snapshot')
|
|
293
|
+
source.sections = [{ name: section.name, text: section.text }];
|
|
294
|
+
return createUserMessage({
|
|
295
|
+
content: [{ type: 'text', text }],
|
|
296
|
+
source,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
function clearedMessage(key, label) {
|
|
300
|
+
return createUserMessage({
|
|
301
|
+
content: [{ type: 'text', text: clearedDomainText(key, label) }],
|
|
302
|
+
source: { kind: INJECT_KIND_OF[key], form: 'notice', summary: `${label}已清空` },
|
|
303
|
+
});
|
|
304
|
+
}
|
package/lib/hub.js
CHANGED
|
@@ -1,23 +1,33 @@
|
|
|
1
|
-
// dsh-plugin-tool-management —— 统一数据目录(v0.4「一个 hub
|
|
1
|
+
// dsh-plugin-tool-management —— 统一数据目录(v0.4「一个 hub」,v0.9.0 名称按域对齐)。
|
|
2
2
|
//
|
|
3
3
|
// 用户在 2026-09-13 提出:插件产生的文件夹/文件全部收在 `$DSH_HOME/tool-management/`
|
|
4
4
|
// 一个目录里,方便统一备份与查看。此前它们散在四处:
|
|
5
5
|
//
|
|
6
6
|
// $DSH_HOME/scene-memory/ 记忆真源 → tool-management/memories/
|
|
7
7
|
// $DSH_HOME/tool-management/ 侧车(索引/回收站) → 就地(hub 根)
|
|
8
|
-
// $DSH_HOME/subagents/ 人设 → tool-management/
|
|
9
|
-
// <插件目录>/data/agents-md-presets/ AGENTS.md 预设库 → tool-management/
|
|
8
|
+
// $DSH_HOME/subagents/ 人设 → tool-management/subagents/
|
|
9
|
+
// <插件目录>/data/agents-md-presets/ AGENTS.md 预设库 → tool-management/prompts/
|
|
10
10
|
//
|
|
11
|
-
//
|
|
11
|
+
// 2026-09-16 用户又提出两点:① 根目录下还漏着几个以插件名开头的侧车与日志(`$DSH_HOME/
|
|
12
|
+
// dsh-plugin-tool-management-*.json` / `.log`)+ 改 MCP 配置前的 patch 备份,都该收进 hub;
|
|
13
|
+
// ② hub 内的名字要与**当前代码的域命名**一致(界面「提示词」+ 工具 `prompt_manager_*`,
|
|
14
|
+
// 目录却叫 `agents-md`;域早就叫「记忆」,索引还叫 `rules-index.json`)。于是统一为:
|
|
12
15
|
//
|
|
13
16
|
// ~/.dsh/tool-management/
|
|
14
|
-
// ├─ memories/<场景>/<名>.md |
|
|
15
|
-
// ├─
|
|
16
|
-
// ├─
|
|
17
|
-
// ├─ skills/
|
|
18
|
-
// ├─
|
|
19
|
-
// ├─
|
|
20
|
-
//
|
|
17
|
+
// ├─ memories/<场景>/<名>.md | <场景>/<名>/<名>.md 记忆正文(真源)
|
|
18
|
+
// ├─ subagents/<人设>.md 子智能体人设
|
|
19
|
+
// ├─ prompts/<预设 id>/AGENTS.md 提示词预设库(全局指令基线)
|
|
20
|
+
// ├─ skills/ 插件新建/导入的技能(可写根)
|
|
21
|
+
// ├─ memories-index.json 记忆启停/顺序 + 场景记录/档案/模式
|
|
22
|
+
// ├─ subagents-index.json 人设启停集合
|
|
23
|
+
// ├─ skills-state.json 技能启停 / 首选 / 自定义目录
|
|
24
|
+
// ├─ mcp-disabled-tools.json | mcp-known-tools.json | mcp-notes.json | mcp-settings.json | mcp-export.json
|
|
25
|
+
// ├─ tool-management.log 插件日志(滚动 .1)
|
|
26
|
+
// ├─ backups/cordis.patch.yml.bak-<时间戳> 改宿主 patch 前的备份
|
|
27
|
+
// └─ trash/{skills,subagents,prompts,scenes,memories}-trash/<id>/
|
|
28
|
+
//
|
|
29
|
+
// 留在 `$DSH_HOME` 根下的两个文件**不是**插件的:`AGENTS.md`(宿主每轮读取的全局基线,
|
|
30
|
+
// 插件只是按场景/预设写它)与 `cordis.patch.yml`(宿主加载插件的配置入口)。
|
|
21
31
|
//
|
|
22
32
|
// 搬运纪律(三条,全部为了「不丢数据」):
|
|
23
33
|
// 1. **只搬不删**——源目录里的文件被 `rename` 走后,空壳目录保留;
|
|
@@ -25,8 +35,9 @@
|
|
|
25
35
|
// 2. **绝不覆盖**——目标已存在同名项时跳过该项,保留目标。
|
|
26
36
|
// 3. **每进程一次**——迁移是启动期的一次性动作,失败也不阻断服务(旧目录仍可读)。
|
|
27
37
|
import { cp, lstat, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
38
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs';
|
|
28
39
|
import { randomUUID } from 'node:crypto';
|
|
29
|
-
import {
|
|
40
|
+
import { basename, dirname, join } from 'node:path';
|
|
30
41
|
import { resolveDshHome } from './skills/core.js';
|
|
31
42
|
/** hub 根目录名(`$DSH_HOME` 下)。 */
|
|
32
43
|
export const HUB_DIR = 'tool-management';
|
|
@@ -38,6 +49,127 @@ export function hubRoot() {
|
|
|
38
49
|
export function hubPath(...segments) {
|
|
39
50
|
return join(hubRoot(), ...segments);
|
|
40
51
|
}
|
|
52
|
+
/** patch 备份目录(`hub/backups/`):宿主 patch 的 `.bak-<时间戳>` 都收在这里。 */
|
|
53
|
+
export function hubBackupDir(home = resolveDshHome()) {
|
|
54
|
+
return join(home, HUB_DIR, 'backups');
|
|
55
|
+
}
|
|
56
|
+
// ── 一次性布局迁移(v0.9.0:根目录收编 + 按域改名)────────────────────────────
|
|
57
|
+
//
|
|
58
|
+
// 旧名 → 新名(hub 内相对路径)。`readIndex` 之类的读取端只用新名,所以迁移必须在任何
|
|
59
|
+
// 读取之前完成 —— 由 `apply()` 在启动期同步跑一次(见 index.ts),不参与并发。
|
|
60
|
+
const HUB_RENAMES = [
|
|
61
|
+
['agents-md', 'prompts'],
|
|
62
|
+
['rules-index.json', 'memories-index.json'],
|
|
63
|
+
['rules-trash', 'memories-trash'],
|
|
64
|
+
['agents', 'subagents'],
|
|
65
|
+
['agents-index.json', 'subagents-index.json'],
|
|
66
|
+
['state.json', 'skills-state.json'],
|
|
67
|
+
// 回收站分类目录(`trash/<域>-trash/`)
|
|
68
|
+
['trash/agents-md-trash', 'trash/prompts-trash'],
|
|
69
|
+
['trash/agents-trash', 'trash/subagents-trash'],
|
|
70
|
+
];
|
|
71
|
+
/** `$DSH_HOME` 根下由插件产生、要收进 hub 的文件(旧名 → hub 内新名)。 */
|
|
72
|
+
const ROOT_FILE_MOVES = [
|
|
73
|
+
['dsh-plugin-tool-management-disabled-tools.json', 'mcp-disabled-tools.json'],
|
|
74
|
+
['dsh-plugin-tool-management-mcp-tools.json', 'mcp-known-tools.json'],
|
|
75
|
+
['dsh-plugin-tool-management-notes.json', 'mcp-notes.json'],
|
|
76
|
+
['dsh-plugin-tool-management-settings.json', 'mcp-settings.json'],
|
|
77
|
+
['dsh-plugin-tool-management-export.json', 'mcp-export.json'],
|
|
78
|
+
['dsh-plugin-tool-management.log', 'tool-management.log'],
|
|
79
|
+
['dsh-plugin-tool-management.log.1', 'tool-management.log.1'],
|
|
80
|
+
// 更早的前缀(插件曾叫 dsh-skill-mcp-manager)
|
|
81
|
+
['skill-mcp-manager-disabled-tools.json', 'mcp-disabled-tools.json'],
|
|
82
|
+
['skill-mcp-manager-notes.json', 'mcp-notes.json'],
|
|
83
|
+
['skill-mcp-manager-settings.json', 'mcp-settings.json'],
|
|
84
|
+
['skill-mcp-manager-export.json', 'mcp-export.json'],
|
|
85
|
+
];
|
|
86
|
+
/** 宿主 patch 的备份名(`cordis.patch.yml.bak-<时间戳>`)。 */
|
|
87
|
+
export function isPatchBackupName(name) {
|
|
88
|
+
return /^cordis\.patch\.yml\.bak-\d{8}-\d{6}$/.test(basename(name));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 布局迁移(同步、幂等、只搬不删、绝不覆盖)。**必须在任何读盘之前调用一次**:
|
|
92
|
+
* ① hub 内旧名 → 新名(`agents-md/` → `prompts/` 等);
|
|
93
|
+
* ② `$DSH_HOME` 根下的插件侧车 / 日志 → hub(原来它们躺在宿主目录里);
|
|
94
|
+
* ③ 宿主 patch 的 `.bak-*` → `hub/backups/`;
|
|
95
|
+
* ④ 技能回收站条目 `trash/<id>/` → `trash/skills-trash/<id>/`(与其余四类的命名对齐)。
|
|
96
|
+
* 任一步失败只跳过该项(下次启动再试),绝不抛错、绝不删除源数据。
|
|
97
|
+
*/
|
|
98
|
+
export function migrateHubLayoutSync(home = resolveDshHome()) {
|
|
99
|
+
const hubDir = join(home, HUB_DIR);
|
|
100
|
+
const move = (from, to) => {
|
|
101
|
+
try {
|
|
102
|
+
if (from === to || !existsSync(from) || existsSync(to))
|
|
103
|
+
return false;
|
|
104
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
105
|
+
try {
|
|
106
|
+
renameSync(from, to);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
cpSync(from, to, { recursive: true, force: true });
|
|
110
|
+
rmSync(from, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false; // 单项失败不阻断其余项(文件被占用 → 下次启动再试)
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
// ① 更早的 hub 目录名(插件叫 dsh-skill-mcp-manager 的时期):**先**逐项并入 hub,
|
|
119
|
+
// 让下面 ② 的改名也覆盖从旧目录搬进来的那些(同名保留 hub 里已有的那份)。
|
|
120
|
+
for (const legacyHub of ['dsh-plugin-tool-management', 'skill-mcp-manager']) {
|
|
121
|
+
const from = join(home, legacyHub);
|
|
122
|
+
try {
|
|
123
|
+
if (!existsSync(from))
|
|
124
|
+
continue;
|
|
125
|
+
for (const name of readdirSync(from))
|
|
126
|
+
move(join(from, name), join(hubDir, name));
|
|
127
|
+
}
|
|
128
|
+
catch { /* 列举失败:下次启动再试 */ }
|
|
129
|
+
}
|
|
130
|
+
// ② hub 内旧名 → 新名
|
|
131
|
+
for (const [oldName, newName] of HUB_RENAMES)
|
|
132
|
+
move(join(hubDir, oldName), join(hubDir, newName));
|
|
133
|
+
// ③ `$DSH_HOME` 根下的插件侧车 / 日志 → hub
|
|
134
|
+
for (const [oldName, newName] of ROOT_FILE_MOVES)
|
|
135
|
+
move(join(home, oldName), join(hubDir, newName));
|
|
136
|
+
// 技能回收站:`trash/` 直属的条目目录(其余 `<域>-trash/` 已在上面搬过)。
|
|
137
|
+
try {
|
|
138
|
+
const trashDir = join(hubDir, 'trash');
|
|
139
|
+
if (existsSync(trashDir)) {
|
|
140
|
+
for (const entry of readdirSync(trashDir, { withFileTypes: true })) {
|
|
141
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.endsWith('-trash'))
|
|
142
|
+
continue;
|
|
143
|
+
move(join(trashDir, entry.name), join(trashDir, 'skills-trash', entry.name));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch { /* 列举失败:下次启动再试 */ }
|
|
148
|
+
// 宿主 patch 备份:根下的直接搬;`profiles/<名字>/` 下的带上层级后缀(否则与全局撞名)。
|
|
149
|
+
try {
|
|
150
|
+
const backups = hubBackupDir(home);
|
|
151
|
+
for (const name of existsSync(home) ? readdirSync(home) : []) {
|
|
152
|
+
if (isPatchBackupName(name))
|
|
153
|
+
move(join(home, name), join(backups, name));
|
|
154
|
+
}
|
|
155
|
+
const profilesDir = join(home, 'profiles');
|
|
156
|
+
if (existsSync(profilesDir)) {
|
|
157
|
+
for (const prof of readdirSync(profilesDir)) {
|
|
158
|
+
const dir = join(profilesDir, prof);
|
|
159
|
+
try {
|
|
160
|
+
for (const name of readdirSync(dir)) {
|
|
161
|
+
if (!isPatchBackupName(name))
|
|
162
|
+
continue;
|
|
163
|
+
const tag = 'profile-' + prof.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
164
|
+
move(join(dir, name), join(backups, name.replace(/^cordis\.patch\.yml/, 'cordis.patch.yml.' + tag)));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch { /* 单个 profile 目录读不动就跳过 */ }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch { /* 同上 */ }
|
|
172
|
+
}
|
|
41
173
|
/** 路径是否存在(不跟随符号链接)。 */
|
|
42
174
|
export async function pathExists(path) {
|
|
43
175
|
try {
|
|
@@ -93,6 +225,15 @@ export async function relocateEntries(from, to, filter) {
|
|
|
93
225
|
}
|
|
94
226
|
return moved;
|
|
95
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* 老 manifest 里写着的 kind 值(v0.9.0 按域改名前的):读盘时一并认,否则升级后
|
|
230
|
+
* 老条目会「列不出来、恢复不了」——目录搬对了名字,manifest 里的字段值还在说旧名。
|
|
231
|
+
*/
|
|
232
|
+
const LEGACY_TRASH_KINDS = {
|
|
233
|
+
prompts: ['agents-md'],
|
|
234
|
+
subagents: ['agents'],
|
|
235
|
+
scenes: [],
|
|
236
|
+
};
|
|
96
237
|
/** 某一类回收站的根目录:`hub/trash/<kind>-trash`。 */
|
|
97
238
|
export function trashKindDir(kind) {
|
|
98
239
|
return hubPath('trash', `${kind}-trash`);
|
|
@@ -119,7 +260,8 @@ async function readManifest(kind, id) {
|
|
|
119
260
|
const parsed = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8'));
|
|
120
261
|
if (parsed === null || typeof parsed !== 'object')
|
|
121
262
|
return null;
|
|
122
|
-
|
|
263
|
+
const accepted = [kind, ...LEGACY_TRASH_KINDS[kind]];
|
|
264
|
+
if (!accepted.includes(String(parsed.kind)) || String(parsed.id) !== id)
|
|
123
265
|
return null;
|
|
124
266
|
return {
|
|
125
267
|
v: Number(parsed.v) || 1,
|
package/lib/imports/upload.js
CHANGED
|
@@ -166,8 +166,9 @@ const MAX_BUNDLE_ATTACH_TOTAL = 16 * 1024 * 1024;
|
|
|
166
166
|
/**
|
|
167
167
|
* 记忆落点:
|
|
168
168
|
* - 裸 `.md` → 落到 defaultScene(空串 = 全局:任何对话都注入);zip 内带目录 → 目录路径即场景/分组。
|
|
169
|
-
* - zip 内
|
|
170
|
-
* 文件作为附件一并带入(`.md` 不带——bundle
|
|
169
|
+
* - zip 内 `<场景路径>/<名>/<名>.md` → bundle 记忆(目录末段是记忆名,正文文件名与目录名一致,
|
|
170
|
+
* 其余前缀是场景);同层非 `.md` 文件作为附件一并带入(`.md` 不带——bundle 是叶子,塞进去不会被发现,
|
|
171
|
+
* 静默降级反而误导)。旧导出包里正文可能仍叫 `SKILL.md`,也认(只读兼容)。
|
|
171
172
|
* - zip 根层的裸 `SKILL.md` 没有目录名可当记忆名 → 跳过并回报;`SKILL.md` 的大小写变体也跳过
|
|
172
173
|
* (发现层只认精确 `SKILL.md`,NTFS 大小写不敏感下两者不能共存)。
|
|
173
174
|
*/
|
|
@@ -180,18 +181,39 @@ export function planMemoryImport(entries, defaultScene) {
|
|
|
180
181
|
const idx = path.lastIndexOf('/');
|
|
181
182
|
return { dir: idx >= 0 ? path.slice(0, idx) : '', base: path.slice(idx + 1) };
|
|
182
183
|
};
|
|
183
|
-
// pass 1:认 bundle
|
|
184
|
+
// pass 1:认 bundle 目录(`<目录名>.md` 是正文;旧导出包的 `SKILL.md` 也认,只读兼容)。
|
|
184
185
|
const bundleDocs = new Map();
|
|
186
|
+
const consumed = new Set(); // 已被 pass 1 认领(或已回报)的条目路径,pass 3 不再当 flat 处理
|
|
185
187
|
for (const entry of entries) {
|
|
186
188
|
const { dir, base } = split(entry.path);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (
|
|
189
|
+
const dirName = dir === '' ? '' : dir.slice(dir.lastIndexOf('/') + 1);
|
|
190
|
+
const isLegacyCaseVariant = base.toLowerCase() === 'skill.md' && base !== 'SKILL.md';
|
|
191
|
+
if (isLegacyCaseVariant) {
|
|
190
192
|
problems.push({ name: entry.path, reason: 'SKILL.md 大小写变体不导入(发现层只认精确 SKILL.md,且与 NTFS 大小写不敏感冲突),已跳过' });
|
|
193
|
+
consumed.add(entry.path);
|
|
191
194
|
continue;
|
|
192
195
|
}
|
|
193
|
-
|
|
196
|
+
// 一级目录恒为场景(与发现层 `discover`/`probeSceneFilesSync` 同口径):`1/1.md` 是
|
|
197
|
+
// 「场景 1 的记忆 1」的 flat 正文,**不是**根层 bundle「1」——bundle 只认目录至少两段
|
|
198
|
+
// (`<场景>/<名>/<名>.md`)。认错会写出发现层根本读不到、且会把整个场景目录吃成叶子的形态。
|
|
199
|
+
const isRootSkill = base === 'SKILL.md' && dir === '';
|
|
200
|
+
if (isRootSkill) {
|
|
201
|
+
// zip 根层的裸 SKILL.md:没有目录名可作记忆名。
|
|
194
202
|
problems.push({ name: entry.path, reason: 'SKILL.md 在 zip 根层,没有目录名可作记忆名,已跳过' });
|
|
203
|
+
consumed.add(entry.path);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const isLegacyDoc = base === 'SKILL.md' && dir.includes('/');
|
|
207
|
+
const isNamedDoc = dir.includes('/') && base === `${dirName}.md`;
|
|
208
|
+
if (!isLegacyDoc && !isNamedDoc)
|
|
209
|
+
continue;
|
|
210
|
+
consumed.add(entry.path);
|
|
211
|
+
const existing = bundleDocs.get(dir);
|
|
212
|
+
if (existing) {
|
|
213
|
+
// 同一目录里同时出现 `<目录名>.md` 与旧的 `SKILL.md`(少见):`<目录名>.md` 优先。
|
|
214
|
+
const { base: existingBase } = split(existing.path);
|
|
215
|
+
if (isNamedDoc && existingBase !== `${dirName}.md`)
|
|
216
|
+
bundleDocs.set(dir, entry);
|
|
195
217
|
continue;
|
|
196
218
|
}
|
|
197
219
|
bundleDocs.set(dir, entry);
|
|
@@ -206,7 +228,7 @@ export function planMemoryImport(entries, defaultScene) {
|
|
|
206
228
|
if (!bundleDir)
|
|
207
229
|
continue;
|
|
208
230
|
if (dir !== bundleDir) {
|
|
209
|
-
problems.push({ name: entry.path, reason: 'bundle
|
|
231
|
+
problems.push({ name: entry.path, reason: 'bundle 只支持一层附件(正文同层),子目录条目已跳过' });
|
|
210
232
|
continue;
|
|
211
233
|
}
|
|
212
234
|
if (!entry.bytes.length) {
|
|
@@ -231,7 +253,7 @@ export function planMemoryImport(entries, defaultScene) {
|
|
|
231
253
|
list.push({ name, bytes: entry.bytes });
|
|
232
254
|
attachments.set(bundleDir, list);
|
|
233
255
|
}
|
|
234
|
-
// pass 3:先规划 bundle
|
|
256
|
+
// pass 3:先规划 bundle 目标(含正文同层 .md 的明确跳过),再走 flat。
|
|
235
257
|
for (const [dir, doc] of bundleDocs) {
|
|
236
258
|
const segs = dir.split('/');
|
|
237
259
|
const name = segs[segs.length - 1];
|
|
@@ -256,12 +278,10 @@ export function planMemoryImport(entries, defaultScene) {
|
|
|
256
278
|
const { dir, base } = split(entry.path);
|
|
257
279
|
if (!base.toLowerCase().endsWith('.md'))
|
|
258
280
|
continue;
|
|
259
|
-
if (
|
|
260
|
-
continue;
|
|
261
|
-
if (base.toLowerCase() === 'skill.md')
|
|
262
|
-
continue; // pass 1 已回报
|
|
281
|
+
if (consumed.has(entry.path))
|
|
282
|
+
continue; // pass 1 已认领为 bundle 正文,或已回报为大小写变体/根层裸 SKILL.md
|
|
263
283
|
if ([...bundleDocs.keys()].some((d) => dir === d || dir.startsWith(d + '/'))) {
|
|
264
|
-
problems.push({ name: entry.path, reason: 'bundle
|
|
284
|
+
problems.push({ name: entry.path, reason: 'bundle 目录内的非正文 .md 不导入(bundle 是叶子,放进去不会被发现),已跳过' });
|
|
265
285
|
continue;
|
|
266
286
|
}
|
|
267
287
|
const name = base.slice(0, -3); // 去掉 .md
|