Перейти к основному содержимому
N

dsh-scope-manager

nanaaya98/dsh-scope-manager

Scope manager for DeepSeek Harness: binds presets, persona cards, tools, skills and MCP connections to global / workspace / session layers, with a sidebar.

Установка

dsh plugin --profile web add github:nanaaya98/dsh-scope-manager

README

功能管理和预设(dsh-scope-manager)

一个带侧边栏的 DSH 管理插件(侧栏里叫「功能管理和预设」,包名 dsh-scope-manager):把「哪些工具、哪些技能、用哪张角色卡」按 全局 / 工作区 / 会话 三层管起来。

优先级:会话 > 工作区 > 全局。没绑预设的节点继承上级;顶层不绑就是「默认预设」——全部工具与技能都启用,之后新增的也自动算启用。

六个页签(作用域 / 预设 / 角色卡 / 插件 / MCP / 技能)

页签干什么
作用域一张层级图:全局 → 各工作区 → 工作区内的会话,每个节点只绑一个预设(不绑 = 继承上级,绑「默认预设」= 全部工具技能都启用,含以后新增的);工作区可重命名/移除,会话可归档/取消归档/置顶/删除
预设「一套设定」的打包:角色卡 + 工具名单 + 技能名单 + 系统提示词段落名单,可被任意一层引用;名单同样按两级分组勾选。工具/技能的候选集取全量(所有 agent 的并集),不会因为当前会话绑了窄预设就报「不在当前列表里」。一套预设一张卡片(名字 + 权限/新工具徽标 + 说明 +「关掉几把工具 / 几个技能 / 几段提示词 · 角色卡是谁」),编辑与删除在卡片最下面那一行;点卡片进详情改。挨着「作用域」摆:先在作用域里看层级,再回这里改那一套预设
角色卡角色卡模板集。一张卡一片卡片(首字图标 + 名字 + 注入方式徽标 + 一段说明或正文摘要 +「世界观/角色/约束」各多少字),编辑与删除在卡片最下面那一行;点卡片进详情改三段正文。卡片本身写在「世界观 / 角色设定 / 行为约束」三段里(分别对应系统提示词的 harness:identity、deployment:persona-prefix、deployment:persona-suffix),并可选两种注入方式(详情页顶部就是模式选择器,两档并排、各带一句说明;切换模式只改注入方式,下面三个框里的字一个字都不动,要保存才生效):角色卡上下文(拼成一条合成 user 消息)或系统提示词覆盖(装配时直接盖掉那两段文字:世界观 → harness:identity,角色设定 + 行为约束 → deployment:persona-prefix,行为约束接在末尾;覆盖模式下某一格留空也会盖,等于把那一段抹掉)。供预设与各层引用
插件两个子页:系统插件(宿主自带的 @deepseek-ai/dsh-*,只显示这个会话里真正在跑的)与用户插件(自己装进 profile 的,连「一个工具都没注册」的换肤/挂件类也算在内)。每个插件一张卡片:名字 + 版本 + 一句简介 + 「几把工具 / 几段提示词」,点卡片进详情 —— 详情里就是它自己带的系统提示词段落(可改写、可开关注入、可关联工具)和它注册的工具(可改显示名、写描述备注)。这一页是全量清单,跟当前会话绑了什么预设无关:插件注册了什么就列什么,用来查询、改显示名、写备注、指定归属 —— 预设决定的是「最后给会话调用的那份」,不在这页体现。MCP 那批不在这里:它们跟着「MCP」页的连接器走。备注、改写、是否注入都只有全局一份——改了就对所有会话生效。接不到任何插件的段落(既认不出来源、又接不上工具组的那几段)列在系统插件子页底部的「未归组的提示词」里,每段同样有「注入 / 归属 / 编辑」——归属决定这段跟着哪几把工具走(关联着的工具一个都没启用,它就不注入);有来源插件的段落候选就是那个包自己的工具,未归组的段落候选是全量工具名。同一个人点出来的「归属」上半截还能指定这段算哪个插件的东西(下拉里是全量插件),选完这段就搬进那张插件卡片的系统提示词里、从「未归组」消失;选「不归属」又搬回来
技能两个子页:已安装(内置与第三方技能一览,能读能改 SKILL.md,改之前自动留 .bak,还能停用 / 启用)与技能市场(直接读 skillhub.cn,点「安装」就装进本机)
MCPMCP 连接器与它们的工具:谁在跑、跑没跑起来、有几个工具。一台连接器一张卡片(名字 + 状态徽标 + 一句「怎么连的」+ 工具数),启用/停用、编辑、探测、复制、删除都排在卡片最下面那一行;点卡片正文进详情,详情里才是它的系统提示词和工具清单(逐个开关、逐个写备注)。新增 / 编辑走弹窗(第一个字段是给人看的「名称」)

