dsh-plugin-tool-management 0.15.0 → 0.16.5
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 +49 -0
- package/docs/update.md +29 -0
- package/lib/client.js +242 -123
- package/lib/compat/probe.js +207 -19
- package/lib/mcp/manager.js +177 -14
- package/lib/memories/service.js +9 -0
- package/lib/ops/compat.js +19 -3
- package/lib/skills/core.js +183 -9
- package/lib/skills/service.js +5 -1
- package/package.json +24 -23
package/lib/compat/probe.js
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import { createRequire } from 'node:module';
|
|
33
33
|
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
34
34
|
import { dirname, join } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
35
36
|
/** Packages whose physical module identity matters to this plugin. */
|
|
36
37
|
export const IDENTITY_PACKAGES = [
|
|
37
38
|
'@deepseek-ai/cordis',
|
|
@@ -42,29 +43,55 @@ export const IDENTITY_PACKAGES = [
|
|
|
42
43
|
'@deepseek-ai/dsh-spill-local',
|
|
43
44
|
];
|
|
44
45
|
/**
|
|
45
|
-
*
|
|
46
|
+
* 本插件是不是从**源码检出**跑起来的(仓库里那份,`scripts/host-deps.mjs` 就躺在旁边)。
|
|
47
|
+
*
|
|
48
|
+
* 用来决定「两份拷贝」那条阻塞项给什么修复指引:`scripts/` 不在 `package.json` 的
|
|
49
|
+
* `files` 里(发布形态本就不该带开发者脚本),所以 npm 装出来的用户照那条指引去跑
|
|
50
|
+
* 只会拿到 `MODULE_NOT_FOUND` —— 这是 issue #1 的次要问题之一。指引必须按实际形态给。
|
|
51
|
+
*/
|
|
52
|
+
const SOURCE_INSTALL = existsSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts', 'host-deps.mjs'));
|
|
53
|
+
/** 供界面决定修复指引:`true` = 源码检出(`scripts/` 就在旁边),`false` = npm 安装形态。 */
|
|
54
|
+
export function isSourceInstall() {
|
|
55
|
+
return SOURCE_INSTALL;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* peer range 与已验证版本。官方全程走 prerelease 渠道(x.y.z-rc.N),semver 的
|
|
46
59
|
* 预发布规则(prerelease 版本只匹配同 [major,minor,patch] 元组的比较器)意味着**每一代
|
|
47
60
|
* rc 都要显式列进范围**:`>=0.1.5-rc.2` 匹配不了 `0.1.7-rc.2`(实测),所以范围是逐代
|
|
48
61
|
* 枚举的并集。上界依旧不设:宿主跨代升级由运行时能力探测兜底(见下)。
|
|
62
|
+
*
|
|
63
|
+
* 0.2.0-rc.2(桌面版首发)已实测通过:`assessHost` 18/18 全通过,宿主版本读得出来,
|
|
64
|
+
* 归档宿主的身份判定见 `hostPackageRoot` / `isAsarPath`。所以它同时进范围与「已验证」。
|
|
49
65
|
*/
|
|
50
|
-
export const EXPECTED_PEER_RANGE = '^0.1.5-rc.2 || ^0.1.7-rc.2';
|
|
66
|
+
export const EXPECTED_PEER_RANGE = '^0.1.5-rc.2 || ^0.1.7-rc.2 || ^0.2.0-rc.2';
|
|
51
67
|
/** The release this plugin's adapters were last verified against. */
|
|
52
|
-
export const VERIFIED_HOST_VERSION = '0.
|
|
68
|
+
export const VERIFIED_HOST_VERSION = '0.2.0-rc.2';
|
|
53
69
|
/**
|
|
54
|
-
* peer range
|
|
70
|
+
* peer range 的下界。
|
|
55
71
|
*
|
|
56
72
|
* 刻意不带 semver 上界:官方持续发版,硬上界会在宿主跨 minor 升级时直接挡住
|
|
57
73
|
* 插件安装(pnpm peer 校验失败)。上界改为由**运行时能力探测**兜底 ——
|
|
58
74
|
* 宿主版本高于已验证范围时,`assessHost` 会逐项探测并把缺失能力降级/禁用,
|
|
59
|
-
*
|
|
75
|
+
* 而不是在安装期拒绝整包。
|
|
60
76
|
* 从 EXPECTED_PEER_RANGE 派生,避免两处手写漂移。
|
|
77
|
+
*
|
|
78
|
+
* 2026-09-29 起**不再出现在界面上**(用户裁定:删掉「最低要求 ≥ …」那一行 —— 它是安装期
|
|
79
|
+
* 门槛,摆在"当前宿主"卡片下面会被读成对当前宿主的断言)。保留此导出是因为它进了
|
|
80
|
+
* `compat-status` 的载荷,doctor 与外部脚本可能仍在读。
|
|
61
81
|
*/
|
|
62
82
|
export const EXPECTED_MIN_HOST_VERSION = EXPECTED_PEER_RANGE.match(/(\d+\.\d+\.\d+(?:-rc\.\d+)?)/)?.[1] ?? VERIFIED_HOST_VERSION;
|
|
63
83
|
const isFn = (value) => typeof value === 'function';
|
|
64
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a package the way this plugin resolves it, without throwing.
|
|
86
|
+
*
|
|
87
|
+
* `import.meta.resolve` 给的是**URL**:目录里带空格的路径(桌面版装在
|
|
88
|
+
* `D:\DeepSeek Harness\...`)会以 `%20` 形式回来,直接当路径用会得到一个
|
|
89
|
+
* 永远打不开的名字 —— 版本号读不出来(显示 `unknown`)、asar 路径也匹配不上。
|
|
90
|
+
* 用 `fileURLToPath` 老实解码(它同时处理盘符与百分号转义)。
|
|
91
|
+
*/
|
|
65
92
|
function safeResolve(specifier) {
|
|
66
93
|
try {
|
|
67
|
-
return import.meta.resolve(specifier)
|
|
94
|
+
return fileURLToPath(import.meta.resolve(specifier));
|
|
68
95
|
}
|
|
69
96
|
catch {
|
|
70
97
|
try {
|
|
@@ -75,21 +102,41 @@ function safeResolve(specifier) {
|
|
|
75
102
|
}
|
|
76
103
|
}
|
|
77
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Whether a path lives inside an electron `app.asar` archive.
|
|
107
|
+
*
|
|
108
|
+
* 桌面版把整个 DSH 装进 `resources/app.asar`:宿主的 `@deepseek-ai/*` 物理上就在
|
|
109
|
+
* 归档里,插件(装在 `$DSH_HOME/profiles/<name>/node_modules`)在外面的磁盘上。
|
|
110
|
+
* 归档里的模块**不可能**与外面的目录是同一个物理文件,`@deepseek-ai/*` 又是
|
|
111
|
+
* peer(官方包由宿主提供),所以「不同路径」是这种打包方式的必然结果,不是安装
|
|
112
|
+
* 故障。判据取 `.asar` 这个路径段(`join` 出来的分隔符在 Windows 上是 `\`)。
|
|
113
|
+
*/
|
|
114
|
+
function isAsarPath(value) {
|
|
115
|
+
if (value === null)
|
|
116
|
+
return false;
|
|
117
|
+
return /[\\/][^\\/]+\.asar[\\/]/i.test(value);
|
|
118
|
+
}
|
|
78
119
|
/**
|
|
79
120
|
* Resolve a path through every junction/symlink to its physical file.
|
|
80
121
|
* Node loads modules by real path, so two different-looking paths that
|
|
81
122
|
* realpath to one file ARE the same module — which is exactly the property
|
|
82
123
|
* this plugin depends on, and the reason `@deepseek-ai/*` is junctioned into
|
|
83
124
|
* the host installation instead of being copied.
|
|
125
|
+
*
|
|
126
|
+
* 取不到真实形态时**不再拿输入去比**(issue #1 要求):那会把「路径不可解析」
|
|
127
|
+
* 伪装成「两份不同拷贝」,给出一个编出来的结论。改由调用方把原因报成说明。
|
|
84
128
|
*/
|
|
85
|
-
function
|
|
129
|
+
function realPathWithReason(value) {
|
|
86
130
|
if (value === null)
|
|
87
|
-
return
|
|
131
|
+
return { reason: '路径为空' };
|
|
88
132
|
try {
|
|
89
|
-
return realpathSync.native !== undefined ? realpathSync.native(value) : realpathSync(value);
|
|
133
|
+
return { path: realpathSync.native !== undefined ? realpathSync.native(value) : realpathSync(value) };
|
|
90
134
|
}
|
|
91
|
-
catch {
|
|
92
|
-
|
|
135
|
+
catch (error) {
|
|
136
|
+
const detail = error;
|
|
137
|
+
const code = typeof detail.code === 'string' ? detail.code : '';
|
|
138
|
+
const message = typeof detail.message === 'string' ? detail.message : String(error);
|
|
139
|
+
return { reason: code === '' ? message : `${code} ${message}` };
|
|
93
140
|
}
|
|
94
141
|
}
|
|
95
142
|
/** Read a package version from a resolvable manifest. */
|
|
@@ -114,6 +161,46 @@ function versionOf(specifier) {
|
|
|
114
161
|
}
|
|
115
162
|
return undefined;
|
|
116
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Version of the package a resolved entry path belongs to: walk up until a
|
|
166
|
+
* `package.json` whose `name` matches the package directory we came from.
|
|
167
|
+
*
|
|
168
|
+
* 与 `versionOf` 的分工:那个从**说明符**出发(要能解析),这个从**已经拿到的路径**
|
|
169
|
+
* 出发。归档宿主那种「路径不同但版本相同」的判断只能用后者 —— 前者在 `.asar` 里
|
|
170
|
+
* 读得动,但两边路径不同时它无法回答「这两个文件各属于哪个版本」。
|
|
171
|
+
*/
|
|
172
|
+
function versionOfPackageAt(entry) {
|
|
173
|
+
const expected = packageNameOfPath(entry);
|
|
174
|
+
let dir = dirname(entry);
|
|
175
|
+
for (let i = 0; i < 5; i += 1) {
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
178
|
+
if (typeof parsed.version === 'string' && (expected === null || parsed.name === expected))
|
|
179
|
+
return parsed.version;
|
|
180
|
+
}
|
|
181
|
+
catch { /* keep walking up */ }
|
|
182
|
+
const parent = dirname(dir);
|
|
183
|
+
if (parent === dir)
|
|
184
|
+
break;
|
|
185
|
+
dir = parent;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
/** `.../node_modules/@scope/name/lib/index.js` -> `@scope/name` (null when the path has no such segment). */
|
|
190
|
+
function packageNameOfPath(entry) {
|
|
191
|
+
const parts = entry.split(/[\\/]/);
|
|
192
|
+
const at = parts.lastIndexOf('node_modules');
|
|
193
|
+
if (at === -1 || at + 1 >= parts.length)
|
|
194
|
+
return null;
|
|
195
|
+
const first = parts[at + 1];
|
|
196
|
+
if (first === undefined)
|
|
197
|
+
return null;
|
|
198
|
+
if (first.startsWith('@')) {
|
|
199
|
+
const second = parts[at + 2];
|
|
200
|
+
return second === undefined ? null : `${first}/${second}`;
|
|
201
|
+
}
|
|
202
|
+
return first;
|
|
203
|
+
}
|
|
117
204
|
/**
|
|
118
205
|
* Compare a live member with this plugin's copy of the implementation.
|
|
119
206
|
* `undefined` means "not comparable" — the member is absent on the live object
|
|
@@ -761,7 +848,17 @@ export function assessHost(ctx) {
|
|
|
761
848
|
const sameAsHost = {};
|
|
762
849
|
const blockers = [];
|
|
763
850
|
const unverified = [];
|
|
851
|
+
const notes = [];
|
|
764
852
|
const hostRoot = hostPackageRoot();
|
|
853
|
+
const hostIsAsar = isAsarPath(hostRoot);
|
|
854
|
+
// 桌面版(宿主在 app.asar 里)两组「版本」的容器:插件自己解析到的、宿主锚点解析到的。
|
|
855
|
+
const asarHostPackages = [];
|
|
856
|
+
// realpath 失败(路径取不到真实形态):两侧各算一次就够,与是哪个包无关。
|
|
857
|
+
const realpathFailures = [];
|
|
858
|
+
let hostRealpathNote = null;
|
|
859
|
+
let pluginRealpathNote = null;
|
|
860
|
+
let asarHostVersion = null;
|
|
861
|
+
let asarPluginVersion = null;
|
|
765
862
|
for (const name of IDENTITY_PACKAGES) {
|
|
766
863
|
const resolved = safeResolve(name);
|
|
767
864
|
modules[name] = resolved;
|
|
@@ -777,23 +874,83 @@ export function assessHost(ctx) {
|
|
|
777
874
|
}
|
|
778
875
|
// Resolve the same name from the host installation's own anchor, then
|
|
779
876
|
// compare PHYSICAL files: a junction is the same module, not a copy.
|
|
877
|
+
//
|
|
878
|
+
// 逐包解析、逐包取真实形态。realpath 的**机制**确实整轮不变,但上一版曾把
|
|
879
|
+
// 这句话错写成连**解析结果**一起复用:第一个包(cordis)的宿主侧入口路径被当成
|
|
880
|
+
// 其余五个包的宿主侧路径,于是五条全部比成「与 cordis 不同」→ 假「两份不同拷贝」
|
|
881
|
+
// (官方桌面版 0.2.0-rc.2 实测,2026-09-30;解析本就便宜,节点自身还有缓存)。
|
|
780
882
|
let hostResolved = null;
|
|
883
|
+
let hostRealpath = null;
|
|
781
884
|
try {
|
|
782
|
-
|
|
783
|
-
|
|
885
|
+
hostResolved = createRequire(join(dirname(hostRoot), 'package.json')).resolve(name);
|
|
886
|
+
hostRealpath = realPathWithReason(hostResolved);
|
|
784
887
|
}
|
|
785
888
|
catch {
|
|
786
889
|
hostResolved = null;
|
|
787
890
|
}
|
|
788
|
-
|
|
891
|
+
// 真实形态取不到 → 这一格是「比不了」,不是「两份拷贝」。理由如实上报,
|
|
892
|
+
// 绝不把路径问题说成模块问题(issue #1 的次要问题之二)。
|
|
893
|
+
if (hostRealpath !== null && 'reason' in hostRealpath) {
|
|
894
|
+
sameAsHost[name] = null;
|
|
895
|
+
hostRealpathNote = hostRealpathNote ?? hostRealpath.reason;
|
|
896
|
+
realpathFailures.push({ name, side: 'host', path: hostResolved ?? '(未解析到)', reason: hostRealpath.reason });
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const pluginRealpath = realPathWithReason(resolved);
|
|
900
|
+
if ('reason' in pluginRealpath) {
|
|
901
|
+
sameAsHost[name] = null;
|
|
902
|
+
pluginRealpathNote = pluginRealpathNote ?? pluginRealpath.reason;
|
|
903
|
+
realpathFailures.push({ name, side: 'plugin', path: resolved, reason: pluginRealpath.reason });
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const same = hostResolved === null ? null : hostRealpath.path === pluginRealpath.path;
|
|
907
|
+
// 归档宿主:路径必然不同(归档 vs 磁盘),能比的是版本。相同 → 这一格是
|
|
908
|
+
// 「比不了(同版本、打包方式不同)」,记进说明并从 unverified 里摘出来(那句
|
|
909
|
+
// 「宿主的解析锚点里找不到它」对它不成立 —— 锚点找得到,在归档里);不同 →
|
|
910
|
+
// 真正的版本错配,`same` 保持 false 照旧进 blockers(那是要修的)。
|
|
911
|
+
if (same === false && hostIsAsar && !isAsarPath(resolved) && hostResolved !== null) {
|
|
912
|
+
const hostVersion = versionOfPackageAt(hostResolved);
|
|
913
|
+
const pluginVersion = versionOfPackageAt(resolved);
|
|
914
|
+
if (hostVersion !== null && hostVersion === pluginVersion) {
|
|
915
|
+
asarHostPackages.push({ name, hostPath: hostResolved, pluginPath: resolved });
|
|
916
|
+
asarHostVersion = asarHostVersion ?? hostVersion;
|
|
917
|
+
asarPluginVersion = asarPluginVersion ?? pluginVersion;
|
|
918
|
+
sameAsHost[name] = null;
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
789
922
|
sameAsHost[name] = same;
|
|
790
|
-
if (same === false)
|
|
791
|
-
|
|
923
|
+
if (same === false) {
|
|
924
|
+
// 指引按**实际形态**给:`scripts/host-deps.mjs` 只存在于源码检出里
|
|
925
|
+
// (`files` 不含 `scripts/`,npm 安装形态下没有这个文件),
|
|
926
|
+
// 发布形态照那条指引去跑必然 `MODULE_NOT_FOUND` —— issue #1 的次要问题之一。
|
|
927
|
+
blockers.push(SOURCE_INSTALL
|
|
928
|
+
? `${name}:插件与宿主加载的是两份不同拷贝(在插件源码目录运行 node scripts/host-deps.mjs --fix)`
|
|
929
|
+
: `${name}:插件与宿主加载的是两份不同拷贝(升级或重装本插件后仍然如此再报)`);
|
|
930
|
+
}
|
|
792
931
|
// 解析得到、却比不了:宿主锚点里找不到它。不能静默 —— 否则整块身份校验等于没做,
|
|
793
932
|
// 而页头仍报「全部可用」(pnpm 的 .pnpm 隔离目录就是这种情形,见 hostPackageRoot)。
|
|
794
933
|
if (same === null)
|
|
795
934
|
unverified.push(name);
|
|
796
935
|
}
|
|
936
|
+
if (realpathFailures.length > 0) {
|
|
937
|
+
notes.push({
|
|
938
|
+
kind: 'realpath',
|
|
939
|
+
failures: realpathFailures,
|
|
940
|
+
...(hostRealpathNote === null ? {} : { hostReason: hostRealpathNote }),
|
|
941
|
+
...(pluginRealpathNote === null ? {} : { pluginReason: pluginRealpathNote }),
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
if (asarHostPackages.length > 0 && hostRoot !== null) {
|
|
945
|
+
notes.push({
|
|
946
|
+
kind: 'asar-host',
|
|
947
|
+
asarName: hostRoot.split(/[\\/]/).find((segment) => /\.asar$/i.test(segment)) ?? 'app.asar',
|
|
948
|
+
hostRoot,
|
|
949
|
+
hostVersion: asarHostVersion,
|
|
950
|
+
pluginVersion: asarPluginVersion,
|
|
951
|
+
packages: asarHostPackages,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
797
954
|
// Degraded = something is genuinely unavailable. Optional slots are excluded:
|
|
798
955
|
// their absence selects the adapter route and leaves the feature fully
|
|
799
956
|
// working, so listing them here would tell the user a working feature is
|
|
@@ -812,6 +969,7 @@ export function assessHost(ctx) {
|
|
|
812
969
|
sameAsHost,
|
|
813
970
|
blockers,
|
|
814
971
|
unverified,
|
|
972
|
+
notes,
|
|
815
973
|
},
|
|
816
974
|
findings,
|
|
817
975
|
degraded,
|
|
@@ -841,10 +999,19 @@ function hostPackageRoot() {
|
|
|
841
999
|
const candidates = [];
|
|
842
1000
|
if (home !== '')
|
|
843
1001
|
candidates.push(join(home, 'profiles', 'node_modules', '@deepseek-ai'));
|
|
1002
|
+
// 桌面版:整个 DSH 装进 `resources/app.asar`,宿主的 `@deepseek-ai/*` 就在归档里。
|
|
1003
|
+
// 这一候选必须排在 npx 缓存**之前** —— 否则装过 web 版的机器上会先命中 npx 缓存里
|
|
1004
|
+
// 那个 0.1.7,于是拿「另一个安装」当锚点比出 6 条假的「两份不同拷贝」(实测事故)。
|
|
1005
|
+
const asarHost = asarHostPackageRoot();
|
|
1006
|
+
if (asarHost !== null)
|
|
1007
|
+
candidates.push(asarHost);
|
|
844
1008
|
for (const anchor of IDENTITY_PACKAGES) {
|
|
845
|
-
|
|
846
|
-
|
|
1009
|
+
// 锚点候选只认**真实形态**:取不到就跳过(这里没有可比的对象,如实跳过即可,
|
|
1010
|
+
// 与身份比对不同 —— 那边取不到必须报出来,见 assessHost 的 realpath 说明)。
|
|
1011
|
+
const real = realPathWithReason(safeResolve(anchor));
|
|
1012
|
+
if ('reason' in real)
|
|
847
1013
|
continue;
|
|
1014
|
+
const resolved = real.path;
|
|
848
1015
|
let dir = dirname(resolved);
|
|
849
1016
|
for (let i = 0; i < 4; i += 1) {
|
|
850
1017
|
if (dir.endsWith(join('node_modules', '@deepseek-ai'))) {
|
|
@@ -868,6 +1035,27 @@ function hostPackageRoot() {
|
|
|
868
1035
|
}
|
|
869
1036
|
return null;
|
|
870
1037
|
}
|
|
1038
|
+
/**
|
|
1039
|
+
* 正在运行的桌面版 DSH 的 `@deepseek-ai` 目录(在 `resources/app.asar` 里),没有则 null。
|
|
1040
|
+
*
|
|
1041
|
+
* 判据是 `process.resourcesPath`(electron 才有)指向的归档里确实躺着 `dsh` —— 不靠
|
|
1042
|
+
* 包名或环境变量猜,因此在 `dsh web`(node 跑)下一律返回 null。归档内路径由 electron
|
|
1043
|
+
* 自己的 fs 修补解析,归档外的普通 node 读不到,所以这一步失败是静默的正常路径。
|
|
1044
|
+
*/
|
|
1045
|
+
function asarHostPackageRoot() {
|
|
1046
|
+
const resources = process.resourcesPath;
|
|
1047
|
+
if (typeof resources !== 'string' || resources === '')
|
|
1048
|
+
return null;
|
|
1049
|
+
const dir = join(resources, 'app.asar', 'dsh', 'node_modules', '@deepseek-ai');
|
|
1050
|
+
try {
|
|
1051
|
+
if (!existsSync(join(dir, 'dsh', 'package.json')))
|
|
1052
|
+
return null;
|
|
1053
|
+
}
|
|
1054
|
+
catch {
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
1057
|
+
return dir;
|
|
1058
|
+
}
|
|
871
1059
|
/**
|
|
872
1060
|
* npx 缓存里的宿主 `@deepseek-ai` 目录,按目录 mtime 新到旧排。npm 缓存位置依次看
|
|
873
1061
|
* `npm_config_cache`、`~/.npmrc` 的 `cache=`、`%LOCALAPPDATA%\npm-cache` —— probe 全程同步,
|
package/lib/mcp/manager.js
CHANGED
|
@@ -1493,12 +1493,118 @@ export function createMcpManager(deps) {
|
|
|
1493
1493
|
if (!id || (level !== 'global' && level !== 'project'))
|
|
1494
1494
|
return { ok: false, error: '缺少 id 或 level' };
|
|
1495
1495
|
const abs = level === 'global' ? p.globalPatch : p.projectPatch;
|
|
1496
|
-
|
|
1496
|
+
const removedScenes = [];
|
|
1497
|
+
const skippedLockedScenes = [];
|
|
1498
|
+
await withWriteLock(async () => {
|
|
1499
|
+
// serverName 在锁内取:场景档案(mcp / mcpNotes)与两张工具侧车表都按它键,
|
|
1500
|
+
// 补丁条目 id 是另一套;锁外读会和并发写竞态。
|
|
1501
|
+
const known = (await collectAll()).rows.find((r) => r.id === id);
|
|
1502
|
+
const serverName = known ? known.serverName : '';
|
|
1497
1503
|
let c = await readPatch(abs);
|
|
1498
1504
|
c = removeEntryAll(c, id);
|
|
1499
1505
|
await writePatch(abs, c);
|
|
1500
|
-
|
|
1506
|
+
// ── 同步清引用(best-effort,口径与 mcpm-edit 的改名同步一致)─────────────
|
|
1507
|
+
// 只删补丁行的话,引用它的档案/侧车全是死键:场景页会显示一台不存在的服务器,
|
|
1508
|
+
// 进/退场景还会去切换一个空条目;界面上则短暂残留宿主 loader 的 include:* 行。
|
|
1509
|
+
if (serverName) {
|
|
1510
|
+
try {
|
|
1511
|
+
const slice = await memoriesService.readArchiveSlice();
|
|
1512
|
+
let mode = slice.mode;
|
|
1513
|
+
// 运行时快照的 mcp 同样按 serverName 键:进场景时引擎按它关停/恢复,
|
|
1514
|
+
// 死键会在退场景回写时被无害地跳过 —— 但删了就该摘,与改名的 F-025 同口径。
|
|
1515
|
+
const snapMcp = mode && mode.snapshot && mode.snapshot.mcp;
|
|
1516
|
+
if (snapMcp && Object.prototype.hasOwnProperty.call(snapMcp, serverName)) {
|
|
1517
|
+
const rest = Object.assign({}, snapMcp);
|
|
1518
|
+
delete rest[serverName];
|
|
1519
|
+
mode = Object.assign({}, mode, { snapshot: Object.assign({}, mode.snapshot, { mcp: rest }) });
|
|
1520
|
+
}
|
|
1521
|
+
// 场景档案:被**锁定**的场景档案是用户显式冻结的,跳过并如实上报,
|
|
1522
|
+
// 不绕开门禁偷偷写;解锁后重新保存一次档案即可清掉死键。
|
|
1523
|
+
let locks = {};
|
|
1524
|
+
try {
|
|
1525
|
+
if (typeof memoriesService.sceneLocks === 'function')
|
|
1526
|
+
locks = (await memoriesService.sceneLocks()) || {};
|
|
1527
|
+
}
|
|
1528
|
+
catch { /* 锁态读不到时按未锁处理,与修复前的行为一致 */ }
|
|
1529
|
+
const archives = {};
|
|
1530
|
+
let archivesTouched = false;
|
|
1531
|
+
for (const [name, archive] of Object.entries(slice.archives || {})) {
|
|
1532
|
+
const a = archive || {};
|
|
1533
|
+
const hasMcp = !!(a.mcp && Object.prototype.hasOwnProperty.call(a.mcp, serverName));
|
|
1534
|
+
const hasNote = !!(a.mcpNotes && Object.prototype.hasOwnProperty.call(a.mcpNotes, serverName));
|
|
1535
|
+
if (!hasMcp && !hasNote) {
|
|
1536
|
+
archives[name] = archive;
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
if (locks[name] === true) {
|
|
1540
|
+
skippedLockedScenes.push(name);
|
|
1541
|
+
archives[name] = archive;
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
const copy = Object.assign({}, a);
|
|
1545
|
+
if (hasMcp) {
|
|
1546
|
+
const rest = Object.assign({}, a.mcp);
|
|
1547
|
+
delete rest[serverName];
|
|
1548
|
+
copy.mcp = rest;
|
|
1549
|
+
}
|
|
1550
|
+
if (hasNote) {
|
|
1551
|
+
const rest = Object.assign({}, a.mcpNotes);
|
|
1552
|
+
delete rest[serverName];
|
|
1553
|
+
// mcpNotes 全空 → null:与档案保存("全空 = 未定义")同一口径。
|
|
1554
|
+
copy.mcpNotes = Object.keys(rest).length ? rest : null;
|
|
1555
|
+
}
|
|
1556
|
+
archives[name] = copy;
|
|
1557
|
+
archivesTouched = true;
|
|
1558
|
+
removedScenes.push(name);
|
|
1559
|
+
}
|
|
1560
|
+
if (archivesTouched || mode !== slice.mode) {
|
|
1561
|
+
const patch = {};
|
|
1562
|
+
if (archivesTouched)
|
|
1563
|
+
patch.archives = archives;
|
|
1564
|
+
if (mode !== slice.mode)
|
|
1565
|
+
patch.mode = mode;
|
|
1566
|
+
await memoriesService.patchIndex(patch);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
catch { /* 清理失败不拦删除本身;死键无行为影响,与修复前一致 */ }
|
|
1570
|
+
try {
|
|
1571
|
+
// 停用表与「已知工具」也按 serverName 键:整台没了,键留着只会喂出错的口径
|
|
1572
|
+
// (补集里多出一台永远凑不上的服务器的旧工具名)。
|
|
1573
|
+
const toolMap = Object.assign({}, await readDisabledTools(true));
|
|
1574
|
+
if (Object.prototype.hasOwnProperty.call(toolMap, serverName)) {
|
|
1575
|
+
delete toolMap[serverName];
|
|
1576
|
+
await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), toolMap);
|
|
1577
|
+
disabledToolsCache = { at: Date.now(), value: toolMap };
|
|
1578
|
+
await applyToolRestrictions();
|
|
1579
|
+
}
|
|
1580
|
+
const knownMap = Object.assign({}, await readKnownMcpTools(true));
|
|
1581
|
+
if (Object.prototype.hasOwnProperty.call(knownMap, serverName)) {
|
|
1582
|
+
delete knownMap[serverName];
|
|
1583
|
+
await writeKnownMcpTools(knownMap);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
catch { /* non-fatal */ }
|
|
1587
|
+
try {
|
|
1588
|
+
// 备注按 loader id 键:条目删了备注就该走(writeNoteUnderLock 约定在写锁内调用)。
|
|
1589
|
+
await writeNoteUnderLock(id, '');
|
|
1590
|
+
}
|
|
1591
|
+
catch { /* non-fatal */ }
|
|
1592
|
+
}
|
|
1501
1593
|
});
|
|
1594
|
+
// 等 loader 把条目摘掉。等待放在**写锁外**(同 mcpm-restart:锁内等待会卡住其他写)。
|
|
1595
|
+
// 宿主清单偶尔停在上一帧,等不到**不算失败** —— 补丁条目确实已经删了,宿主侧收敛有
|
|
1596
|
+
// 自己的节奏;但要说出来,否则用户对着残留的 include:* 行只会以为插件没删干净。
|
|
1597
|
+
const warnings = [];
|
|
1598
|
+
if (pluginInventory) {
|
|
1599
|
+
const gone = await waitFor(async () => (await liveEntry(id)) === null, LOADER_STATE_WAIT_MS, LOADER_STATE_POLL_MS);
|
|
1600
|
+
if (!gone)
|
|
1601
|
+
warnings.push(`宿主 loader 清单未在 ${LOADER_STATE_WAIT_MS / 1000} 秒内摘除该条目,列表可能短暂残留它的行(宿主收敛后自动消失)`);
|
|
1602
|
+
}
|
|
1603
|
+
if (removedScenes.length)
|
|
1604
|
+
warnings.push(`已同步摘掉 ${removedScenes.length} 个场景档案里对它的引用(${removedScenes.join('、')})`);
|
|
1605
|
+
if (skippedLockedScenes.length)
|
|
1606
|
+
warnings.push(`场景 ${skippedLockedScenes.join('、')} 已锁定,档案里对它的引用保留未动(解锁后重新保存档案即可清掉)`);
|
|
1607
|
+
return warnings.length ? { ok: true, warning: warnings.join(';') } : { ok: true };
|
|
1502
1608
|
}
|
|
1503
1609
|
/**
|
|
1504
1610
|
* 收敛补丁文件里的启停覆盖块(用户显式点「整理补丁」才走这里)。
|
|
@@ -1549,9 +1655,11 @@ export function createMcpManager(deps) {
|
|
|
1549
1655
|
return { ok: true, removed: total, ids: removed, files };
|
|
1550
1656
|
});
|
|
1551
1657
|
}
|
|
1552
|
-
// ---------- 配置体检(0.15.0 B1
|
|
1553
|
-
//
|
|
1554
|
-
// `which`
|
|
1658
|
+
// ---------- 配置体检(0.15.0 B1;0.16.5 起 streamable-http 含端点探测)--------------
|
|
1659
|
+
// 对补丁文件**只读**:不写盘、不拉起子进程。对外调用两处 ——
|
|
1660
|
+
// · `where` / `which` 查 PATH:只查「这个命令名能不能找到」,绝不执行配置里的命令本身;
|
|
1661
|
+
// · streamable-http 行对配置地址发一次 GET 连通性探测(带配置的鉴权头、5 秒超时、
|
|
1662
|
+
// 不读响应体 —— 与宿主连接该服务走的是同一地址同一头部,只是更轻)。
|
|
1555
1663
|
//
|
|
1556
1664
|
// 为什么单独读补丁而不复用 `mcpmList()`:那个函数顺带回写「已知工具」缓存、还要问一次
|
|
1557
1665
|
// schema 注册表。体检的承诺是「点了什么都不变」,所以这里只走 readPatch + parseRows。
|
|
@@ -1577,6 +1685,53 @@ export function createMcpManager(deps) {
|
|
|
1577
1685
|
});
|
|
1578
1686
|
});
|
|
1579
1687
|
}
|
|
1688
|
+
/** streamable-http 连通性探测的超时:再长,「全部检查」就会被一台挂掉的服务拖住。 */
|
|
1689
|
+
const INSPECT_PROBE_TIMEOUT_MS = 5000;
|
|
1690
|
+
/** 探测失败的原因短语(进词典模板的 {reason}):认得出常见错误码就说人话,认不出给原文截断。 */
|
|
1691
|
+
function describeProbeFailure(e) {
|
|
1692
|
+
const err = e;
|
|
1693
|
+
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
1694
|
+
return '探测超时(' + Math.round(INSPECT_PROBE_TIMEOUT_MS / 1000) + ' 秒无响应)';
|
|
1695
|
+
}
|
|
1696
|
+
const code = err && err.cause && err.cause.code;
|
|
1697
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN')
|
|
1698
|
+
return '域名解析失败(' + code + ')';
|
|
1699
|
+
if (code === 'ECONNREFUSED')
|
|
1700
|
+
return '连接被拒绝(' + code + ')';
|
|
1701
|
+
if (code === 'ETIMEDOUT')
|
|
1702
|
+
return '连接超时(' + code + ')';
|
|
1703
|
+
if (code === 'ECONNRESET')
|
|
1704
|
+
return '连接被重置(' + code + ')';
|
|
1705
|
+
if (typeof code === 'string' && /CERT|SSL|TLS/.test(code))
|
|
1706
|
+
return 'TLS 证书校验失败(' + code + ')';
|
|
1707
|
+
const text = String((err && err.cause && err.cause.message) || (err && err.message) || e || '');
|
|
1708
|
+
return text.length > 80 ? text.slice(0, 77) + '…' : text;
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* streamable-http 的连通性探测:对配置地址 GET 一次,**任何** HTTP 响应(含 404/405)
|
|
1712
|
+
* 都算「可达」—— 探测只回答"这个地址现在连不连得上",不校验它是不是 MCP 服务。
|
|
1713
|
+
* 不读响应体:streamable-http 对 GET 常回一条 SSE 长连接,拿到状态行就取消。
|
|
1714
|
+
*/
|
|
1715
|
+
async function probeHttpEndpoint(url, headers) {
|
|
1716
|
+
const extra = {};
|
|
1717
|
+
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
|
|
1718
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
1719
|
+
if (typeof v === 'string' && v)
|
|
1720
|
+
extra[k] = v;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
try {
|
|
1724
|
+
const res = await fetch(url, { method: 'GET', headers: extra, redirect: 'follow', signal: AbortSignal.timeout(INSPECT_PROBE_TIMEOUT_MS) });
|
|
1725
|
+
try {
|
|
1726
|
+
await res.body?.cancel();
|
|
1727
|
+
}
|
|
1728
|
+
catch { /* 取消失败不影响「可达」结论 */ }
|
|
1729
|
+
return { reachable: true };
|
|
1730
|
+
}
|
|
1731
|
+
catch (e) {
|
|
1732
|
+
return { reachable: false, reason: describeProbeFailure(e) };
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1580
1735
|
/** 一行配置 → 检查项。`level: 'warn'` 才点黄;`info` 是「查不了 / 不算问题」那类说明。 */
|
|
1581
1736
|
async function inspectChecks(row, duplicateIds) {
|
|
1582
1737
|
const checks = [];
|
|
@@ -1621,6 +1776,13 @@ export function createMcpManager(deps) {
|
|
|
1621
1776
|
checks.push({ id: 'noUrl', level: 'warn' });
|
|
1622
1777
|
else if (!/^https?:\/\//.test(url))
|
|
1623
1778
|
checks.push({ id: 'badUrl', level: 'warn', params: { url } });
|
|
1779
|
+
else {
|
|
1780
|
+
// 格式对了再做一次真实探测(0.16.5):此前的体检止步于格式校验,一条编造的
|
|
1781
|
+
// 地址也能拿绿点 —— 绿点承诺的「没发现问题」其实只覆盖了一半(2026-09-29 实测)。
|
|
1782
|
+
const probe = await probeHttpEndpoint(url, row.headers);
|
|
1783
|
+
if (!probe.reachable)
|
|
1784
|
+
checks.push({ id: 'httpUnreachable', level: 'warn', params: { url, reason: probe.reason } });
|
|
1785
|
+
}
|
|
1624
1786
|
const badHeaders = badKv(row.headers);
|
|
1625
1787
|
if (badHeaders.length)
|
|
1626
1788
|
checks.push({ id: 'kvNotString', level: 'warn', params: { where: 'headers', keys: badHeaders.join('、') } });
|
|
@@ -1664,15 +1826,16 @@ export function createMcpManager(deps) {
|
|
|
1664
1826
|
}
|
|
1665
1827
|
const onlyId = args && String(args.id || '') ? String(args.id) : '';
|
|
1666
1828
|
const onlyLevel = args && String(args.level || '') ? String(args.level) : '';
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1829
|
+
// 逐行检查并行跑:体检含 streamable-http 的端点探测(每次至多 5 秒超时),串行会让
|
|
1830
|
+
// 「全部检查」在多台同时连不上时按行数翻倍地慢。Promise.all 保序,与旧实现一致。
|
|
1831
|
+
const targets = rows.filter((r) => (!onlyId || String(r.id) === onlyId) && (!onlyLevel || String(r.level) === onlyLevel));
|
|
1832
|
+
const results = await Promise.all(targets.map(async (r) => ({
|
|
1833
|
+
id: r.id,
|
|
1834
|
+
serverName: r.serverName,
|
|
1835
|
+
level: r.level,
|
|
1836
|
+
transport: r.transport || null,
|
|
1837
|
+
checks: await inspectChecks(r, (idsByName[String(r.serverName || '')] || new Set()).size),
|
|
1838
|
+
})));
|
|
1676
1839
|
return { ok: true, results, errors };
|
|
1677
1840
|
}
|
|
1678
1841
|
async function mcpmExport() {
|
package/lib/memories/service.js
CHANGED
|
@@ -696,6 +696,15 @@ export function createMemoriesService(ctx, deps) {
|
|
|
696
696
|
refresh,
|
|
697
697
|
patchIndex,
|
|
698
698
|
readArchiveSlice,
|
|
699
|
+
sceneLocks: async () => {
|
|
700
|
+
const index = await readIndex(stateDir);
|
|
701
|
+
const out = {};
|
|
702
|
+
for (const [name, rec] of Object.entries(index.scenes || {})) {
|
|
703
|
+
if (rec && rec.locked === true)
|
|
704
|
+
out[name] = true;
|
|
705
|
+
}
|
|
706
|
+
return out;
|
|
707
|
+
},
|
|
699
708
|
};
|
|
700
709
|
return service;
|
|
701
710
|
}
|
package/lib/ops/compat.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// 边界纪律:本文件只负责「op 的实现」,不负责 ops 表之外的接线(MCP 写后刷新、场景锁定
|
|
8
8
|
// 守卫仍作用在组装后的整张表上,见 index.ts)。
|
|
9
|
-
import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, routeFor, summarize, } from '../compat/probe.js';
|
|
9
|
+
import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, isSourceInstall, routeFor, summarize, } from '../compat/probe.js';
|
|
10
10
|
import { INJECT_DOMAIN_KEYS } from '../context-inject.js';
|
|
11
11
|
import { assessPresetReach } from '../compat/preset-reach.js';
|
|
12
12
|
import { runtimeNotes } from '../compat/runtime-notes.js';
|
|
@@ -64,6 +64,13 @@ export function buildCompatOps(deps) {
|
|
|
64
64
|
host: { version: assessment.identity.version, modules: assessment.identity.modules },
|
|
65
65
|
sameAsHost: assessment.identity.sameAsHost,
|
|
66
66
|
unverified: assessment.identity.unverified,
|
|
67
|
+
// 身份校验的说明:不是阻塞项,界面据此解释那一排「无法比较」,而不是留给
|
|
68
|
+
// 用户一句「两份拷贝,去 --fix」。两种来源 —— 宿主在 app.asar 里(桌面版)、
|
|
69
|
+
// 路径取不到真实形态(realpath 失败,issue #1)。
|
|
70
|
+
notes: assessment.identity.notes,
|
|
71
|
+
// 修复指引按实际形态给:`scripts/` 不在 `files` 里,npm 安装形态下没有
|
|
72
|
+
// `scripts/doctor.mjs` 与 `scripts/host-deps.mjs`,指了也跑不了。
|
|
73
|
+
sourceInstall: isSourceInstall(),
|
|
67
74
|
findings,
|
|
68
75
|
degraded,
|
|
69
76
|
blockers: assessment.identity.blockers,
|
|
@@ -258,13 +265,22 @@ export function buildCompatOps(deps) {
|
|
|
258
265
|
// 身份 / 版本:与 compat-status 同一份数据。
|
|
259
266
|
const identity = assessment?.identity;
|
|
260
267
|
const identityIssue = identity !== undefined && (identity.blockers.length > 0 || Object.values(identity.sameAsHost).some((same) => same === false));
|
|
268
|
+
// 归档宿主(桌面版):不是阻塞项,但"模块与宿主同源"这句话对它不成立(同版本、
|
|
269
|
+
// 不同文件),所以总览那一行要换句话讲,而不是继续报一句善意的假话。
|
|
270
|
+
const asarHost = identity?.notes.find((note) => note.kind === 'asar-host');
|
|
261
271
|
push({
|
|
262
272
|
key: 'host-identity', label: '宿主身份与模块', tab: 'compat',
|
|
263
273
|
state: assessment === undefined ? 'unknown' : identityIssue ? 'degraded' : 'ok',
|
|
264
274
|
detail: assessment === undefined ? '能力探测不可用(归档服务未挂载)'
|
|
265
275
|
: identityIssue ? '存在阻塞项:' + (identity?.blockers ?? []).join(';')
|
|
266
|
-
:
|
|
267
|
-
|
|
276
|
+
: asarHost !== undefined
|
|
277
|
+
? `官方包与宿主同版本,但宿主跑在 ${asarHost.asarName} 里(插件在归档外)`
|
|
278
|
+
: `模块与宿主同源(宿主 DSH ${identity?.version ?? '?'})`,
|
|
279
|
+
...(assessment === undefined ? {} : {
|
|
280
|
+
version: identity?.version ?? '?',
|
|
281
|
+
blockers: identity?.blockers ?? [],
|
|
282
|
+
...(asarHost === undefined ? {} : { asarHost: true, asarName: asarHost.asarName }),
|
|
283
|
+
}),
|
|
268
284
|
});
|
|
269
285
|
// 无独立装配点的域:没有上报就是正常(如实说明依据是"没有降级上报")。
|
|
270
286
|
for (const [key, label, tab] of [['memory', '记忆', 'memory'], ['prompts', '提示词', 'prompts'], ['subagents', '子智能体', 'subagents'], ['scenes', '场景档案', 'scenes']]) {
|