dsh-plugin-tool-management 0.6.0 → 0.8.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 +179 -0
- package/README.md +117 -221
- package/README_EN.md +116 -220
- package/docs/images/1/345/234/272/346/231/257.png +0 -0
- package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
- package/docs/images/2MCP.png +0 -0
- package/docs/images/2MCP_en.png +0 -0
- package/docs/images/3/346/212/200/350/203/275.png +0 -0
- package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
- package/docs/images/6/350/256/260/345/277/206.png +0 -0
- package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
- package/docs/images/7/344/274/232/350/257/235.png +0 -0
- package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
- package/docs/images/8/345/205/274/345/256/271.png +0 -0
- package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
- package/docs/update.md +56 -38
- package/lib/agents-md/service.js +84 -5
- package/lib/client.js +1488 -475
- package/lib/compat/preset-reach.js +425 -0
- package/lib/compat/probe.js +8 -0
- package/lib/http-fence.js +21 -0
- package/lib/hub.js +160 -2
- package/lib/index.js +687 -47
- package/lib/mcp/override-blocks.js +195 -0
- package/lib/mcp/state-section.js +90 -0
- package/lib/prompt-sections.js +148 -0
- package/lib/rules/archive-engine.js +99 -29
- package/lib/rules/archive.js +56 -28
- package/lib/rules/service.js +332 -35
- package/lib/skills/core.js +15 -1
- package/lib/skills/service.js +3 -0
- package/lib/subagents/catalog.js +89 -0
- package/lib/subagents/service.js +384 -41
- package/lib/subagents/tools.js +18 -5
- package/package.json +97 -96
- package/screenshots.json +10 -10
- package/docs/Changelog.md +0 -144
- package/lib/rules/provider.js +0 -121
package/lib/index.js
CHANGED
|
@@ -16,12 +16,17 @@ import { randomUUID } from 'node:crypto';
|
|
|
16
16
|
import { ArchiveWorkspaceRegistry as HistoryService, workspaceBaseName, workspacePathKey } from './history/workspace.js';
|
|
17
17
|
import { createSkillsService, pluginLog } from './skills/service.js';
|
|
18
18
|
import { createAgentsMdService } from './agents-md/service.js';
|
|
19
|
-
import { createRulesService } from './rules/service.js';
|
|
19
|
+
import { createRulesService, planMemoryExport } from './rules/service.js';
|
|
20
20
|
import { createArchiveEngine } from './rules/archive-engine.js';
|
|
21
|
-
import { createSubagentService } from './subagents/service.js';
|
|
21
|
+
import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
|
|
22
|
+
import { createSubagentCatalog } from './subagents/catalog.js';
|
|
23
|
+
import { SECTION_ORDER, createPromptSectionRegistrar } from './prompt-sections.js';
|
|
22
24
|
import { isApprovalNever } from './approval-policy.js';
|
|
23
|
-
import { EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
|
|
24
|
-
import {
|
|
25
|
+
import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
|
|
26
|
+
import { assessPresetReach, presetRosterOf, reachNoticeForAgent } from './compat/preset-reach.js';
|
|
27
|
+
import { fenceRejection, secretOpRejection } from './http-fence.js';
|
|
28
|
+
import { planOverrideCompaction } from './mcp/override-blocks.js';
|
|
29
|
+
import { createMcpStateCatalog, mcpStateSectionOf } from './mcp/state-section.js';
|
|
25
30
|
import { hubPath, hubRoot, relocateEntries } from './hub.js';
|
|
26
31
|
import { createScenePromptSync } from './scene-prompt-sync.js';
|
|
27
32
|
import { defineSubagentListTool, defineSubagentRunTool } from './subagents/tools.js';
|
|
@@ -29,7 +34,8 @@ import { detectFormat, extractText, parseGenericText, parseJsonlTranscript, pars
|
|
|
29
34
|
import { homedir } from 'node:os';
|
|
30
35
|
import { spawn } from 'node:child_process';
|
|
31
36
|
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
32
|
-
import { dirname, join } from 'node:path';
|
|
37
|
+
import { basename, dirname, join } from 'node:path';
|
|
38
|
+
import { zipSync } from 'fflate';
|
|
33
39
|
// ---------------------------------------------------------------------------
|
|
34
40
|
// Transcript export helpers — mirror the import parsers (src/imports/parsers.js)
|
|
35
41
|
// so an exported file can be imported back losslessly. Pure Node, no ctx.
|
|
@@ -73,6 +79,9 @@ export default {
|
|
|
73
79
|
const agents = ctx.get('agents');
|
|
74
80
|
// pluginInventory is optional: probe at use time, degrade to no live info.
|
|
75
81
|
const pluginInventory = ctx.get('pluginInventory');
|
|
82
|
+
// Agent 预设名单(@deepseek-ai/dsh-agent-presets):同样是可选服务,按需取用。
|
|
83
|
+
// 读它只为了回答"当前预设下本插件注入的东西到不到得了模型"——只读,不挂载任何预设。
|
|
84
|
+
const presetRoster = () => presetRosterOf(ctx);
|
|
76
85
|
// Package version, surfaced in the Settings pages and the HTTP API. Read
|
|
77
86
|
// from the installed package.json so it always matches the release tag.
|
|
78
87
|
let PKG_VERSION = 'unknown';
|
|
@@ -119,7 +128,7 @@ export default {
|
|
|
119
128
|
// ---------- agents-md 预设库 + 切换 ----------
|
|
120
129
|
// DSH 全局指令基线只有 ~/.dsh/AGENTS.md 一个文件,无内置多预设切换;
|
|
121
130
|
// 本服务在 hub 内 agents-md/ 维护预设库,「应用」= 写入 ~/.dsh/AGENTS.md,
|
|
122
|
-
//
|
|
131
|
+
// 下一轮对话生效:宿主 dsh-agent-instructions 每个 agent/pre-step 都会 stat 并重读该文件。
|
|
123
132
|
// presetsDir 可由 config 注入(测试用),否则落到 $DSH_HOME/tool-management/agents-md。
|
|
124
133
|
// v0.4:旧位置(插件目录 data/agents-md-presets/)在启动时搬入 hub——
|
|
125
134
|
// 插件目录在 npm 安装下会被覆盖,放用户数据在那里本身就丢数据风险。
|
|
@@ -254,6 +263,44 @@ export default {
|
|
|
254
263
|
// 用生效集去校验会把合法勾选判成 stale 丢掉。
|
|
255
264
|
return (await skillScan()).all;
|
|
256
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* 场景档案「技能集」的技能行 —— 技能名与来源名**分开**给出。
|
|
268
|
+
*
|
|
269
|
+
* 界面早先直接渲染 `key`(形如 `<来源 key>/<技能名>`),而自定义目录的来源 key 是
|
|
270
|
+
* `custom-<sha256-16>`,于是导入的技能在场景档案里显示成一串哈希(用户 2026-09-15 报的)。
|
|
271
|
+
* 这里把来源的 label / localeKey 一并带上,界面就能显示「技能名 · 来源名」。
|
|
272
|
+
*
|
|
273
|
+
* 含被覆盖的副本(`shadowed`):场景允许勾选任意一条技能,用生效集校验会把合法勾选判成 stale。
|
|
274
|
+
*/
|
|
275
|
+
async function skillRows() {
|
|
276
|
+
const rows = [];
|
|
277
|
+
try {
|
|
278
|
+
const r = await skillsService.ops['skill-state']({});
|
|
279
|
+
for (const root of ((r && r.data && r.data.roots) || [])) {
|
|
280
|
+
const rootKey = String((root && root.key) || '');
|
|
281
|
+
if (!rootKey)
|
|
282
|
+
continue;
|
|
283
|
+
for (const sk of ((root && root.skills) || [])) {
|
|
284
|
+
const name = String((sk && (sk.declaredName || sk.name)) || '');
|
|
285
|
+
if (!name)
|
|
286
|
+
continue;
|
|
287
|
+
rows.push({
|
|
288
|
+
key: rootKey + '/' + name,
|
|
289
|
+
name,
|
|
290
|
+
enabled: sk.enabled === true && !sk.shadowedBy,
|
|
291
|
+
shadowed: !!sk.shadowedBy,
|
|
292
|
+
rootKey,
|
|
293
|
+
rootLabel: String((root && root.label) || rootKey),
|
|
294
|
+
...(root && root.localeKey ? { rootLocaleKey: String(root.localeKey) } : {}),
|
|
295
|
+
...(root && root.kind ? { rootKind: String(root.kind) } : {}),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch { /* 技能状态读不到 → 空列表(界面如实显示"没有可选项") */ }
|
|
301
|
+
// 平铺列表按技能名排(同名再按来源),比按来源聚在一起更好找。
|
|
302
|
+
return rows.sort((a, b) => a.name.localeCompare(b.name) || a.rootKey.localeCompare(b.rootKey));
|
|
303
|
+
}
|
|
257
304
|
const archiveService = createArchiveEngine({
|
|
258
305
|
loadSlice: async () => ({ ...(await rulesService.readArchiveSlice()) }),
|
|
259
306
|
saveSlice: (slice) => rulesService.patchIndex(slice),
|
|
@@ -322,12 +369,76 @@ export default {
|
|
|
322
369
|
throw new Error(`技能 ${key} 应用失败: ${r.error}`);
|
|
323
370
|
}
|
|
324
371
|
},
|
|
372
|
+
// ── 服务器级 / 来源级启停(P5)────────────────────────────────────────────
|
|
373
|
+
// 为什么档案必须能改**上层**:工具级停用只在「服务器已经加载」时才有意义 ——
|
|
374
|
+
// 服务器没起来,它的工具根本不存在,勾选与否毫无区别。技能同理:来源关闭时,
|
|
375
|
+
// 技能级的 enable 会被 core 的 sourceEnabled 判定直接吞掉。
|
|
376
|
+
// 只报**补丁文件里真实存在**的行(level = project/global);loader 级条目改不了补丁。
|
|
377
|
+
mcpServerStates: async () => {
|
|
378
|
+
const r = await mcpmListView();
|
|
379
|
+
const out = [];
|
|
380
|
+
for (const row of ((r && r.rows) || [])) {
|
|
381
|
+
const level = String((row && row.level) || '');
|
|
382
|
+
if (level !== 'global' && level !== 'project')
|
|
383
|
+
continue;
|
|
384
|
+
out.push({
|
|
385
|
+
id: String(row.id),
|
|
386
|
+
serverName: String(row.serverName || row.id),
|
|
387
|
+
level,
|
|
388
|
+
disabled: !!row.disabled,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
},
|
|
393
|
+
applyMcpServerSwitches: async (switches) => {
|
|
394
|
+
for (const s of switches) {
|
|
395
|
+
const r = await mcpmSetEnabled({ id: s.id, level: s.level, enabled: s.enabled });
|
|
396
|
+
if (r && r.ok === false)
|
|
397
|
+
throw new Error(`MCP 服务器 ${s.id} ${s.enabled ? '启用' : '停用'}失败:${r.error}`);
|
|
398
|
+
}
|
|
399
|
+
// 补丁改动由宿主热重载(实测 ≤5s)。这里**不等**,避免进入模式被拖住十几秒;
|
|
400
|
+
// 界面在应用完成后即可用,工具集合会在数秒内补齐。
|
|
401
|
+
},
|
|
402
|
+
skillSourceStates: async () => {
|
|
403
|
+
const r = await skillsService.ops['skill-state']({});
|
|
404
|
+
const roots = (r && r.data && r.data.roots) || [];
|
|
405
|
+
const out = [];
|
|
406
|
+
for (const root of roots) {
|
|
407
|
+
const key = String((root && root.key) || '');
|
|
408
|
+
if (!key)
|
|
409
|
+
continue;
|
|
410
|
+
// 默认来源(dsh / hub)不能停用(core 会拒绝)→ 不进切换集,避免无谓报错。
|
|
411
|
+
if (root.defaultSource === true)
|
|
412
|
+
continue;
|
|
413
|
+
out.push({ root: key, enabled: root.enabled === true });
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
},
|
|
417
|
+
applySkillSourceSwitches: async (switches) => {
|
|
418
|
+
for (const s of switches) {
|
|
419
|
+
const op = s.enabled ? 'skill-source-enable' : 'skill-source-disable';
|
|
420
|
+
const r = await skillsService.ops[op]({ root: s.root });
|
|
421
|
+
if (r && r.ok === false)
|
|
422
|
+
throw new Error(`技能来源 ${s.root} ${s.enabled ? '启用' : '停用'}失败:${r.error}`);
|
|
423
|
+
}
|
|
424
|
+
},
|
|
325
425
|
sceneExists: async (name) => {
|
|
326
426
|
const r = await rulesService.ops['rules-list']({});
|
|
327
427
|
return !!(r && r.ok !== false && (r.scenes || []).some((s) => s.name === name));
|
|
328
428
|
},
|
|
329
429
|
// 人设名全集(保存 subagents 段时校验并报 stale,与 mcp/skills 两段同口径)。
|
|
330
430
|
knownPersonas: async () => new Set((await subagentService.list()).map((p) => p.name)),
|
|
431
|
+
// 人设开关读/写(进入模式启用档案勾选的人设;退出按快照停回)。
|
|
432
|
+
disabledPersonas: async (names) => subagentService.enabledStore.disabledAmong(names),
|
|
433
|
+
applySubagentSwitches: async (switches) => {
|
|
434
|
+
const off = switches.filter((s) => !s.enabled).map((s) => s.name);
|
|
435
|
+
const on = switches.filter((s) => s.enabled).map((s) => s.name);
|
|
436
|
+
if (off.length)
|
|
437
|
+
await subagentService.enabledStore.setEnabled(off, false);
|
|
438
|
+
if (on.length)
|
|
439
|
+
await subagentService.enabledStore.setEnabled(on, true);
|
|
440
|
+
void subagentCatalog.refresh();
|
|
441
|
+
},
|
|
331
442
|
// 记忆 id 全集(保存 memories 段时校验并报 stale)。
|
|
332
443
|
knownMemoryIds: async () => {
|
|
333
444
|
const r = await rulesService.ops['rules-list']({});
|
|
@@ -337,7 +448,8 @@ export default {
|
|
|
337
448
|
// ---------- 轻量子智能体(设计 §3)----------
|
|
338
449
|
// 人设 = $DSH_HOME/tool-management/agents/<name>.md;运行走官方 ctx.subagents.start(spawn provider)。
|
|
339
450
|
// sceneLists 供场景绑定校验:启用场景(rules-list 的 active 行)档案里的 subagents 并集。
|
|
340
|
-
|
|
451
|
+
// stateDir 与 rules 共用同一个覆盖键(测试注入一致);子智能体开关存 <stateDir>/agents-index.json。
|
|
452
|
+
const subagentService = createSubagentService(ctx, { stateDir: String(config?.rulesStateDir || '') });
|
|
341
453
|
const subagentSceneLists = async () => {
|
|
342
454
|
const slice = await rulesService.readArchiveSlice();
|
|
343
455
|
const r = await rulesService.ops['rules-list']({});
|
|
@@ -347,6 +459,149 @@ export default {
|
|
|
347
459
|
.filter((s) => s.active)
|
|
348
460
|
.map((s) => slice.archives[s.name]?.subagents ?? []);
|
|
349
461
|
};
|
|
462
|
+
// ---------- 注入类段(P1 人设目录 + P2 MCP 状态)----------
|
|
463
|
+
// 两者是同一类缺陷的两端:**某个域的配置状态没有在场陈述**,模型只能靠用户口头提醒。
|
|
464
|
+
// - 人设:本插件自造的概念,宿主没有对应物 → 模型不知道有哪些人设,subagent_run 不会被触发。
|
|
465
|
+
// - MCP:启停只改工具 schema,没有任何显式状态陈述 → 模型不知道有哪些 server,
|
|
466
|
+
// 更拿不到用户写的**备注**(那是写给「未来的模型」的决策提示,工具 schema 永远传达不到)。
|
|
467
|
+
// 两个段都只列「名字 + 摘要」,正文/工具清单绝不进上下文。
|
|
468
|
+
// 口径与理由分别见 src/subagents/catalog.ts、src/mcp/state-section.ts 的文件头。
|
|
469
|
+
const subagentCatalog = createSubagentCatalog({
|
|
470
|
+
list: () => subagentService.list(),
|
|
471
|
+
sceneLists: subagentSceneLists,
|
|
472
|
+
});
|
|
473
|
+
// ⚠️ 取数必须用 mcpmRowsWithNotes(false):true 会带上**未打码**的 url/headers/env(含明文密钥)。
|
|
474
|
+
const mcpStateCatalog = createMcpStateCatalog({
|
|
475
|
+
rows: () => mcpmRowsWithNotes(false),
|
|
476
|
+
});
|
|
477
|
+
try {
|
|
478
|
+
ctx.effect(() => {
|
|
479
|
+
const registrar = createPromptSectionRegistrar(ctx, [
|
|
480
|
+
{ name: 'tool-management:subagents', order: SECTION_ORDER.subagents, text: () => subagentCatalog.text() },
|
|
481
|
+
mcpStateSectionOf(mcpStateCatalog, SECTION_ORDER.mcpState),
|
|
482
|
+
]);
|
|
483
|
+
return () => registrar.dispose();
|
|
484
|
+
}, 'dsh-plugin-tool-management: prompt sections');
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
console.error('[dsh-plugin-tool-management] prompt section setup failed:', message(e));
|
|
488
|
+
}
|
|
489
|
+
// 预热放到下一轮事件循环:此时 apply 的同步初始化(补丁路径、tools 服务…)已全部完成,
|
|
490
|
+
// 避免在初始化中途就去读 MCP 补丁与工具 schema。
|
|
491
|
+
setTimeout(() => {
|
|
492
|
+
void subagentCatalog.warm();
|
|
493
|
+
void mcpStateCatalog.warm();
|
|
494
|
+
}, 0);
|
|
495
|
+
// 人设写操作成功后立即重算目录(SWR:text() 同步返回缓存值,写后主动 refresh)。
|
|
496
|
+
// 包装在 service 的 ops 上:写入口只有这一处,不需要在界面层逐个补。
|
|
497
|
+
for (const opName of subagentService.writeOps) {
|
|
498
|
+
const original = subagentService.ops[opName];
|
|
499
|
+
if (typeof original !== 'function')
|
|
500
|
+
continue;
|
|
501
|
+
subagentService.ops[opName] = async (args) => {
|
|
502
|
+
const result = await original(args);
|
|
503
|
+
if (result && result.ok !== false)
|
|
504
|
+
void subagentCatalog.refresh();
|
|
505
|
+
return result;
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* 人设改名后同步场景绑定:场景档案的 `subagents` 名单存的是**人设名**,
|
|
510
|
+
* 不跟着改就会留一个悬空引用(界面把它报成 stale,用户看到「人设不存在」却找不到地方改)。
|
|
511
|
+
* 绑定存在 rules-index.json 里,人设服务看不到它,所以在这一层补一次(同提示词改名的做法)。
|
|
512
|
+
*/
|
|
513
|
+
async function rebindSubagentInArchives(from, to) {
|
|
514
|
+
try {
|
|
515
|
+
const slice = await rulesService.readArchiveSlice();
|
|
516
|
+
const archives = { ...(slice.archives || {}) };
|
|
517
|
+
let changed = 0;
|
|
518
|
+
for (const [scene, archive] of Object.entries(archives)) {
|
|
519
|
+
const list = archive.subagents;
|
|
520
|
+
if (!Array.isArray(list) || !list.includes(from))
|
|
521
|
+
continue;
|
|
522
|
+
archives[scene] = { ...archive, subagents: list.map((n) => (n === from ? to : n)) };
|
|
523
|
+
changed++;
|
|
524
|
+
}
|
|
525
|
+
if (changed)
|
|
526
|
+
await rulesService.patchIndex({ archives });
|
|
527
|
+
return changed;
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return 0;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const baseSubagentUpdate = subagentService.ops['subagent-update'];
|
|
534
|
+
if (typeof baseSubagentUpdate === 'function') {
|
|
535
|
+
subagentService.ops['subagent-update'] = async (args) => {
|
|
536
|
+
const res = await baseSubagentUpdate(args);
|
|
537
|
+
if (res && res.ok !== false && res.renamedFrom)
|
|
538
|
+
void rebindSubagentInArchives(String(res.renamedFrom), String(res.name));
|
|
539
|
+
return res;
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
// 模式切换会改人设开关(进入=启用勾选的,退出=按快照停回)——目录段立即重算,
|
|
543
|
+
// 别等 1s TTL:切完场景紧接着的下一轮请求就该看到新名单。
|
|
544
|
+
const baseSceneModeSet = archiveService.ops['scene-mode-set'];
|
|
545
|
+
if (typeof baseSceneModeSet === 'function') {
|
|
546
|
+
archiveService.ops['scene-mode-set'] = async (args) => {
|
|
547
|
+
// 锁定的场景不能关闭(用户裁定):退出模式(scene=null)和切换到别的场景
|
|
548
|
+
// 都意味着先退出当前模式 —— 当前模式场景处于锁定状态时一律拒绝。
|
|
549
|
+
// 目标就是当前场景 = 无操作,放行(引擎自己会 early-return)。
|
|
550
|
+
try {
|
|
551
|
+
const slice = await rulesService.readArchiveSlice();
|
|
552
|
+
const current = slice.mode && slice.mode.scene;
|
|
553
|
+
if (current && args && 'scene' in (args || {})) {
|
|
554
|
+
const target = args.scene == null ? null : String(args.scene).trim() || null;
|
|
555
|
+
if (target !== current) {
|
|
556
|
+
const locked = await lockedSceneNames();
|
|
557
|
+
if (locked.includes(current)) {
|
|
558
|
+
return { ok: false, error: `场景「${current}」已锁定:先解锁再关闭` };
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch { /* 守卫读状态失败不拦正常流程(引擎自身校验兜底) */ }
|
|
564
|
+
const res = await baseSceneModeSet(args);
|
|
565
|
+
if (res && res.ok !== false)
|
|
566
|
+
void subagentCatalog.refresh();
|
|
567
|
+
return res;
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* 模式进行中保存当前场景的档案时,联动人设开关:subagents 段新勾进来的名字**立即启用**,
|
|
572
|
+
* 并追加进快照的「模式期间自动启用」名单(退出时停回)。只启用、不停用 —— 用户手动
|
|
573
|
+
* 关掉的开关不该被一次档案保存悄悄改回去。
|
|
574
|
+
*/
|
|
575
|
+
const baseSceneArchiveSave = archiveService.ops['scene-archive-save'];
|
|
576
|
+
if (typeof baseSceneArchiveSave === 'function') {
|
|
577
|
+
archiveService.ops['scene-archive-save'] = async (args) => {
|
|
578
|
+
const res = await baseSceneArchiveSave(args);
|
|
579
|
+
try {
|
|
580
|
+
const bound = res && res.ok !== false && res.archive && Array.isArray(res.archive.subagents) ? res.archive.subagents : [];
|
|
581
|
+
if (bound.length) {
|
|
582
|
+
const slice = await rulesService.readArchiveSlice();
|
|
583
|
+
const mode = slice.mode;
|
|
584
|
+
if (mode && mode.scene === res.scene) {
|
|
585
|
+
const disabled = await subagentService.enabledStore.disabledAmong(bound);
|
|
586
|
+
if (disabled.length) {
|
|
587
|
+
await subagentService.enabledStore.setEnabled(disabled, true);
|
|
588
|
+
void subagentCatalog.refresh();
|
|
589
|
+
const snapshot = mode.snapshot;
|
|
590
|
+
if (snapshot) {
|
|
591
|
+
const merged = Array.isArray(snapshot.subagents) ? snapshot.subagents.slice() : [];
|
|
592
|
+
for (const n of disabled)
|
|
593
|
+
if (merged.indexOf(n) < 0)
|
|
594
|
+
merged.push(n);
|
|
595
|
+
await rulesService.patchIndex({ mode: { ...mode, snapshot: { ...snapshot, subagents: merged } } });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
catch { /* 联动失败不阻断档案保存本身;开关会在下次进/退模式时对齐 */ }
|
|
602
|
+
return res;
|
|
603
|
+
};
|
|
604
|
+
}
|
|
350
605
|
// HTTP 写操作门禁清单。skills/rules/档案引擎域由各自 service 导出的 writeOps 派生
|
|
351
606
|
// (与其 ops 表同文件维护,新增写 op 改对应 service 即可);本文件内联域
|
|
352
607
|
// (mcpm-* / skill-open / agentsmd-* / history-*)在此列举。
|
|
@@ -357,6 +612,7 @@ export default {
|
|
|
357
612
|
...archiveService.writeOps,
|
|
358
613
|
...subagentService.writeOps,
|
|
359
614
|
'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-restart',
|
|
615
|
+
'mcpm-compact',
|
|
360
616
|
'mcpm-export', 'mcpm-import', 'mcpm-note', 'mcpm-settings', 'mcpm-tool-enabled',
|
|
361
617
|
// mcpm-reveal returns UNMASKED secrets; even though it is a read, it is
|
|
362
618
|
// token-gated like a write — on a LAN-exposed port the token must be the
|
|
@@ -365,13 +621,18 @@ export default {
|
|
|
365
621
|
'skill-open',
|
|
366
622
|
// agents-md 写操作(create/update/remove 改预设库;apply 写全局 AGENTS.md;import 从外部内容建预设)
|
|
367
623
|
'agentsmd-create', 'agentsmd-update', 'agentsmd-apply', 'agentsmd-remove', 'agentsmd-import',
|
|
624
|
+
// 提示词预设的回收站(恢复 / 永久删除都是写)
|
|
625
|
+
'agentsmd-trash-restore', 'agentsmd-trash-delete',
|
|
368
626
|
// history 写操作(archive/unarchive 改归档集合;delete 永久删除;retention-set 写保留期;
|
|
369
627
|
// workspace-register 会新增一条宿主工作区登记,同样是写)
|
|
370
628
|
'history-archive', 'history-unarchive', 'history-delete', 'history-retention-set',
|
|
371
629
|
'history-unarchive-batch', 'history-delete-batch', 'history-import', 'history-export',
|
|
630
|
+
// 通用导出:往用户指定的目录写文件,按写操作门禁(token)。
|
|
631
|
+
'bundle-export',
|
|
372
632
|
'history-archive-batch', 'history-workspace-register',
|
|
373
633
|
]);
|
|
374
|
-
// 会泄露明文凭据 / 完整配置的 op
|
|
634
|
+
// 会泄露明文凭据 / 完整配置的 op:**必须**带对的访问令牌,没配令牌就一律拒绝
|
|
635
|
+
// (判定在 http-fence.ts 的 secretOpRejection,含两种情况的区分与理由)。
|
|
375
636
|
const SENSITIVE_OPS = new Set(['mcpm-reveal', 'mcpm-export']);
|
|
376
637
|
// 兼容体检的日志去重:同一宿主版本只写一条 compat/probe,避免轮询刷屏。
|
|
377
638
|
let compatLoggedFor;
|
|
@@ -1941,6 +2202,55 @@ export default {
|
|
|
1941
2202
|
return { ok: true };
|
|
1942
2203
|
});
|
|
1943
2204
|
}
|
|
2205
|
+
/**
|
|
2206
|
+
* 收敛补丁文件里的启停覆盖块(用户显式点「整理补丁」才走这里)。
|
|
2207
|
+
*
|
|
2208
|
+
* 只删"删掉也不改变生效状态"的块(判定见 planOverrideCompaction):insert 行、
|
|
2209
|
+
* 带 config 的覆盖块、以及决定当前生效值的那一条都原样保留。删除前 writePatch
|
|
2210
|
+
* 会自动备份上一版(保留最近 5 份),所以这一步是可回退的。
|
|
2211
|
+
*
|
|
2212
|
+
* 基准状态由 planOverrideCompaction 自己从 insert 行读(另一份文件的内容只用来补
|
|
2213
|
+
* "insert 行不在本文件里"的情况)。这里**不要**喂 parseRows() 的 rows —— 那是合并
|
|
2214
|
+
* 覆盖块之后的生效值,会让每个 decider 都被判成多余:第一版就是这么把 6 台 MCP
|
|
2215
|
+
* 全部启用的。同理,界面不显示任何"多余块数"(那个数字也来自同一条错误口径)。
|
|
2216
|
+
*/
|
|
2217
|
+
async function mcpmCompact() {
|
|
2218
|
+
const p = await ensurePaths();
|
|
2219
|
+
return withWriteLock(async () => {
|
|
2220
|
+
const levels = ['project', 'global'];
|
|
2221
|
+
const absOf = (level) => (level === 'project' ? p.projectPatch : p.globalPatch);
|
|
2222
|
+
const contents = {};
|
|
2223
|
+
for (const level of levels) {
|
|
2224
|
+
try {
|
|
2225
|
+
contents[level] = await readPatch(absOf(level));
|
|
2226
|
+
}
|
|
2227
|
+
catch (e) {
|
|
2228
|
+
contents[level] = '';
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
const removed = {};
|
|
2232
|
+
const files = [];
|
|
2233
|
+
let total = 0;
|
|
2234
|
+
for (const level of levels) {
|
|
2235
|
+
const content = contents[level];
|
|
2236
|
+
if (!content)
|
|
2237
|
+
continue;
|
|
2238
|
+
const others = levels.filter((other) => other !== level).map((other) => contents[other]).filter(Boolean);
|
|
2239
|
+
const plan = planOverrideCompaction(content, others);
|
|
2240
|
+
if (!plan.ranges.length)
|
|
2241
|
+
continue;
|
|
2242
|
+
await writePatch(absOf(level), spliceRanges(splitLines(content), plan.ranges));
|
|
2243
|
+
files.push(absOf(level));
|
|
2244
|
+
for (const [id, stat] of Object.entries(plan.ids)) {
|
|
2245
|
+
if (!stat.dropped)
|
|
2246
|
+
continue;
|
|
2247
|
+
removed[id] = (removed[id] || 0) + stat.dropped;
|
|
2248
|
+
total += stat.dropped;
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
return { ok: true, removed: total, ids: removed, files };
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
1944
2254
|
async function mcpmExport() {
|
|
1945
2255
|
const p = await ensurePaths();
|
|
1946
2256
|
const list = await mcpmList();
|
|
@@ -2289,32 +2599,24 @@ export default {
|
|
|
2289
2599
|
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
2290
2600
|
if (agentPresets && typeof agentPresets.list === 'function') {
|
|
2291
2601
|
try {
|
|
2602
|
+
// roster 顺序 = 官方的展示顺序(preset.yml 的 order:标准 1 / PTC 2 / 极简 3 / 创造 4,
|
|
2603
|
+
// 自建预设排在其后)。人设编辑器的四行照抄这个顺序,**不要**再按 id 排序。
|
|
2292
2604
|
const roster = await agentPresets.list();
|
|
2293
2605
|
for (const p of roster || []) {
|
|
2294
2606
|
const id = String((p && p.id) || '');
|
|
2295
2607
|
if (!id)
|
|
2296
2608
|
continue;
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
}
|
|
2307
|
-
let names = [];
|
|
2308
|
-
try {
|
|
2309
|
-
names = (await tools.schemas(scopeKey) || []).map((s) => String(s.name)).filter(Boolean);
|
|
2310
|
-
}
|
|
2311
|
-
catch {
|
|
2312
|
-
names = [];
|
|
2313
|
-
}
|
|
2609
|
+
// MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
|
|
2610
|
+
// 子代理照样能用当前在跑的 MCP —— 那份名单在 decideToolFilter 里运行时并进白名单。
|
|
2611
|
+
const names = (await presetToolNames(id)).filter((name) => !name.startsWith('mcp__'));
|
|
2612
|
+
presets.push({
|
|
2613
|
+
id,
|
|
2614
|
+
name: String((p && (p.name || p.id)) || id),
|
|
2615
|
+
trust: String((p && p.trust) || 'user'),
|
|
2616
|
+
broken: typeof (p && p.broken) === 'string',
|
|
2617
|
+
tools: names,
|
|
2618
|
+
});
|
|
2314
2619
|
for (const name of names) {
|
|
2315
|
-
// MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
|
|
2316
|
-
if (name.startsWith('mcp__'))
|
|
2317
|
-
continue;
|
|
2318
2620
|
const rec = byName.get(name) || { name, presets: new Set(), current: false };
|
|
2319
2621
|
rec.presets.add(id);
|
|
2320
2622
|
byName.set(name, rec);
|
|
@@ -2336,11 +2638,94 @@ export default {
|
|
|
2336
2638
|
tools: [...byName.values()]
|
|
2337
2639
|
.map((r) => ({ name: r.name, presets: [...r.presets].sort(), current: r.current }))
|
|
2338
2640
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
2339
|
-
|
|
2641
|
+
// 保持 roster 顺序(官方的展示顺序),前端四行直接照用。
|
|
2642
|
+
presets,
|
|
2340
2643
|
};
|
|
2341
2644
|
presetToolsCache = { at: Date.now(), value };
|
|
2342
2645
|
return value;
|
|
2343
2646
|
}
|
|
2647
|
+
/**
|
|
2648
|
+
* 单个预设的工具名(列表里含 MCP 与宿主平面的工具,因为子代理的可见集合是
|
|
2649
|
+
* "宿主平面 ∪ 该预设"的并集)。枚举会为该预设建立 standing mount —— 官方语义:
|
|
2650
|
+
* 一个预设每进程只挂一次,正常创建会话时同样会挂,所以这里按 id 缓存 60 秒;
|
|
2651
|
+
* `subagent_run` 每次委派都要用它校验名单,不能每次都重新枚举。
|
|
2652
|
+
*/
|
|
2653
|
+
const presetNamesCache = new Map();
|
|
2654
|
+
async function presetToolNames(id) {
|
|
2655
|
+
const hit = presetNamesCache.get(id);
|
|
2656
|
+
if (hit && Date.now() - hit.at < 60000)
|
|
2657
|
+
return hit.names;
|
|
2658
|
+
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
2659
|
+
let scopeKey;
|
|
2660
|
+
try {
|
|
2661
|
+
scopeKey = agentPresets && typeof agentPresets.standingKeyFor === 'function'
|
|
2662
|
+
? await agentPresets.standingKeyFor(id)
|
|
2663
|
+
: undefined;
|
|
2664
|
+
}
|
|
2665
|
+
catch {
|
|
2666
|
+
scopeKey = undefined;
|
|
2667
|
+
}
|
|
2668
|
+
let names = [];
|
|
2669
|
+
try {
|
|
2670
|
+
names = ((await tools.schemas(scopeKey)) || []).map((s) => String(s.name)).filter(Boolean);
|
|
2671
|
+
}
|
|
2672
|
+
catch {
|
|
2673
|
+
names = [];
|
|
2674
|
+
}
|
|
2675
|
+
presetNamesCache.set(id, { at: Date.now(), names });
|
|
2676
|
+
return names;
|
|
2677
|
+
}
|
|
2678
|
+
/** 预设名单的名字投影(只读 roster,不挂载任何预设):场景页显示模式名用。 */
|
|
2679
|
+
async function presetNames() {
|
|
2680
|
+
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
2681
|
+
if (!agentPresets || typeof agentPresets.list !== 'function')
|
|
2682
|
+
return [];
|
|
2683
|
+
try {
|
|
2684
|
+
const roster = await agentPresets.list();
|
|
2685
|
+
return (roster || [])
|
|
2686
|
+
.map((p) => ({ id: String((p && p.id) || ''), name: String((p && (p.name || p.id)) || ''), trust: String((p && p.trust) || 'user') }))
|
|
2687
|
+
.filter((p) => p.id !== '');
|
|
2688
|
+
}
|
|
2689
|
+
catch {
|
|
2690
|
+
return [];
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
/** 调用方 agent 跑在哪个 Agent 预设上;判断不了返回 null(绝不猜)。 */
|
|
2694
|
+
function currentPresetId(agentCtx) {
|
|
2695
|
+
if (agentCtx === undefined || agentCtx === null)
|
|
2696
|
+
return null;
|
|
2697
|
+
try {
|
|
2698
|
+
const roster = presetRoster();
|
|
2699
|
+
if (roster === undefined || typeof roster.composedPreset !== 'function')
|
|
2700
|
+
return null;
|
|
2701
|
+
const id = String(roster.composedPreset(agentCtx) ?? '');
|
|
2702
|
+
return id === '' ? null : id;
|
|
2703
|
+
}
|
|
2704
|
+
catch {
|
|
2705
|
+
return null;
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
/**
|
|
2709
|
+
* 人设的工具限制:按**当前会话的预设**决定这次委派下发什么(规则见 decideToolFilter)。
|
|
2710
|
+
* 已知工具名 = 宿主平面(含 MCP)∪ 当前预设的工具;枚举失败时退化成宿主平面,
|
|
2711
|
+
* 于是模式名单里的预设侧工具会被如实记为"当前不存在",而不是造成官方 restrict 抛错。
|
|
2712
|
+
*/
|
|
2713
|
+
async function subagentToolFilterFor(persona, agentCtx) {
|
|
2714
|
+
let hostNames = [];
|
|
2715
|
+
try {
|
|
2716
|
+
hostNames = ((await tools.schemas()) || []).map((s) => String(s.name)).filter(Boolean);
|
|
2717
|
+
}
|
|
2718
|
+
catch {
|
|
2719
|
+
hostNames = [];
|
|
2720
|
+
}
|
|
2721
|
+
const mcp = hostNames.filter((name) => name.startsWith('mcp__'));
|
|
2722
|
+
const known = new Set(hostNames);
|
|
2723
|
+
const presetId = currentPresetId(agentCtx);
|
|
2724
|
+
if (presetId !== null)
|
|
2725
|
+
for (const name of await presetToolNames(presetId))
|
|
2726
|
+
known.add(name);
|
|
2727
|
+
return decideToolFilter(persona, presetId, { names: known, mcp });
|
|
2728
|
+
}
|
|
2344
2729
|
/**
|
|
2345
2730
|
* 模型候选(人设的「模型」下拉):宿主已注册的 provider + 各 provider 能宣告的模型。
|
|
2346
2731
|
* 只读宿主 LLM 目录,**不发起网络请求**(`discoverModels` 会打端点,这里不用);
|
|
@@ -2400,6 +2785,7 @@ export default {
|
|
|
2400
2785
|
'mcpm-set-all': mcpmSetAll,
|
|
2401
2786
|
'mcpm-restart': mcpmRestart,
|
|
2402
2787
|
'mcpm-remove': mcpmRemove,
|
|
2788
|
+
'mcpm-compact': mcpmCompact,
|
|
2403
2789
|
'mcpm-export': mcpmExport,
|
|
2404
2790
|
'mcpm-import': mcpmImport,
|
|
2405
2791
|
// 技能管理 ops(由 ./skills/service.js 提供):skill-state / skill-detail /
|
|
@@ -2438,7 +2824,7 @@ export default {
|
|
|
2438
2824
|
'model-candidates': async () => ({ ok: true, ...(await modelCandidates()) }),
|
|
2439
2825
|
// 场景档案勾选器数据源 v2:全部 MCP 服务器(含未运行)+ 技能全集 + 人设清单。
|
|
2440
2826
|
'scene-inventory': async () => {
|
|
2441
|
-
const [rowsR, tools, skills, subs] = await Promise.all([mcpmListView(), toolStates(),
|
|
2827
|
+
const [rowsR, tools, skills, subs] = await Promise.all([mcpmListView(), toolStates(), skillRows(), subagentService.list()]);
|
|
2442
2828
|
const disabledRaw = await readDisabledTools();
|
|
2443
2829
|
const rows = (rowsR && rowsR.rows) || [];
|
|
2444
2830
|
const mcpServers = [];
|
|
@@ -2462,8 +2848,13 @@ export default {
|
|
|
2462
2848
|
ok: true,
|
|
2463
2849
|
mcpServers,
|
|
2464
2850
|
tools: Object.entries(tools).map(([key, enabled]) => ({ key, enabled })),
|
|
2465
|
-
|
|
2466
|
-
|
|
2851
|
+
// 技能行:技能名与来源名分开给(早先只给 `<来源 key>/<技能名>`,自定义目录的
|
|
2852
|
+
// 来源 key 是 `custom-<hash>`,于是导入的技能在场景里显示成一串哈希)。
|
|
2853
|
+
skills,
|
|
2854
|
+
// 人设行要显示"按模式配了什么限制",所以把按模式名单一起带上;
|
|
2855
|
+
// presets 只给名字与来源(不枚举工具,避免打开档案弹窗就挂载每个预设)。
|
|
2856
|
+
subagents: subs.map((p) => ({ name: p.name, description: p.description, toolsByPreset: p.toolsByPreset ?? null })),
|
|
2857
|
+
presets: await presetNames(),
|
|
2467
2858
|
// 档案编辑器第 4 段「记忆」的候选:场景 + 每个场景里的记忆条数。
|
|
2468
2859
|
// 记忆正文不在这里返回(勾选集只存 id,渲染时才读盘)。
|
|
2469
2860
|
scenes: await memorySceneCandidates(),
|
|
@@ -2507,13 +2898,16 @@ export default {
|
|
|
2507
2898
|
'agentsmd-create': (args) => agentsMdService.create(String((args && args.id) || ''), {
|
|
2508
2899
|
...(args && args.from ? { from: String(args.from) } : {}),
|
|
2509
2900
|
...(args && typeof args.content === 'string' ? { content: String(args.content) } : {}),
|
|
2901
|
+
...(args && typeof args.description === 'string' ? { description: String(args.description) } : {}),
|
|
2510
2902
|
}),
|
|
2511
2903
|
// 保存:改正文 + 可选改名(nextId)。改名成功顺带把场景绑定一起改名 ——
|
|
2512
2904
|
// 绑定存在 rules-index.json 里,预设库自己看不到,不叫这一声就会留悬空绑定。
|
|
2513
2905
|
// 保存后同步一次全局基线:若改的正是「正在驱动基线的那份」,正文要跟着写进
|
|
2514
2906
|
// `~/.dsh/AGENTS.md`(否则页面标着「生效中」而文件里还是旧内容)。
|
|
2515
2907
|
'agentsmd-update': async (args) => {
|
|
2516
|
-
const res = await agentsMdService.update(String((args && args.id) || ''), String((args && args.content) ?? ''), args && args.nextId !== undefined ? String(args.nextId) : undefined
|
|
2908
|
+
const res = await agentsMdService.update(String((args && args.id) || ''), String((args && args.content) ?? ''), args && args.nextId !== undefined ? String(args.nextId) : undefined,
|
|
2909
|
+
// 描述与正文同一次提交:省略(undefined)= 不动,空串 = 清空。
|
|
2910
|
+
args && typeof args.description === 'string' ? String(args.description) : undefined);
|
|
2517
2911
|
if (!res || res.ok === false)
|
|
2518
2912
|
return res;
|
|
2519
2913
|
let renamed = {};
|
|
@@ -2539,7 +2933,12 @@ export default {
|
|
|
2539
2933
|
}
|
|
2540
2934
|
return agentsMdService.remove(id);
|
|
2541
2935
|
},
|
|
2542
|
-
'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? '')),
|
|
2936
|
+
'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? ''), args && typeof args.description === 'string' ? String(args.description) : undefined),
|
|
2937
|
+
// 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/agents-md-trash/<id>/`,
|
|
2938
|
+
// 误删可从「提示词」页的回收站里恢复(同 id 已存在时拒绝恢复,绝不覆盖)。
|
|
2939
|
+
'agentsmd-trash-list': () => agentsMdService.trashList(),
|
|
2940
|
+
'agentsmd-trash-restore': (args) => agentsMdService.trashRestore(String((args && args.id) || '')),
|
|
2941
|
+
'agentsmd-trash-delete': (args) => agentsMdService.trashDelete(String((args && args.id) || '')),
|
|
2543
2942
|
// History(归档会话管理)ops。只读:history-list / history-retention-get;
|
|
2544
2943
|
// 写:history-archive / history-unarchive / history-delete / history-retention-set。
|
|
2545
2944
|
// workspaceRegistry 缺失(补丁未生效)时返回明确错误,不崩页面。
|
|
@@ -2742,6 +3141,7 @@ export default {
|
|
|
2742
3141
|
mayDelete: assessment.mayDelete,
|
|
2743
3142
|
verifiedVersion: VERIFIED_HOST_VERSION,
|
|
2744
3143
|
expectedPeerRange: EXPECTED_PEER_RANGE,
|
|
3144
|
+
minHostVersion: EXPECTED_MIN_HOST_VERSION,
|
|
2745
3145
|
generatedAt: assessment.generatedAt,
|
|
2746
3146
|
summary,
|
|
2747
3147
|
};
|
|
@@ -2750,6 +3150,30 @@ export default {
|
|
|
2750
3150
|
return { ok: false, error: message(e) };
|
|
2751
3151
|
}
|
|
2752
3152
|
},
|
|
3153
|
+
// 预设可达性矩阵:每个 Agent 预设下,本插件的注入类能力到不到得了模型。
|
|
3154
|
+
// 只读预设组合文本,不挂载任何预设、不改任何数据。回答的是"面板上说注入了,
|
|
3155
|
+
// 模型真的看得到吗" —— minimal 这类 persona complete 的预设会压制全部提示词段。
|
|
3156
|
+
'preset-reach': async () => {
|
|
3157
|
+
try {
|
|
3158
|
+
// 宿主平面的 MCP 工具数:四个官方预设都不在组合里挂 MCP,MCP 由
|
|
3159
|
+
// $DSH_HOME/cordis.patch.yml 这一层挂载,所以它对所有预设一视同仁;
|
|
3160
|
+
// 这一列要回答的只是"现在有没有 MCP 工具可用"。探测失败 → 不传该字段,
|
|
3161
|
+
// 矩阵那一格显示"判断不了"而不是猜。
|
|
3162
|
+
let mcpTools;
|
|
3163
|
+
try {
|
|
3164
|
+
const schemas = await tools.schemas();
|
|
3165
|
+
mcpTools = schemas.filter((s) => String(s && s.name || '').startsWith('mcp__')).length;
|
|
3166
|
+
}
|
|
3167
|
+
catch {
|
|
3168
|
+
mcpTools = undefined;
|
|
3169
|
+
}
|
|
3170
|
+
const report = await assessPresetReach(presetRoster(), { mcpTools });
|
|
3171
|
+
return { ok: true, ...report };
|
|
3172
|
+
}
|
|
3173
|
+
catch (e) {
|
|
3174
|
+
return { ok: false, error: message(e) };
|
|
3175
|
+
}
|
|
3176
|
+
},
|
|
2753
3177
|
'history-unarchive': async (args) => {
|
|
2754
3178
|
const registry = getHistoryRegistry();
|
|
2755
3179
|
if (!registry)
|
|
@@ -2892,6 +3316,130 @@ export default {
|
|
|
2892
3316
|
},
|
|
2893
3317
|
// 把选中的归档会话导出为可再导入的转录文件(Markdown / JSONL),写入指定目录。
|
|
2894
3318
|
// 每个会话一个文件;读不到正文的冷会话列入 skipped,不中断其余导出。
|
|
3319
|
+
// ---------- 通用导出(D14)----------
|
|
3320
|
+
// 技能 / 子智能体 / 提示词 / 记忆四个域的差异只有「从哪取哪些文件」,其余
|
|
3321
|
+
//(绝对路径校验、建目录、zip 打包、错误回报)完全相同 —— 所以只加**一个** op。
|
|
3322
|
+
// 打包用已在依赖里的 `fflate.zipSync`(此前只用了 unzipSync)。
|
|
3323
|
+
'bundle-export': async (args) => {
|
|
3324
|
+
const kind = String((args && args.kind) || '');
|
|
3325
|
+
const KINDS = ['skills', 'subagents', 'presets', 'memories'];
|
|
3326
|
+
if (KINDS.indexOf(kind) < 0)
|
|
3327
|
+
return { ok: false, error: `kind 需为 ${KINDS.join(' / ')}` };
|
|
3328
|
+
const rawNames = (args && args.names) || [];
|
|
3329
|
+
const names = Array.isArray(rawNames) ? rawNames.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
3330
|
+
if (!names.length)
|
|
3331
|
+
return { ok: false, error: '请至少选择一项' };
|
|
3332
|
+
const outDir = String((args && args.outDir) || '').trim();
|
|
3333
|
+
// 与 history-export 同一条校验:必须绝对路径(相对路径会在宿主进程的 cwd 下落盘,
|
|
3334
|
+
// 用户根本找不到文件)。
|
|
3335
|
+
if (!/^([A-Za-z]:[\\/]|\\\\|\/)/.test(outDir))
|
|
3336
|
+
return { ok: false, error: '导出目录需为绝对路径' };
|
|
3337
|
+
// ① 收集 (zip 内路径, 绝对路径)。
|
|
3338
|
+
const entries = [];
|
|
3339
|
+
const missing = [];
|
|
3340
|
+
try {
|
|
3341
|
+
if (kind === 'subagents') {
|
|
3342
|
+
const dir = defaultPersonasDir();
|
|
3343
|
+
for (const name of names)
|
|
3344
|
+
entries.push({ zip: `${name}.md`, abs: join(dir, `${name}.md`) });
|
|
3345
|
+
}
|
|
3346
|
+
else if (kind === 'presets') {
|
|
3347
|
+
for (const id of names) {
|
|
3348
|
+
entries.push({ zip: `${id}/AGENTS.md`, abs: join(agentsMdPresetsDir, id, 'AGENTS.md') });
|
|
3349
|
+
// 描述侧车(「只给使用者看」的那句)随预设一起走:导出再导入不该把它丢掉。
|
|
3350
|
+
const metaAbs = join(agentsMdPresetsDir, id, 'meta.json');
|
|
3351
|
+
try {
|
|
3352
|
+
if ((await stat(metaAbs)).isFile())
|
|
3353
|
+
entries.push({ zip: `${id}/meta.json`, abs: metaAbs });
|
|
3354
|
+
}
|
|
3355
|
+
catch { /* 没写描述 */ }
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
else if (kind === 'memories') {
|
|
3359
|
+
// 记忆有 flat(`<场景>/<name>.md`)与 bundle(`<场景>/<name>/SKILL.md`)两种形态,
|
|
3360
|
+
// 且场景名本身可含 '/' —— 按 id 拼路径会把 bundle 读成 `<场景>/<name>.md`,读不到
|
|
3361
|
+
// 就静默丢项(详见 planMemoryExport)。所以走索引拿那条规则自己的真实路径。
|
|
3362
|
+
const r = await rulesService.ops['rules-list']({});
|
|
3363
|
+
const plan = planMemoryExport(names, (r && r.rules) || []);
|
|
3364
|
+
missing.push(...plan.missing);
|
|
3365
|
+
for (const item of plan.entries) {
|
|
3366
|
+
if (item.kind === 'file') {
|
|
3367
|
+
entries.push({ zip: item.zip, abs: item.abs });
|
|
3368
|
+
continue;
|
|
3369
|
+
}
|
|
3370
|
+
// bundle:把目录内的文件全部打包(与技能分支同口径),单个目录读不了只丢它自己。
|
|
3371
|
+
try {
|
|
3372
|
+
for (const e of await readdir(item.abs, { withFileTypes: true })) {
|
|
3373
|
+
if (e.isFile())
|
|
3374
|
+
entries.push({ zip: `${item.zip}/${e.name}`, abs: join(item.abs, e.name) });
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
catch {
|
|
3378
|
+
missing.push(item.id);
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
else {
|
|
3383
|
+
// 技能:先经 skill-detail 拿源文件路径(bundle 型是目录,单文件型是 .md)。
|
|
3384
|
+
for (const key of names) {
|
|
3385
|
+
const i = key.indexOf('/');
|
|
3386
|
+
if (i <= 0) {
|
|
3387
|
+
missing.push(key);
|
|
3388
|
+
continue;
|
|
3389
|
+
}
|
|
3390
|
+
const short = key.slice(i + 1);
|
|
3391
|
+
const r = await skillsService.ops['skill-detail']({ root: key.slice(0, i), name: short });
|
|
3392
|
+
const p = r && r.ok !== false && r.data ? String(r.data.path || '') : '';
|
|
3393
|
+
if (!p) {
|
|
3394
|
+
missing.push(key);
|
|
3395
|
+
continue;
|
|
3396
|
+
}
|
|
3397
|
+
let isDir = false;
|
|
3398
|
+
try {
|
|
3399
|
+
isDir = (await stat(p)).isDirectory();
|
|
3400
|
+
}
|
|
3401
|
+
catch {
|
|
3402
|
+
isDir = false;
|
|
3403
|
+
}
|
|
3404
|
+
if (isDir) {
|
|
3405
|
+
for (const e of await readdir(p, { withFileTypes: true })) {
|
|
3406
|
+
if (e.isFile())
|
|
3407
|
+
entries.push({ zip: `${short}/${e.name}`, abs: join(p, e.name) });
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
else {
|
|
3411
|
+
entries.push({ zip: `${short}/${basename(p)}`, abs: p });
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
catch (e) {
|
|
3417
|
+
return { ok: false, error: '收集待导出文件失败: ' + message(e) };
|
|
3418
|
+
}
|
|
3419
|
+
// ② 读盘 + 打包(读不到的项进 missing,不整体失败 —— 部分成功也有价值)。
|
|
3420
|
+
const bundle = {};
|
|
3421
|
+
for (const item of entries) {
|
|
3422
|
+
try {
|
|
3423
|
+
bundle[item.zip] = new Uint8Array(await readFile(item.abs));
|
|
3424
|
+
}
|
|
3425
|
+
catch {
|
|
3426
|
+
missing.push(item.zip);
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
if (!Object.keys(bundle).length)
|
|
3430
|
+
return { ok: false, error: '没有可导出的文件(选中项都不存在?)' };
|
|
3431
|
+
let zipPath = '';
|
|
3432
|
+
try {
|
|
3433
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
3434
|
+
zipPath = join(outDir, `dsh-tool-management-${kind}-${stamp}.zip`);
|
|
3435
|
+
await mkdir(outDir, { recursive: true });
|
|
3436
|
+
await writeFile(zipPath, Buffer.from(zipSync(bundle)));
|
|
3437
|
+
}
|
|
3438
|
+
catch (e) {
|
|
3439
|
+
return { ok: false, error: '写入导出文件失败: ' + message(e) };
|
|
3440
|
+
}
|
|
3441
|
+
return { ok: true, written: Object.keys(bundle).length, zipPath, missing };
|
|
3442
|
+
},
|
|
2895
3443
|
'history-export': async (args) => {
|
|
2896
3444
|
const rawIds = (args && args.sessionIds) || [];
|
|
2897
3445
|
const ids = Array.isArray(rawIds) ? rawIds.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
@@ -2987,6 +3535,87 @@ export default {
|
|
|
2987
3535
|
return { ok: true, retentionDays: days };
|
|
2988
3536
|
},
|
|
2989
3537
|
};
|
|
3538
|
+
// MCP 写操作成功后立即重算状态段(SWR:text() 同步返回缓存值,写后主动 refresh)。
|
|
3539
|
+
// 覆盖所有会改变「哪些 server 可用 / 有哪些备注」的写入口 —— 集中在这一处包,
|
|
3540
|
+
// 不需要在界面层逐个补(漏一个就会出现「改了但模型看不到」的静默不一致)。
|
|
3541
|
+
for (const opName of ['mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all',
|
|
3542
|
+
'mcpm-restart', 'mcpm-note', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact']) {
|
|
3543
|
+
const original = handlers[opName];
|
|
3544
|
+
if (typeof original !== 'function')
|
|
3545
|
+
continue;
|
|
3546
|
+
handlers[opName] = async (args) => {
|
|
3547
|
+
const result = await original(args);
|
|
3548
|
+
if (result && result.ok !== false)
|
|
3549
|
+
void mcpStateCatalog.refresh();
|
|
3550
|
+
return result;
|
|
3551
|
+
};
|
|
3552
|
+
}
|
|
3553
|
+
// ---------- 场景锁定守卫(v0.8)----------
|
|
3554
|
+
// 任一场景 locked=true = 五个管理域(MCP/技能/子智能体/记忆/提示词)整体冻结:
|
|
3555
|
+
// 下面列出的写 op 一律拒绝;界面按钮同步禁用,这里是兜底(防止绕过界面直接打 op)。
|
|
3556
|
+
// 只包 **handlers** 这一层是有意的:进/退模式的运行时应用走的是内部函数与
|
|
3557
|
+
// service.ops(applyMcpServerSwitches / applySkills / applySubagentSwitches / patchIndex),
|
|
3558
|
+
// 不经过 handlers —— 锁定就是为了让场景能按原样启动,运行时应用不能被自己挡住。
|
|
3559
|
+
// 例外里的例外:scene-archive-save / rules-remove-scene 只对**被锁的那个场景**拒绝。
|
|
3560
|
+
async function lockedSceneNames() {
|
|
3561
|
+
const r = await rulesService.ops['rules-list']({});
|
|
3562
|
+
return ((r && r.scenes) || []).filter((s) => s.locked === true).map((s) => String(s.name));
|
|
3563
|
+
}
|
|
3564
|
+
function guardLockedOps(opNames, what) {
|
|
3565
|
+
for (const opName of opNames) {
|
|
3566
|
+
const original = handlers[opName];
|
|
3567
|
+
if (typeof original !== 'function')
|
|
3568
|
+
continue;
|
|
3569
|
+
handlers[opName] = async (args) => {
|
|
3570
|
+
const locked = await lockedSceneNames();
|
|
3571
|
+
if (locked.length)
|
|
3572
|
+
return { ok: false, error: `场景已锁定(${locked.join('、')}):先到场景页解锁再${what}` };
|
|
3573
|
+
return original(args);
|
|
3574
|
+
};
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
guardLockedOps([
|
|
3578
|
+
// MCP:改配置 / 服务器启停 / 工具启停 / 导入导出配置 / 备注 / 设置。restart 只重连不改配置,放行。
|
|
3579
|
+
'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact', 'mcpm-note', 'mcpm-settings',
|
|
3580
|
+
// 技能:启停 / 来源启停与移除恢复 / 首选 / 删除 / 创建导入 / 自定义目录 / 回收站。
|
|
3581
|
+
'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable', 'skill-source-remove', 'skill-source-restore',
|
|
3582
|
+
'skill-prefer', 'skill-unprefer', 'skill-delete', 'skill-create', 'skill-import', 'skill-upload',
|
|
3583
|
+
'skill-custom-add', 'skill-custom-remove', 'skill-trash-restore', 'skill-trash-delete',
|
|
3584
|
+
// 子智能体:开关 / 改名保存 / 删除 / 导入 / 回收站。
|
|
3585
|
+
'subagent-create', 'subagent-update', 'subagent-delete', 'subagent-toggle', 'subagent-import', 'subagent-trash-restore', 'subagent-trash-delete',
|
|
3586
|
+
// 记忆:增删改 / 开关 / 导入 / 回收站 / 绑定。set-active 是场景启停,不在冻结范围。
|
|
3587
|
+
'rules-create', 'rules-update', 'rules-remove', 'rules-toggle', 'rules-import', 'rules-restore', 'rules-trash-remove', 'rules-attach', 'rules-detach', 'rules-set-index',
|
|
3588
|
+
// 提示词:建改删 / 应用(切换生效基线)/ 导入 / 回收站。
|
|
3589
|
+
'agentsmd-create', 'agentsmd-update', 'agentsmd-remove', 'agentsmd-apply', 'agentsmd-import', 'agentsmd-trash-restore', 'agentsmd-trash-delete',
|
|
3590
|
+
], '修改');
|
|
3591
|
+
// 被锁场景自身的档案与删除:只挡它自己,别的场景照常。
|
|
3592
|
+
for (const [opName, pickScene, what] of [
|
|
3593
|
+
['scene-archive-save', (args) => (args && args.scene) || '', '改档案'],
|
|
3594
|
+
['rules-remove-scene', (args) => (args && args.name) || '', '删除'],
|
|
3595
|
+
]) {
|
|
3596
|
+
const original = handlers[opName];
|
|
3597
|
+
if (typeof original !== 'function')
|
|
3598
|
+
continue;
|
|
3599
|
+
handlers[opName] = async (args) => {
|
|
3600
|
+
const scene = String(pickScene(args) || '').trim();
|
|
3601
|
+
const locked = await lockedSceneNames();
|
|
3602
|
+
if (scene && locked.includes(scene))
|
|
3603
|
+
return { ok: false, error: `场景「${scene}」已锁定:先解锁再${what}` };
|
|
3604
|
+
return original(args);
|
|
3605
|
+
};
|
|
3606
|
+
}
|
|
3607
|
+
// 各页列表响应带上 anyLocked:界面据此禁用写控件(读 op,附加字段不影响既有消费方)。
|
|
3608
|
+
async function annotateLocked(res) {
|
|
3609
|
+
if (res && res.ok !== false)
|
|
3610
|
+
res.anyLocked = (await lockedSceneNames()).length > 0;
|
|
3611
|
+
return res;
|
|
3612
|
+
}
|
|
3613
|
+
for (const opName of ['mcpm-list', 'mcpm-reveal', 'skill-state', 'subagent-list', 'agentsmd-list']) {
|
|
3614
|
+
const original = handlers[opName];
|
|
3615
|
+
if (typeof original !== 'function')
|
|
3616
|
+
continue;
|
|
3617
|
+
handlers[opName] = async (args) => annotateLocked(await original(args));
|
|
3618
|
+
}
|
|
2990
3619
|
// ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
|
|
2991
3620
|
const text = (value) => [{ type: 'text', text: value }];
|
|
2992
3621
|
tools.register(defineTool({
|
|
@@ -3120,17 +3749,20 @@ export default {
|
|
|
3120
3749
|
description: 'List AGENTS.md presets in the plugin preset library (id, active state).',
|
|
3121
3750
|
parameters: {},
|
|
3122
3751
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3123
|
-
async execute() {
|
|
3752
|
+
async execute(_args, exec) {
|
|
3124
3753
|
const r = await agentsMdService.list();
|
|
3125
3754
|
if (!r.ok)
|
|
3126
3755
|
throw new Error(r.error);
|
|
3127
3756
|
const summary = r.presets.map((p) => p.id + (p.active ? ' [active]' : ''));
|
|
3128
|
-
|
|
3757
|
+
// 同上:AGENTS.md 由 dsh-agent-instructions 行承载,预设没挂这一行(或 persona
|
|
3758
|
+
// 是 complete)时文件内容不会进提示词。
|
|
3759
|
+
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
|
|
3760
|
+
return 'AGENTS.md presets:\n' + (summary.join('\n') || '(none)') + '\n(DSH re-reads ~/.dsh/AGENTS.md every turn, so applying takes effect on the next turn.)' + notice;
|
|
3129
3761
|
},
|
|
3130
3762
|
}));
|
|
3131
3763
|
tools.register(defineTool({
|
|
3132
3764
|
name: 'agentsmd_apply',
|
|
3133
|
-
description: 'Apply one AGENTS.md preset by writing it to ~/.dsh/AGENTS.md.
|
|
3765
|
+
description: 'Apply one AGENTS.md preset by writing it to ~/.dsh/AGENTS.md. DSH re-reads that file every turn, so it takes effect on the next turn.',
|
|
3134
3766
|
parameters: {
|
|
3135
3767
|
id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens).' },
|
|
3136
3768
|
},
|
|
@@ -3153,15 +3785,18 @@ export default {
|
|
|
3153
3785
|
group: { type: 'string', description: 'Optional scene filter.' },
|
|
3154
3786
|
},
|
|
3155
3787
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
3156
|
-
async execute(args) {
|
|
3788
|
+
async execute(args, exec) {
|
|
3157
3789
|
const r = await rulesService.ops['rules-list'](args);
|
|
3158
3790
|
if (!r || r.ok === false)
|
|
3159
3791
|
throw new Error((r && r.error) || '读取规则失败');
|
|
3160
3792
|
const lines = (r.rules || []).map((x) => ('- ' + x.id + ' [' + (x.group || '未归属场景') + '] ' + (x.enabled ? '已启用' : '已停用') +
|
|
3161
3793
|
(x.description ? ' — ' + x.description : '')));
|
|
3162
3794
|
const scenes = (r.scenes || []).map((s) => (s.label || s.name) + (s.active ? '(启用)' : '(未启用)')).join('、');
|
|
3795
|
+
// 注入边界:预设可能压制提示词段(complete persona,或未挂 agent-instructions),
|
|
3796
|
+
// 此时列出的记忆**不在**模型上下文里。必须说出来,否则模型会假设自己已经看到正文。
|
|
3797
|
+
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
|
|
3163
3798
|
return '记忆(' + (r.rules || []).length + '):\n' + (lines.join('\n') || '(无记忆)') +
|
|
3164
|
-
'\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)');
|
|
3799
|
+
'\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)') + notice;
|
|
3165
3800
|
},
|
|
3166
3801
|
}));
|
|
3167
3802
|
tools.register(defineTool({
|
|
@@ -3201,7 +3836,9 @@ export default {
|
|
|
3201
3836
|
// 与其余 12 个工具同一条注册通道:defineTool 负责编译 parameters(object root + required),
|
|
3202
3837
|
// 裸 register 会把未编译的参数声明直接发给模型 API。
|
|
3203
3838
|
tools.register(defineTool(defineSubagentListTool({ list: () => subagentService.list(), sceneLists: subagentSceneLists })));
|
|
3204
|
-
|
|
3839
|
+
// 工具限制按**当前会话的 Agent 预设**下发:父会话跑在哪个预设,就用那个预设那一行的
|
|
3840
|
+
// 白/黑名单(`decideToolFilter`),名单里已消失的工具名会被丢掉并在结果里如实说明。
|
|
3841
|
+
tools.register(defineTool(defineSubagentRunTool({ ...subagentService, sceneLists: subagentSceneLists, toolFilterFor: subagentToolFilterFor })));
|
|
3205
3842
|
}
|
|
3206
3843
|
catch (e) {
|
|
3207
3844
|
console.error('[dsh-plugin-tool-management] subagent tool registration failed:', message(e));
|
|
@@ -3377,7 +4014,6 @@ export default {
|
|
|
3377
4014
|
res.end(JSON.stringify({ ok: false, error: 'missing plugin gate header' }));
|
|
3378
4015
|
return;
|
|
3379
4016
|
}
|
|
3380
|
-
const origin = hdr('origin');
|
|
3381
4017
|
const tokenAccepted = TOKEN !== '' && hdr('x-dsh-token') === TOKEN;
|
|
3382
4018
|
if (!tokenAccepted) {
|
|
3383
4019
|
const fence = fenceRejection(req, connection);
|
|
@@ -3401,12 +4037,16 @@ export default {
|
|
|
3401
4037
|
/* otherwise fall through with {} */
|
|
3402
4038
|
}
|
|
3403
4039
|
const op = String(payload.op || '');
|
|
3404
|
-
// 敏感 op
|
|
3405
|
-
//
|
|
3406
|
-
//
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
4040
|
+
// 敏感 op(会吐明文密钥与完整配置):**必须**带对的访问令牌,没配令牌
|
|
4041
|
+
// 就没有明文(判定与理由见 http-fence.ts 的 secretOpRejection)。
|
|
4042
|
+
// 之前这里只查"有没有 Origin 头",而 token 为空时它形同虚设 —— 任何能
|
|
4043
|
+
// 打开 GUI 的浏览器都拿得到全部密钥原文。
|
|
4044
|
+
if (SENSITIVE_OPS.has(op)) {
|
|
4045
|
+
const gate = secretOpRejection({ tokenConfigured: TOKEN !== '', tokenAccepted });
|
|
4046
|
+
if (gate) {
|
|
4047
|
+
res.end(JSON.stringify({ ok: false, error: gate.error, code: gate.code }));
|
|
4048
|
+
return;
|
|
4049
|
+
}
|
|
3410
4050
|
}
|
|
3411
4051
|
if (TOKEN && WRITE_OPS.has(op) && hdr('x-dsh-token') !== TOKEN) {
|
|
3412
4052
|
res.end(JSON.stringify({ ok: false, error: '缺少或错误的访问令牌(x-dsh-token)' }));
|