作用域的三种取值,就是存储里的三种写法:

界面上的选法存的东西含义
继承上级这层没有 scene 键往上找:会话 → 工作区 → 全局
默认预设scene: null从这里往下全部放开,角色卡/名单/备注都不会从下面漏上来
预设「X」scene: "X"用这套预设

勾选的语义

工具与技能列表默认全勾:勾着 = 这一层让它生效,取消勾选 = 这一层停用它。

  • 全勾 = 这一层不限制(配置里干脆不出现这个键),所以新装的插件/新注册的工具默认就是可用的,不会被一份旧的名单悄悄挡在外面。
  • 只有真的取消了几项,才会写进「停用名单」({ mode: 'deny', names: [...] })。
  • mode: 'allow' 形态的名单(只放行)读进来会按当前候选集反推成停用名单,语义不变。

MCP 页(连接器与工具)

一块 MCP server 在 DSH 里是一条 loader 行:名字固定 @deepseek-ai/dsh-mcp-client, config.serverName 是服务器名([A-Za-z0-9_-]{1,32},全局唯一),transport 二选一 (stdio 用 command/args/env/cwd,streamable-http 用 url/headers)。 它挂上以后注册的工具叫 mcp__<serverName>__<toolName>。

真源是补丁文件,不是插件内存。这一页读写的是 cordis.patch.yml:

层路径作用范围
全局$DSH_HOME/cordis.patch.yml所有 profile 共用
本 profile<profile>/cordis.patch.yml只影响当前 profile

宿主按「bundle → profile 层 → 全局层」叠补丁,全局层优先级更高:两层有同名 id 时, profile 那行是死的(页面上标「重复条目(这条不生效)」)。改一台已经存在的服务器时, 本插件就地改它所在的那一层,不另起一行。

新写的连接器一律进全局层(所有 profile 共用,启用 = 默认启用), 细分到「哪个预设用哪些工具」去「预设」页挑;profile 层里已经有的行仍然就地改。

写盘的规矩和宿主一致:新增行必须进 - insert: 块(裸 - id: 是「覆盖既有行」, 不是新增);!!js 表达式标签原样保留;原子写(临时文件 + rename)。 写完宿主靠 dsh-hmr 监视这两个补丁文件自动重载,不用叫它重启。

状态怎么判

页面上的状态徽标是三处事实对出来的,不是猜的:

补丁里有loader 里有工具表里有状态
———补丁里没有这一行
有,写着 disabled: true——已停用(宿主不认它)
有没有—宿主没挂上(多半被另一层补丁盖住了)
有有,但没激活—宿主里挂着,但没激活(点「启用」会自动补挂,见下)
有有一个都没有已挂载但没连上(多半是地址/鉴权/网络)
有有有已连接(N 个工具)

「配了 MCP 却没被识别」基本都落在中间三行里:常见的例子是 某个连接那行写着 disabled: true,所以宿主一个工具都没给它。

点「启用」为什么不会再卡在「挂着但没激活」

宿主 loader 的 entry.update() 里,disabled 从 true 变回 false 时不会重新 init() —— 它先看 this.fiber?.uid,只要旧 fiber 还没拆干净(dispose() 是异步的),就只走「改配置」 那条分支;于是 entry 在、fiber 不在,页面上一辈子是「宿主里挂着,但没激活」。

