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.
Files changed (49) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +185 -238
  3. package/README_EN.md +185 -304
  4. package/cordis.patch.yml +9 -55
  5. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  6. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  7. package/docs/images/2MCP.png +0 -0
  8. package/docs/images/2MCP_en.png +0 -0
  9. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  10. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  11. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  12. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  13. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  14. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  15. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  16. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  17. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  18. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  19. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  20. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  21. package/docs/update.md +55 -0
  22. package/lib/agents-md/preset-id.js +49 -0
  23. package/lib/agents-md/service.js +180 -57
  24. package/lib/client.js +4710 -3560
  25. package/lib/compat/preset-reach.js +425 -0
  26. package/lib/compat/probe.js +665 -0
  27. package/lib/history/bridge.js +293 -0
  28. package/lib/history/workspace.js +498 -52
  29. package/lib/http-fence.js +94 -0
  30. package/lib/hub.js +160 -2
  31. package/lib/index.js +759 -157
  32. package/lib/mcp/override-blocks.js +195 -0
  33. package/lib/rules/provider.js +3 -3
  34. package/lib/rules/service.js +342 -31
  35. package/lib/scene-prompt-sync.js +112 -0
  36. package/lib/skills/core.js +123 -35
  37. package/lib/skills/service.js +9 -1
  38. package/lib/subagents/service.js +197 -23
  39. package/lib/subagents/tools.js +8 -2
  40. package/package.json +6 -3
  41. package/screenshots.json +10 -9
  42. package/docs/Changelog.md +0 -517
  43. package/docs/images/MCP.png +0 -0
  44. package/docs/images//344/274/232/350/257/235.png +0 -0
  45. package/docs/images//345/234/272/346/231/257.png +0 -0
  46. package/docs/images//345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  47. package/docs/images//346/212/200/350/203/275.png +0 -0
  48. package/docs/images//346/217/220/347/244/272/350/257/215.png +0 -0
  49. package/docs/images//350/256/260/345/277/206.png +0 -0
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Agent-preset injection reachability — the read-only answer to
3
+ * "does what this plugin injects actually reach the model under the preset
4
+ * this session runs?".
5
+ *
6
+ * WHY THIS EXISTS
7
+ * ---------------
8
+ * `rules/provider.ts` registers a per-agent `systemPrompt` section, and the
9
+ * registration SUCCEEDS under every preset. But `@deepseek-ai/dsh-persona`
10
+ * with `complete: true` makes the prompt registry restore its prefix as the
11
+ * ONLY section at assembly time, so the section never reaches the model — and
12
+ * the plugin had no way to notice. The memories page kept reporting
13
+ * "injected" while the model saw nothing.
14
+ *
15
+ * WHAT IS MEASURED, AND HOW
16
+ * -------------------------
17
+ * Answers come from each preset's own composition text, read through the
18
+ * roster's public `read(id)` — evidence, not inference, and no mount:
19
+ *
20
+ * - `personaComplete`: does the preset mount `@deepseek-ai/dsh-persona`
21
+ * with `complete: true`? (that flag suppresses EVERY other prompt section)
22
+ * - `agentInstructions`: is `@deepseek-ai/dsh-agent-instructions` mounted?
23
+ * (the row that carries `~/.dsh/AGENTS.md` into the prompt)
24
+ * - `toolSkill`: is `@deepseek-ai/dsh-tool-skill` mounted?
25
+ * (the row that gives the model the skill catalog)
26
+ * - `subagentTool`: is `@deepseek-ai/dsh-tool-subagent` mounted?
27
+ * (the official delegation tool; the shipped `minimal` preset has none)
28
+ * - `mcpClient`: does the composition mount its own MCP client?
29
+ * (the shipped presets mount none, so MCP comes from the host plane and is
30
+ * equally available under every preset — see `ReachContext.mcpTools`)
31
+ *
32
+ * The plugin's own 14 model tools need NO probe: they register in the HOST
33
+ * plane (this plugin's loader row in the profile patch), so every preset's
34
+ * session resolves them. Verified against the shipped `minimal` preset on
35
+ * 2026-09-14: tools callable, skill catalog absent, memory and AGENTS.md
36
+ * injection suppressed.
37
+ *
38
+ * Reading never mounts. `list()`/`read(id)` are roster reads, so building the
39
+ * matrix cannot activate a preset early — the same guarantee
40
+ * `compositionInventory()` gives the plugin-listing surfaces.
41
+ *
42
+ * This module is deliberately advisory: it never changes what is injected and
43
+ * never refuses an operation. A preset that suppresses prompt text is doing
44
+ * exactly what its author asked for; the plugin's job is to say so out loud
45
+ * instead of reporting a success the model cannot observe.
46
+ */
47
+ /** Module specifiers whose mounting decides reachability. */
48
+ const PERSONA_MODULE = '@deepseek-ai/dsh-persona';
49
+ const AGENT_INSTRUCTIONS_MODULE = '@deepseek-ai/dsh-agent-instructions';
50
+ const TOOL_SKILL_MODULE = '@deepseek-ai/dsh-tool-skill';
51
+ /**
52
+ * The OFFICIAL delegation tool. Absent in the shipped `minimal` preset, present
53
+ * in the other three — the one column whose answer really does differ per
54
+ * preset. (This plugin's own `subagent_list`/`subagent_run` are host-plane and
55
+ * therefore preset-independent; the page's footer says so.)
56
+ */
57
+ const SUBAGENT_TOOL_MODULE = '@deepseek-ai/dsh-tool-subagent';
58
+ /**
59
+ * An MCP client mounted INSIDE a composition. The shipped presets mount none,
60
+ * so MCP normally comes from the host plane (the `$DSH_HOME/cordis.patch.yml`
61
+ * layer) and works under every preset; a user-authored preset that mounts its
62
+ * own client only carries MCP under itself, and this scan is how the page can
63
+ * tell the two apart.
64
+ */
65
+ const MCP_CLIENT_MODULE = '@deepseek-ai/dsh-mcp-client';
66
+ const NAME_LINE = /^(\s*)name:\s*(['"]?)([^'"\s#]+)\2\s*(?:#.*)?$/;
67
+ const ENTRY_LINE = /^(\s*)-\s/;
68
+ const DISABLED_LINE = /^\s*disabled:\s*(.+?)\s*(?:#.*)?$/;
69
+ const COMPLETE_LINE = /^\s*complete:\s*(true|false)\s*(?:#.*)?$/;
70
+ /** Leading whitespace width of a line, used as its nesting level. */
71
+ function indentOf(line) {
72
+ const match = /^(\s*)/.exec(line);
73
+ return match === null ? 0 : match[1].length;
74
+ }
75
+ /**
76
+ * The lines belonging to the composition row that owns `nameIndex`.
77
+ *
78
+ * The row starts at the nearest entry bullet above the `name:` line that is
79
+ * less indented than it (a nested row inside a `cordis:group` still gets its
80
+ * own block) and ends at the next bullet at or above that bullet's indent.
81
+ */
82
+ function rowBlockAround(lines, nameIndex) {
83
+ const nameIndent = indentOf(lines[nameIndex]);
84
+ let start = nameIndex;
85
+ for (let i = nameIndex - 1; i >= 0; i -= 1) {
86
+ const entry = ENTRY_LINE.exec(lines[i]);
87
+ if (entry !== null && entry[1].length < nameIndent) {
88
+ start = i;
89
+ break;
90
+ }
91
+ }
92
+ const entryIndent = indentOf(lines[start]);
93
+ let end = lines.length;
94
+ for (let i = start + 1; i < lines.length; i += 1) {
95
+ const entry = ENTRY_LINE.exec(lines[i]);
96
+ if (entry !== null && entry[1].length <= entryIndent) {
97
+ end = i;
98
+ break;
99
+ }
100
+ }
101
+ return lines.slice(start, end);
102
+ }
103
+ /**
104
+ * How the row owning `nameIndex` decides enablement.
105
+ *
106
+ * `!!js` expressions are only answerable inside a mount, so they stay
107
+ * `'conditional'` rather than being guessed — the same rule
108
+ * `compositionInventory()` applies to its disabled gates.
109
+ */
110
+ function presenceForRow(lines, nameIndex) {
111
+ for (const line of rowBlockAround(lines, nameIndex)) {
112
+ const disabled = DISABLED_LINE.exec(line);
113
+ if (disabled === null)
114
+ continue;
115
+ const value = disabled[1].trim();
116
+ if (value === 'true')
117
+ return 'absent';
118
+ if (value.startsWith('!!js'))
119
+ return 'conditional';
120
+ return 'mounted';
121
+ }
122
+ return 'mounted';
123
+ }
124
+ /** Whether ANY row naming `specifier` survives into the composition. */
125
+ function presenceOf(lines, specifier) {
126
+ let best = 'absent';
127
+ for (let i = 0; i < lines.length; i += 1) {
128
+ const named = NAME_LINE.exec(lines[i]);
129
+ if (named === null || named[3] !== specifier)
130
+ continue;
131
+ const presence = presenceForRow(lines, i);
132
+ if (presence === 'mounted')
133
+ return 'mounted';
134
+ if (presence === 'conditional')
135
+ best = 'conditional';
136
+ }
137
+ return best;
138
+ }
139
+ /**
140
+ * Parse the facts that decide reachability out of one composition's text.
141
+ *
142
+ * Deliberately a text scan rather than a YAML load: this module must not add
143
+ * a parser dependency, and every question it asks is answered by a row's own
144
+ * key/value lines. A `complete: true` nested inside a multi-line scalar cannot
145
+ * produce a false positive because the match is anchored to a whole line.
146
+ *
147
+ * Never throws: unparsable input degrades to `unknown`, which the caller
148
+ * renders as "could not tell" instead of "fine".
149
+ */
150
+ export function readCompositionFacts(text) {
151
+ try {
152
+ const lines = String(text ?? '').split(/\r?\n/);
153
+ if (lines.length <= 1 && lines[0] === '') {
154
+ return {
155
+ personaComplete: 'unknown',
156
+ personaMounted: false,
157
+ agentInstructions: 'absent',
158
+ toolSkill: 'absent',
159
+ subagentTool: 'absent',
160
+ mcpClient: 'absent',
161
+ parseFailure: '组合文件为空',
162
+ };
163
+ }
164
+ let personaComplete = false;
165
+ let personaMounted = false;
166
+ for (let i = 0; i < lines.length; i += 1) {
167
+ const named = NAME_LINE.exec(lines[i]);
168
+ if (named === null || named[3] !== PERSONA_MODULE)
169
+ continue;
170
+ // A disabled persona row does not shadow the deployment persona.
171
+ if (presenceForRow(lines, i) === 'absent')
172
+ continue;
173
+ personaMounted = true;
174
+ for (const line of rowBlockAround(lines, i)) {
175
+ const complete = COMPLETE_LINE.exec(line);
176
+ if (complete !== null) {
177
+ personaComplete = complete[1] === 'true';
178
+ break;
179
+ }
180
+ }
181
+ break;
182
+ }
183
+ return {
184
+ personaComplete,
185
+ personaMounted,
186
+ agentInstructions: presenceOf(lines, AGENT_INSTRUCTIONS_MODULE),
187
+ toolSkill: presenceOf(lines, TOOL_SKILL_MODULE),
188
+ subagentTool: presenceOf(lines, SUBAGENT_TOOL_MODULE),
189
+ mcpClient: presenceOf(lines, MCP_CLIENT_MODULE),
190
+ };
191
+ }
192
+ catch (error) {
193
+ return {
194
+ personaComplete: 'unknown',
195
+ personaMounted: false,
196
+ agentInstructions: 'absent',
197
+ toolSkill: 'absent',
198
+ subagentTool: 'absent',
199
+ mcpClient: 'absent',
200
+ parseFailure: String(error?.message ?? error),
201
+ };
202
+ }
203
+ }
204
+ /** Map parsed facts onto the five user-visible capability columns. */
205
+ export function deriveReach(facts, ctx) {
206
+ const suppressed = facts.personaComplete === true;
207
+ const personaUnknown = facts.personaComplete === 'unknown';
208
+ let memory = suppressed ? 'suppressed' : personaUnknown ? 'unknown' : 'ok';
209
+ let agentsMd;
210
+ if (suppressed)
211
+ agentsMd = 'suppressed';
212
+ else if (personaUnknown)
213
+ agentsMd = 'unknown';
214
+ else if (facts.agentInstructions === 'absent')
215
+ agentsMd = 'absent';
216
+ else if (facts.agentInstructions === 'conditional')
217
+ agentsMd = 'unknown';
218
+ else
219
+ agentsMd = 'ok';
220
+ let skillCatalog;
221
+ if (facts.toolSkill === 'mounted')
222
+ skillCatalog = 'ok';
223
+ else if (facts.toolSkill === 'conditional')
224
+ skillCatalog = 'unknown';
225
+ else
226
+ skillCatalog = 'absent';
227
+ // The official delegation tool is a plain mounting fact: it is a model-facing
228
+ // tool row, so nothing the persona does can hide it.
229
+ let subagent;
230
+ if (facts.subagentTool === 'mounted')
231
+ subagent = 'ok';
232
+ else if (facts.subagentTool === 'conditional')
233
+ subagent = 'unknown';
234
+ else
235
+ subagent = 'absent';
236
+ // MCP: a client mounted in THIS composition only serves this preset; otherwise
237
+ // the answer is the host plane's, which every preset shares. An unknown host
238
+ // count is reported as `unknown` rather than assumed to be fine.
239
+ let mcp;
240
+ if (facts.mcpClient === 'mounted')
241
+ mcp = 'ok';
242
+ else if (facts.mcpClient === 'conditional')
243
+ mcp = 'unknown';
244
+ else if (ctx === undefined || ctx.mcpTools === undefined)
245
+ mcp = 'unknown';
246
+ else
247
+ mcp = ctx.mcpTools > 0 ? 'ok' : 'absent';
248
+ return { memory, agentsMd, skillCatalog, subagent, mcp };
249
+ }
250
+ /** Narrow the roster off a cordis context without throwing. */
251
+ export function presetRosterOf(ctx) {
252
+ try {
253
+ const value = typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined;
254
+ return value === null || typeof value !== 'object' ? undefined : value;
255
+ }
256
+ catch {
257
+ return undefined;
258
+ }
259
+ }
260
+ async function composeRow(roster, meta, ctx) {
261
+ const presetId = String(meta.id ?? '');
262
+ const name = typeof meta.name === 'string' ? meta.name : undefined;
263
+ const trust = typeof meta.trust === 'string' ? meta.trust : undefined;
264
+ const broken = typeof meta.broken === 'string' ? meta.broken : undefined;
265
+ const isDefault = roster.defaultId !== undefined && String(roster.defaultId) === presetId;
266
+ const base = {
267
+ presetId,
268
+ ...(name === undefined ? {} : { name }),
269
+ ...(trust === undefined ? {} : { trust }),
270
+ isDefault,
271
+ ...(broken === undefined ? {} : { broken }),
272
+ };
273
+ if (typeof roster.read !== 'function') {
274
+ return {
275
+ ...base,
276
+ personaComplete: 'unknown',
277
+ personaMounted: false,
278
+ agentInstructions: 'absent',
279
+ toolSkill: 'absent',
280
+ memory: 'unknown',
281
+ agentsMd: 'unknown',
282
+ skillCatalog: 'unknown',
283
+ subagent: 'unknown',
284
+ mcp: 'unknown',
285
+ reason: '预设名单未提供 read():无法读取组合文件',
286
+ };
287
+ }
288
+ let text;
289
+ try {
290
+ text = String((await roster.read(presetId)) ?? '');
291
+ }
292
+ catch (error) {
293
+ return {
294
+ ...base,
295
+ personaComplete: 'unknown',
296
+ personaMounted: false,
297
+ agentInstructions: 'absent',
298
+ toolSkill: 'absent',
299
+ memory: 'unknown',
300
+ agentsMd: 'unknown',
301
+ skillCatalog: 'unknown',
302
+ subagent: 'unknown',
303
+ mcp: 'unknown',
304
+ reason: `读取组合失败:${String(error?.message ?? error)}`,
305
+ };
306
+ }
307
+ const facts = readCompositionFacts(text);
308
+ const reach = deriveReach(facts, ctx);
309
+ return {
310
+ ...base,
311
+ personaComplete: facts.personaComplete,
312
+ personaMounted: facts.personaMounted,
313
+ agentInstructions: facts.agentInstructions,
314
+ toolSkill: facts.toolSkill,
315
+ ...reach,
316
+ ...(facts.parseFailure === undefined ? {} : { reason: `解析组合失败:${facts.parseFailure}` }),
317
+ };
318
+ }
319
+ /**
320
+ * Build the whole preset × capability matrix.
321
+ *
322
+ * Read-only end to end; a preset whose text cannot be read yields a named
323
+ * `reason` on its own row rather than failing the report.
324
+ */
325
+ export async function assessPresetReach(roster, ctx) {
326
+ const generatedAt = Date.now();
327
+ const mcpField = ctx?.mcpTools === undefined ? {} : { mcpTools: ctx.mcpTools };
328
+ if (roster === undefined || typeof roster.list !== 'function') {
329
+ return {
330
+ rows: [],
331
+ defaultId: null,
332
+ generatedAt,
333
+ ...mcpField,
334
+ summary: '预设可达性不可用(宿主未挂载 Agent 预设服务)',
335
+ blockers: ['agentPresets 服务未挂载:无法判断各预设下的注入边界'],
336
+ };
337
+ }
338
+ let presets;
339
+ try {
340
+ const listed = await roster.list();
341
+ presets = Array.isArray(listed) ? listed : [];
342
+ }
343
+ catch (error) {
344
+ const detail = String(error?.message ?? error);
345
+ return {
346
+ rows: [],
347
+ defaultId: null,
348
+ generatedAt,
349
+ ...mcpField,
350
+ summary: '预设可达性不可用(读取预设名单失败)',
351
+ blockers: [`读取预设名单失败:${detail}`],
352
+ };
353
+ }
354
+ const rows = [];
355
+ for (const raw of presets) {
356
+ if (raw === null || typeof raw !== 'object')
357
+ continue;
358
+ const meta = raw;
359
+ if (String(meta.id ?? '') === '')
360
+ continue;
361
+ rows.push(await composeRow(roster, meta, ctx));
362
+ }
363
+ const suppressed = rows.filter((row) => row.memory === 'suppressed').length;
364
+ const summary = rows.length === 0
365
+ ? '未发现任何 Agent 预设'
366
+ : `预设 ${rows.length} 个 · 抑制记忆注入 ${suppressed} 个 · 技能目录缺失 ${rows.filter((row) => row.skillCatalog === 'absent').length} 个 · 无官方子智能体工具 ${rows.filter((row) => row.subagent === 'absent').length} 个`;
367
+ return {
368
+ rows,
369
+ defaultId: roster.defaultId === undefined ? null : String(roster.defaultId),
370
+ generatedAt,
371
+ ...mcpField,
372
+ summary,
373
+ blockers: [],
374
+ };
375
+ }
376
+ /**
377
+ * The one-line boundary notice a model tool appends to its result.
378
+ *
379
+ * This is the load-bearing half of the fix: a model that lists memories while
380
+ * running under a suppressing preset would otherwise assume those memories are
381
+ * already in its context. Pure and synchronous so a contract test can pin the
382
+ * wording to the facts.
383
+ *
384
+ * @returns the notice, or `''` when nothing is suppressed.
385
+ */
386
+ export function reachNoticeFor(presetId, facts) {
387
+ const parts = [];
388
+ if (facts.personaComplete === true) {
389
+ parts.push(`场景记忆正文与 ~/.dsh/AGENTS.md 都不会自动进入你的上下文:预设「${presetId}」的 persona 是 complete,提示词只保留该 persona 本身。`, '不要假设你已经看到任何记忆正文;需要内容时用 rule_manager_read 逐条读取。');
390
+ }
391
+ else if (facts.agentInstructions === 'absent') {
392
+ parts.push(`~/.dsh/AGENTS.md 不会自动进入你的上下文:预设「${presetId}」未挂载 @deepseek-ai/dsh-agent-instructions。`);
393
+ }
394
+ if (facts.toolSkill === 'absent') {
395
+ parts.push(`技能目录在该预设下不可见(未挂载 @deepseek-ai/dsh-tool-skill)。`);
396
+ }
397
+ if (parts.length === 0)
398
+ return '';
399
+ return '\n\n⚠ 当前预设的注入边界:' + parts.join(' ');
400
+ }
401
+ /**
402
+ * Resolve the notice for the agent a tool call runs as.
403
+ *
404
+ * Every failure path answers `''` — a boundary notice must never turn a
405
+ * successful read into an error.
406
+ */
407
+ export async function reachNoticeForAgent(roster, agentCtx) {
408
+ if (roster === undefined || typeof roster.composedPreset !== 'function' || agentCtx === undefined || agentCtx === null)
409
+ return '';
410
+ let presetId = '';
411
+ try {
412
+ presetId = String(roster.composedPreset(agentCtx) ?? '');
413
+ }
414
+ catch {
415
+ return '';
416
+ }
417
+ if (presetId === '' || typeof roster.read !== 'function')
418
+ return '';
419
+ try {
420
+ return reachNoticeFor(presetId, readCompositionFacts(String((await roster.read(presetId)) ?? '')));
421
+ }
422
+ catch {
423
+ return '';
424
+ }
425
+ }