所以这一页启用一台时不靠等:写盘 → 让宿主重读补丁(hmr.reconcileProfile())→ 短等 (5 秒)→ 还不通就直接自己补挂:先 fiber.dispose(),轮询到 fiber.uid 变 null (最多 3 秒),再 entry.init(),然后再等(最多 20 秒)它连上。实测往返十几次都直接落到 「已连接(N 个工具)」。

页面上那颗「刷新」按钮做两件事:让宿主重读一遍补丁,再把所有「补丁里有、没停用、 但就是没激活」的连接器挨个补挂一遍,然后重新读一次列表。按下就变成「刷新中…」, 下面直接出一行结果(比如「已让宿主重新读了一遍补丁,顺手把 2 台重新挂上了」)。

系统提示词绑在连接器上

MCP 服务器连上以后会往系统提示词里推一段目录(段落名固定 mcp:<服务器名>)。这一页把它 挂在那台连接器自己下面:连接器条目里一行「▸ 系统提示词(注入中 / 不注入)」,展开是 段落名、注入勾选、原文、以及「改写这段 / 保存改写 / 还原原文」。

改动落到的地方和插件页一致:注入开关 → layers.json 的 sectionStates, 改写 → sectionOverrides(留空 = 还原原文)。同一段提示词只在这一页维护, 插件页不重复列 mcp: 开头的段落(有连接器那张卡就行)。

只按平台的方式连,不碰别的插件

连接器只认平台的补丁行(cordis.patch.yml):这一页写它、启停它、删它,就这些。 市场里的 dsh-mcp-connector 也把连接记在自己的 unit 状态里 ($DSH_HOME/storages/mcp_connector.json),但这一页只读不写——读它只是为了给 还没在这里起过名的连接器兜底显示名(「面板服务」这种);起过名的用自己的表 (layers.json 的 mcpNames)。

页面上能做什么

  • 新增 / 编辑:点开是一个弹窗(不是页面底部那一长条表单),字段按这个顺序排: 名称 → 服务器名 → 传输方式 → URL / 命令 / 参数 / 环境变量 / 工作目录 / 请求头 → 要不要先停用。点遮罩空白处或右上角 × 关掉。
    • 名称是给人看的(卡片上显示「面板服务」那种), 只存在这个插件自己的表里(layers.json 的 mcpNames),不会写进补丁 —— 因为宿主 loader entry 的 config 有 schema,塞不认识的键有风险。 不填就退回显示服务器名;另一个插件(dsh-mcp-connector)已经给这台起过名时, 页面会拿它的名字兜底。
    • 服务器名才是机器认的(mcp__<服务器名>__<工具名>);改它会连工具名前缀一起改。
  • 停用 / 启用:停用写 disabled: true,启用把这个键删掉(另一个插件也管着这台时,两边一起改)。
  • 探测:stdio 查命令在不在 PATH;HTTP 按 MCP 的规矩 POST 一个 initialize (带上这一行自己的请求头,5 秒超时;只看状态码,不读 SSE 长连接)。
  • 复制:留在原来那一层,复制出一台新的(新 id,服务器名自动加后缀 -2, 因为工具前缀 mcp__<服务器名>__ 必须唯一)。
  • 删除:删掉补丁里的那一行(连它那条显示名也一起清掉,不留没人用的名字)。
  • 逐工具开关:每台服务器的工具清单就折在它自己那条目里 —— 条目底部一行 「▸ 工具(N)」,点一下展开,工具名 + 说明 + 一个勾选框一个工具。 顶上的搜索框搜的是所有连接器的工具清单,搜到哪台,那台会自动展开。
  • 版式和插件页/技能页是同一套(三页共用同一个卡片组件):列表 = 一排卡片 (名字 + 状态徽标 + 「HTTP/stdio · 地址」+ 工具数),动作按钮全在卡片最下面那一行; 点卡片正文进详情 —— 返回条 → 头部卡片(名称、服务器名、状态、地址、id=、探测结果)→ 「系统提示词」(提示词在前)→「工具」(工具在后,逐个开关 + 逐个备注)。
  • 工具也能写备注:插件详情里的工具行右边那颗「备注」,和插件页是同一个编辑器、同一张表 (全局层的 tools)——显示名、描述备注、「作为注入描述」都能改;在哪边改,另一边 立刻就是改完的样子。

逐工具开关存在哪

工具开关不走层里的 toolFilter——那个键是「整块取第一个」的(会话 → 工作区 → 全局), 往全局写 deny 名单会被会话层整块盖掉。所以另开一把跨层共用的总闸:存储顶层 mcpTools[toolName].disabled,在 assemble 过滤工具定义时和 ctx.tools.guard(...) 调用兜底时都生效(两头都拦,改完下一轮就生效,也能立刻反悔)。

五个落点(它到底改了模型看到的什么)

  1. 工具描述:ctx.on('system-prompt/assemble') 里只替换 description,parameters 一个字不动;没勾「作为注入描述」的工具原样保留。
  2. 工具可见性:在 system-prompt/assemble 里把名单外的工具从工具定义数组里剔除 —— 模型拿到的 tools 里根本没有那一条。这是按次计算的,改完下一轮就生效、也能立刻反悔,不像 tools.restrict() 那样留下一层撤不掉的 deny(插件一热重载,撤销函数就跟着实例一起没了,工具被永久锁死)。
  3. 工具调用:ctx.tools.guard(...) 在调用时兜一层(技能名单也在这里拦,按 exec.arguments.name)。
  4. 角色卡:ctx.on('agent/pre-step', ..., { prepend: true }) 往消息列表前面插一条合成 user 消息,内容没变就不重发;选了「系统提示词覆盖」的卡片不走这条,改在 assemble 瀑布里把三段文字盖到对应段落上(优先级高于普通段落覆写)。 —— 不走 systemPrompt:只要有任一 section 标了 complete: true,宿主会把 sections 还原成只剩那一个,写进去也白写。
  5. 系统提示词段落:assemble 钩子用 { prepend: true } 站到最外层,await next() 之后按名单把整段 sections 摘掉。 —— 段落和工具是两套东西:别的插件(比如 meme 的「斗图规则」)是往 assembled.sections 里 push 的,跟它的工具开没开毫无关系,所以只能在 assemble 末尾摘。 —— 段落只有名字没有归属字段,所以「见一次记一次」,落盘 $DSH_HOME/scope-manager/sections.json,界面上按 名字里的冒号前缀 分组。 —— 来源:反查已装插件的源码(找谁写了这个段落名),在行尾显示 插件:行号,鼠标悬停看文件与那一行代码。查不到就标「来源未定位」。 —— 覆写:行尾的「编辑」会先把整段原文完整展示出来(可滚动、只读),下面才是覆写框;只改装配那一刻送进模型的 text,不写回插件源码;清空保存即取消。 —— 会自动长:每装一个新插件,只要它在装配时 push 了段落,下一次装配后这个目录就多出对应条目(新名字还会顺手触发一次来源反查)。不需要手动刷新。 —— 会标「本次未出现」:目录是只增不改的缓存,插件卸了条目也还在;所以列表会对照最近一次装配标出没出现的那些(也可能是按需注入)。覆写按名字存,不受它影响,插件装回来照样生效。 —— 可清理:列表下方出现「清理未出现的条目(N)」时,点一下就把这些陈条目从目录里删掉。带覆写的无论如何都留着(删了覆写变孤儿,插件装回来你改过的字就没了);按需注入的段落被清也没事,它下次出现会自己记回来。
  6. MCP 工具:「MCP」页关掉的工具,在 assemble 里从工具定义数组剔除(和名单外的工具一个待遇),调用期再由 ctx.tools.guard(...) 兜一层。

段落怎么和工具组对上

插件详情里的「系统提示词」那一块,列的就是这个插件自己的段落。接上的线索有两条:

  1. 段落名带工具前缀:tool:bash → 工具 bash → 它所在的那个组。这是最准的一条(名字是插件自己起的,跟工具名一字不差)。
  2. 段落的来源插件:反查出来是 @deepseek-ai/dsh-tool-bash → 包名 dsh-tool-bash → 组归属者也是它。名字不是工具前缀的段落靠这条接上(表情包的 dsh-expression:companion 归 dsh-meme,genui 的 genui:fence 归 dsh-genui)。

宿主自带的段落(@deepseek-ai/* 的包)大多只有段落、没有工具,所以接不到任何工具组 —— 它们的归属查出来(来源反查 + 一张内置的系统插件对照表)之后,就挂在那个官方包名下、算系统工具,跟别的系统插件一起列出来。人设那三段(世界观 / 角色设定 / 收尾口径)归「角色卡」管、永远注入,插件页不重复列。

系统插件对照表(lib/system-sections.js):宿主自带的段落 → 归属包名,先查表、再查源码。表里没有的、或者表跟实际不一致的,都以源码反查为准 —— 宿主以后加了新的系统段落,不用改这个包也能认出来。

「未归组的提示词」那一块留给既认不出来源、又接不上工具的段落(少数情况):直接在这里开关和改写。

  1. 人点出来的归属(第三条线索,优先级最高):段落卡片上那颗「归属」按钮,上半截是个插件下拉(全量插件,含一个工具都没注册的换肤/挂件类,含宿主自带的包)。选了谁保存,这段就搬到那张插件卡片的系统提示词里、从「未归组」消失;选「不归属」又搬回来。存在 layers.json 的 sectionOwners[段落名] = 归属包名,全局一份、不分层。为什么需要它:宿主给的段落只有 {name, text},反查源码也猜不出归属的那些,否则永远没人能认领。

段落页里的两种操作,都写在当前选中的那一层(段落页顶部那个「全局层 / 工作区层 / 当前会话层」下拉说了算):

操作存到哪优先级
逐段选「跟随名单 / 强制注入 / 强制不注入」该层的 sectionStates[name]点名比整张名单细:预设里排掉的一段,会话里可以点名放回来
逐段改写文字该层的 sectionOverrides[name]层内覆写 > 目录里的全局覆写 > 原文

「跟随名单」= 把这个键删掉,听整张名单的;「强制注入 / 强制不注入」= 点名。名单本身(sectionFilter)还是挂在那套预设上的名单:预设里排掉的段落在本层仍会被摘掉,除非本层点名放回来。

固定名次(重排)

装配末尾还有一步「重排」,把模型读到的顺序钉成三段式:

名次是谁
1、2、3harness:identity → deployment:persona-prefix → deployment:persona-suffix
中间其余官方包(@deepseek-ai/*)的段落,保持宿主原本的 order 顺序
末尾第三方插件注入的段落,相对顺序不变 —— 等价于 push 型

人设三条在宿主里原本隔着几千个 order(identity −1000、prefix 0、suffix 10200),中间夹着 全部工具手册,读起来是断的;这里把它们抽出来挨着放。名次写在 lib/pin-order.js 的 PINNED_SECTIONS 里,数组顺序就是名次,改一行就能换。

「第三方」的判据是 lib/tool-owner.js 的 isOfficialPlugin()(包名不以 @deepseek-ai/ 开头), 所以 @dsh-custom/dsh-browser-desktop、dsh-kaomoji、@changfenhuang/dsh-genui 的段落都会沉到末尾。

重排是幂等的:同一批段落排两次结果一模一样,已经排好的一轮 moved 就是 0(不重复 record)。 缺段也不报警——预设里没有 deployment:persona-suffix 时就只钉存在的那两条。

面板上的体现:段落列表每行前面有名次,被钉住的带「固定名次」徽章(接口返回的 pinned 字段)。 注意名次是装配时的数字,所以它跟宿主源码里的 SECTION_ORDERS 不一样——那是宿主排的, 这是本插件改完之后模型真正读到的顺序。

会话里的「会话信息」

会话标题下面那排视图页签(对话 / 轨迹 / 刷过的视频 / 上下文)里多一条 会话信息, 排在「轨迹」后面(conversation.view,id scm-session-view,order 15)。只读,按当前 会话给一份现状快照:

  • 预设、角色卡(名字 / 注入方式 / 三段正文)、权限;
  • 工具:先按 系统工具 / 插件工具 / MCP 工具 三类分开数,每类下面再按归属者折叠展开;
  • 系统提示词:注入 / 停用 / 空段计数,再按宿主真实装配顺序(position)把每一段的原文 摊开——这就是模型实际读到的那份提示词,空段标「空段(宿主丢弃)」。

数据走已有的 scope-context、tools-list、sections-list,没有新接口;右上角有「刷新」。

页签行的宽度有限:visibleViewLimit = max(1, floor((行宽-48)/120)),排不下的会落进 「⋯ 更多会话视图」。order 排在 chat(0)、trajectory(10) 之后,默认可见。

注册这类槽要注意:conversation.view 由 conversation.session 条目声明,必须先 slots.inject('conversation.view', …) 再 slots.register({ name: 'conversation.view', … }), 否则抛 slot "conversation.view" is not declared (a parent entry's children table must declare it)。 conversation.session.header.utilities、sidebar.footer.action 这几个槽本插件碰不到,别再试。

右侧边栏那份同名面板已经删掉(用户要求),只剩页签这一处入口。

装进 profile

在线安装(npm / GitHub)

# npm
dsh plugin --profile <profile> add dsh-scope-manager

# GitHub
dsh plugin --profile <profile> add github:nanaaya98/dsh-scope-manager

装完把 dsh-scope-manager 加进 profile 的 dsh.profile.bundles 列表,重启 DSH 即生效。

本地目录

profile 的 package.json:

{
  "dependencies": {
    "dsh-scope-manager": "link:/path/to/dsh-scope-manager"
  },
  "dsh": { "profile": { "bundles": ["...", "dsh-scope-manager"] } }
}

再让 node_modules/dsh-scope-manager 指过来(ln -s)即可。宿主热重载会自己把它挂上;没有 HMR 时重启 DSH 生效。

改完宿主那一半,怎么让它立刻生效

插件分两半:

  • 界面那一半(lib/client.js)每次打开都从磁盘读 → 刷新浏览器就看到新的。
  • 宿主那一半(lib/index.js、lib/http-api.js、lib/mcp.js、lib/mcp-manager.js) 是宿主进程里 import 进去的模块 → 改了要重新 import 才算生效。

实测:改一下 profile 的 cordis.patch.yml(内容有变化,不是只 touch 时间戳), 宿主的 reconcile 就会把这个插件的模块重新 import 一遍,新代码立刻在跑 —— 不用重启 DSH(重启会连带把跑在同一进程里的 agent loop 打断)。

printf '\n# reload\n' >> <profile>/cordis.patch.yml   # 触发一次 reconcile
# 改完记得把这一行删回去

数据落在哪

$DSH_HOME/scope-manager/(默认 ~/.dsh/scope-manager/)

  • layers.json — 三层设定 + 工具归属修正 + mcpTools(MCP 逐工具总闸)+ mcpNames(MCP 连接器显示名)+ sectionOwners(段落的「归属插件」,人点出来的那份)
  • personas/<id>.md + <id>.json — 角色卡正文与元信息
  • scenes/<id>.json — 预设(存储里仍叫 scene)
  • sections.json — 系统提示词段落目录(含来源定位与全局覆写)

写盘一律「临时文件 + rename」,改一层只动这一层。

技能启停

宿主没有独立的技能开关表,停用的语义落在 frontmatter 的两个调用面开关上: 停用 = 写 disable-model-invocation: true + user-invocable: false; 启用 = 把这两个键删掉(和 dsh-mcp-studio 的 rewriteSkillPolicy 同一个口径)。 技能文件是 $DSH_HOME/skills/<name>/SKILL.md,写完宿主约 200ms 后广播 skills/change, 界面自己刷新。页面上停用的卡片会挂「已停用」徽标。

技能列表也是一排卡片(首字图标 + 名字 + 来源徽标 + 一句简介 + 来源说明), 停用/启用、读取、删除都在卡片最下面那一行;点卡片进详情,详情里是它的完整 SKILL.md(可直接改、点保存写回)和路径、来源。

已安装列表按三组折叠(点组标题收起 / 展开,顺序固定):

组装的是谁卡片上的标签
内置dsh-agent-preset/skills 里跟着 agent 预设一起加载的;official-skills 官方技能包里的内置 · @deepseek-ai/dsh-agent-preset / 内置 · official-skills
插件插件包自己带的(node_modules/.../skills/)插件 · <包名>
其他自己从技能市场装的、在会话里建的、项目 .dsh/skills 里的、宿主配置里额外加的目录其他

标签后面的包名取全(作用域的包要连作用域一起,比如 @changfenhuang/dsh-genui, 只取第一段会变成 @changfenhuang)。

「算哪一组」按宿主报的 source 判(user-dsh / user-agents → 其他),不按路径正则 —— /root/.dsh/skills/xxx(用户级)里也有 .dsh/skills,只看路径会被误判成「项目内」。

卸载自装技能

只有「其他」组里自装的那一类带「删除」按钮(从技能市场装的、或用会话自己建的,都落在 $DSH_HOME/skills 里)。内置(预设自带 / 官方技能包)、插件自带都不给删 —— 删了下次升级又回来, 还会把包弄坏(后端会直接拒绝并说明原因)。

点删除会先确认一次,确认后把整个技能目录直接删掉(rm -rf,硬删,没有回收站, 不能撤销)。删完宿主约 200ms 内广播 skills/change,列表和左上角计数自己就少了那一条。

删之前会过两道判断:位置必须是自装(user),路径必须真的是一个技能子目录 (…/skills 这个根自己、或 node_modules 那层都不让删)。

技能市场(技能页的第二个子页)

数据不是我们自己的,直接读 SkillHub 的公开接口(不用登录、不用 key):

用途接口
列表 / 搜索GET https://api.skillhub.cn/api/skills?page=1&pageSize=20&keyword=&category=&sortBy=&order=
分类GET https://api.skillhub.cn/api/v1/categories
下载GET https://api.skillhub.cn/api/v1/download?slug=<slug> → 302 到一个 zip

注意两件事:https://skillhub.cn/ 本身是个 SPA 壳,/api/... 打在那个域名上只会拿到 HTML, 必须打 api.skillhub.cn;列表只在 /api/skills(/api/v1/skills?page= 会 405)。

装到哪儿:一律装进用户级技能目录 $DSH_HOME/skills/<slug>/ —— 就是 dsh-skill-filesystem 的 roots() 里那个 user-dsh 根,所有项目都能用,界面上没有选项。 (后端 installSkill 还留着 skillsRoot 参数,以后想装到项目级 .dsh/skills 只要传就行, 只是页面不暴露。)

装完会把最终落地的绝对路径回显出来(→ /root/.dsh/skills/xxx),不用猜。

「安装」就是下 zip + 解开:

  1. 下 api/v1/download?slug=(跟随 302,60 秒超时);
  2. 用自己写的 zip 读解(stored / deflate 都支持,node:zlib 就够,不装第三方依赖);
  3. 每个条目都过一遍「必须在目标目录里」的检查 —— 绝对路径、..、\0 一律拒(zip slip);
  4. 解到 $DSH_HOME/skills/<slug>/,宿主 chokidar 监视这个目录,写完约 200ms 广播 skills/change,不用重启,「已安装」那一页自己就会多出一条。

已装过的 slug 会被拦下来并提示「要覆盖就勾上覆盖」(页面上那颗勾选框),勾了才覆盖。

列表是瀑布流:一页 20 条,滚到底自动续下一页(一个哨兵 div + IntersectionObserver, rootMargin 240px 提前拉),顶上那行实时报「共 N 个 · 已加载 M」;到底了显示「到底了」, 不再请求下一页。排序有四档:相关度(score)、热门(按安装数)、下载多、星标多 —— 走接口的 sortBy / order=desc。往下传的 sortBy 只放行 score / installs / downloads / stars 这四个:接口遇到不认识的排序字段会当成没排序,悄悄给回一份热门榜,看起来就像筛选没生效。

搜索怎么排:浏览时默认按热门;一旦填了搜索词就换成相关度。带搜索词时接口那边的 score 是按这次查询算的相关度分(同一个技能换个关键词分值就变),接口就按它出这一页。接口的关键词是 全文召回,只要描述里出现过这个词就会被收进来,所以前端还按贴合档位重排一遍:名字、发布者标识 或 slug 里带搜索词的排在只写在描述里的前面,同一档里再按你选的那档指标排。档位只在这一页里 生效 —— 翻页时不动已经列出来的条目,滚动位置才不会跳。

点卡片看详情:整张卡片可点(cursor: pointer),弹出「技能详情」—— 图标 / 名字 / 版本 / @作者/slug / 分类 / 作者 / 下载·安装·星标 / 两个安全扫描的结论 / 中文摘要 / 英文摘要 / 标签 / 来源页链接,底下三颗按钮「安装到本机」「在 skillhub.cn 打开」「关闭」。详情比列表多 一个请求(GET api/v1/skills/<slug>),点开先出「读取中」,回来再填。

图标怎么来的:市场条目自带 iconUrl,有就用图。没有图标的(以及「已安装」里所有技能 —— 技能目录里根本没有图标)用「名字的第一个字」当图标:英文名首字母大写(find-skills → F, gmai-free-lw-06 → G),中文名取第一个字;底色按名字散列到一个固定色,同一个名字每次同色。 解压到一半失败会把半成品目录删掉,不留一个坏技能在那儿。技能的名字和描述从 SKILL.md 的 frontmatter 里读 —— 下载包里的 _meta.json 只有版本号。

装完还会顺手把 name 揉成宿主认的样子:宿主只认 kebab-case (dsh-skill 里 SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/),而市场里不少技能把 name: 写成展示名(Word / DOCX、Poetry Daily Art)—— 这种宿主会静默丢掉 (日志里 skill file ... ignored: invalid skill name),表现就是「装到目录里了,但技能页没有」。 所以解压后会读 SKILL.md,name 不合法就改成 slug 那种 kebab 形式、把原展示名塞进 title: (本来合法就一个字不动);改过的话「装好了」那行会写出来,比如 「装好了:word-docx(2 个文件)→ /root/.dsh/skills/word-docx (市场里写的名字是「Word / DOCX」, 宿主只认 kebab-case,已改成「word-docx」)」。

自带测试

node test/smoke.mjs

纯 Node、假 ctx,292 项检查:HTTP 门禁、三层覆盖与逐字段合并、assemble 改描述、名单剔除工具定义、guard 拦截、角色卡不重发、预设被显式清空、四类分组、段落↔工具组关联、逐段点名开关与逐段覆写、MCP 补丁行的解析/新增/启停/删除与往返稳定、MCP 管理器真写补丁(新增落全局层、改名跟不跟得上、撞名拦不拦、复制加后缀)、MCP 逐工具总闸、外部插件(dsh-mcp-connector)只读、不写它的状态文件、MCP 连接器显示名(存取、落盘、改名时搬家、删连接器时一起清掉)、MCP 状态七态判定与「启用救不回来时自己补挂」(dispose→等 uid→init,往返十几次都落到已连接)、remountStuck 只捞卡住的不动已跑的、插件页不列 mcp: 段落、MCP 页工具行的备注与工具页共用同一张表、技能来源标签的包名(含作用域包名两段)与三档分类桶(内置/插件/其他,每种来源都落在桶里)、技能启停的 frontmatter 开关、技能市场的 zip 解压与 zip slip 拒绝、非法 slug 与「已经装过了」的拦截、frontmatter 解析、装到指定技能根目录(用假 fetch 喂 zip,验证落点与读到的名字)、展示名被揉成 kebab(原展示名留在 title)、本来就合法的名字不动、没有 frontmatter 就补一个、技能位置按宿主的 source 判(用户目录不会被路径骗成项目内)、卸载自装技能(插件自带/预设自带的拒删、硬删、按路径删也行、技能根目录本身不删)、段落「归属到插件」(命中已有工具组就直接挂过去、归属到没工具的包补空组并标 pinnedOwner、归属到宿主自带的包也照挂不跳过、没人点过时行为不变、第三参不传也不炸、落盘与取消归属、缺段落名报 400)、插件页列全量工具(total 不因当前会话的名单缩水、allowed 只作数据字段给别的判断用、页面不拿它做任何标记)。

Похожие плагины