dsh-skill-picker 0.5.21 → 0.5.22

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/README.md CHANGED
@@ -141,6 +141,7 @@ source.onPick = (args) => { track(args?.candidate?.name); return onPick.call(sou
141
141
 
142
142
  ## 更新日志
143
143
 
144
+ - **v0.5.22**:**「置顶 / 最近使用」改成两端(乃至手机)共用一份**——起因是国豪发现"桌面端和网页端的置顶不一样",并当场质疑"不就读同一个配置吗,要一个小时?"。**他是对的,而且根因就是"读的不是同一个配置"**:`localStorage` **按源(origin)隔离**,桌面端是 `dsh-app://app`、网页端是 `http://127.0.0.1:3080`、手机连上去又是第三个源 —— **同一份数据被存了三遍**,所以在桌面端钉的技能网页端永远看不到。**修法(约 40 行 host + 一个客户端模块,不是重写数据层)**:host 侧新增 `/dsh-skill-picker/state`(GET 取 / PUT 存),存到 **`$DSH_HOME/dsh-skill-picker-state.json`**(写用 temp+rename,原子);客户端新增 `src/client/shared-state.js`,把这份**母本镜像进 localStorage**,于是渲染期的**同步**读取一行都不用改(原来的数据路径是同步的,改成异步要动一大片 —— 这个顾虑没必要)。**细节**:① 每次 `savePinned`/`saveUsage`(含 `/` 菜单里的选中记录)都会**自动回推**;② 插件启动时 + **⚡ 面板每次打开时**都会拉取,所以另一端改了立刻能看见;③ **首次同步用并集、之后才跟随**(关键,实测中发现的坑):host 端 `?migrate=1` 会把收到的状态与已有状态**合并**再返回;客户端用一个 `dsh-skill-picker:shared-synced` 标记区分"这台还没同步过"。**没同步过**且本地有数据 → 走并集迁移(绝不覆盖);**同步过** → 直接采纳文件(所以"在 A 端取消置顶"能真的传播到 B 端)。**为什么必须有这一步**:如果首次也直接采纳,那么**哪台客户端先启动,文件就被它写成什么样,后面启动的那台就会丢掉自己独有的钉子** —— 桌面端 3 条、网页端 2 条的情况下,网页端先起就会把桌面端的 `ji-zhang` 吃掉。端到端实测时正是这条被暴露出来的;④ 文件不存在 / 为空而本地有数据时,**推送而不是覆盖为空**;⑤ 全程 **best-effort**:host 不可达时行为和以前**完全一样**(纯本地),绝不会因为同步失败把选择器弄坏。**回归测试**:新增 `test/shared-state.test.mjs` 7 例(拉取即镜像、缺文件时以本机播种并带 `migrate=1`、迁移返回的并集被本地采纳、远端为空时不把本机数据清成空、推送内容正确、host 失败时一律返回 false 且不抛、同步后会广播事件让界面重读)。全量 `npm test`:**72 tests / 71 pass / 0 fail**
144
145
  - **v0.5.21**:**文件补丁正式退休:默认不再改官方文件,并自动把改过的还原回去**——起因是国豪发现**同一件事桌面端对、网页端不对**,并直接怀疑到"之前魔改过"。**查证结果:他猜对了。** 两端加载的官方 `@deepseek-ai/dsh-client-ui-skill` 根本不是同一份:**桌面端从 `app.asar` 取**(打包只读,永远是原版),**网页端从 `~/.dsh/profiles/node_modules/` 取共享那份 —— 而那份被本插件的老文件补丁改过**(实测:`__dshSkillPickerFuzzy`×2、`order: -1`,旁边躺着 `.bak`)。**这个差异为什么会让排序看起来不一样**:补丁把官方 `candidates` 改成走 `window.__dshSkillPickerFuzzy`,而它返回的**已经是「置顶+使用热度」排好序**的列表;我的包装层再做一次排序时,`groupByPinned` 把「🗂️ 全部」分组的**输入顺序**保留下来(同热度时的稳定排序)——于是网页端的「全部」是**使用热度序**、桌面端是**字母序**。**根因级别的问题**:文件补丁自 v0.5.15 起**已是纯冗余** —— 它只做三件事(技能组置顶 `order: -1`、模糊+拼音匹配、`onPick` 记使用),而**运行时接管三件全都自己做了**。它却仍在改用户的官方包,还让两个 profile 对同一个文件产生分歧。**修法**:① 新增 `revertUiSkillPatches()`,启动时**只针对"仍带我们标记"的文件**从 `.bak` 还原(用 temp+rename,绝不动 pnpm 硬链接的共享 inode;幂等);② 文件补丁改为**默认关闭**,只有显式设 `DSH_SKILL_PICKER_FILE_PATCH=1` 才走旧的自愈路径(给"触发源注册表里没有 live sources 可接管"的老内核兜底)。**本机已实测还原**:共享那份 `lib/client.js` 现在 `__dshSkillPickerFuzzy`=0、`order: 2`、**hash 与 `.bak` 完全一致**。全量 `npm test`:**65 tests / 64 pass / 0 fail**
145
146
  - **v0.5.20**:**`/` 菜单的空查询也走排序,「置顶」技能排到最前面(与 ⚡ 面板一致)**——国豪指出:按 ⚡ 按钮时列表是「📌 置顶(3)」在前、再「📁 全部(57)」,但直接打 `/` 出来的却**是纯字母序、置顶不在前面**,两边排序不一样。**根因**:包装层对**空查询**做了短路 —— `if (query === '') return cached` / `if (query === '' || ...) return everything`,即"空查询把官方列表原封不动返回"。而 ⚡ 面板走的是 `rankPickerItems(items, '')`,它会先 `groupByPinned(...)` 把置顶提到最前。**于是同一个数据源、两套排序。** **修法**:去掉空查询短路,**空查询也交给 `rank()`** —— `rankPickerItems(items, '')` 本就设计为"空查询 = 全部条目、按面板顺序(置顶优先)",所以两边从此同源同序。**顺带发现一条假通过的测试**:旧的 `an empty query returns the official result untouched` 里 `rank` 是**故意抛错**的,而 `rank` 一抛错就被包装层的 try/catch 兜住、仍然返回官方列表 —— 也就是说那条测试**测的是兜底路径,不是它声称的语义**,所以它在新行为下照样"通过"。已改写为正面断言:空查询**必须**调用 `rank`,且置顶技能排第一。全量 `npm test`:**65 tests / 64 pass / 0 fail**
146
147
  - **v0.5.19**:**修掉 v0.5.17 里"预热走错了路"的 bug**(0.5.18 因 npm 发布延迟被误判为"卡住",实际已发布;两者代码逐字节相同)—— 上一版已经把胜负手找对了(技能组要 **0 个 `await`** 才抢得到高亮),但它预热时调的是**官方 `source.warm()`**:`warm()` 只预热**官方模块自己的**缓存,**插件自己的 `itemCache` 依然是空的** → 菜单打开那次**仍然未命中**、仍然 `await` 官方、仍然比命令组多一跳、**仍然输**。这一条是从运行诊断里确认的:`{"mode":"runtime","installed":true,"took":"candidates+order+onPick"}` 与 `{"servedFromCache":false,"sessionId":"session-de1f2494-…","primed":true,"count":71}` —— 即"接管装上了、缓存也被填过,但菜单那次仍是 `servedFromCache:false`"。**修法**:预热改为**走插件自己的包装路径**(一次空查询 `candidates`),**一次调用同时填满两层**(包装层写快照 + 官方层自然预热)。另:缓存**热重载后不再被清空**,所以重载后的第一次打开也是命中。**桌面端已实测确认修好**。**回归测试**:新增一条直击要害的用例 —— 官方取数故意慢 60ms,预热后菜单调用**必须在 30ms 内返回**;另把"预热走官方 `warm()`"这条反例锁死(断言**不会**只调 `warm()`)。全量 `npm test`:**65 tests / 64 pass / 0 fail**
package/lib/client.js CHANGED
@@ -25778,6 +25778,112 @@ function useWorkspaceCwd(props) {
25778
25778
  return typeof cwd === "string" ? cwd : "";
25779
25779
  }
25780
25780
 
25781
+ // src/client/shared-state.js
25782
+ var PINNED_KEY = "dsh-skill-picker:pinned";
25783
+ var USAGE_KEY = "dsh-skill-picker:usage";
25784
+ var SYNCED_KEY = "dsh-skill-picker:shared-synced";
25785
+ var ENDPOINT = "/dsh-skill-picker/state";
25786
+ var SHARED_STATE_EVENT = "dsh-skill-picker:shared-state";
25787
+ function readLocalPinned() {
25788
+ try {
25789
+ const parsed = JSON.parse(localStorage.getItem(PINNED_KEY) ?? "[]");
25790
+ return Array.isArray(parsed) ? parsed.filter((name) => typeof name === "string") : [];
25791
+ } catch {
25792
+ return [];
25793
+ }
25794
+ }
25795
+ function readLocalUsage() {
25796
+ try {
25797
+ const parsed = JSON.parse(localStorage.getItem(USAGE_KEY) ?? "{}");
25798
+ return parsed !== null && typeof parsed === "object" ? parsed : {};
25799
+ } catch {
25800
+ return {};
25801
+ }
25802
+ }
25803
+ function writeLocal(state) {
25804
+ try {
25805
+ localStorage.setItem(PINNED_KEY, JSON.stringify(state?.pinned ?? []));
25806
+ localStorage.setItem(USAGE_KEY, JSON.stringify(state?.usage ?? {}));
25807
+ } catch {
25808
+ }
25809
+ }
25810
+ function isEmptyState(state) {
25811
+ if (state === null || state === void 0 || typeof state !== "object") return true;
25812
+ const pinned = Array.isArray(state.pinned) ? state.pinned : [];
25813
+ const usage = state.usage !== null && typeof state.usage === "object" ? state.usage : {};
25814
+ return pinned.length === 0 && Object.keys(usage).length === 0;
25815
+ }
25816
+ function hasSynced() {
25817
+ try {
25818
+ return localStorage.getItem(SYNCED_KEY) === "1";
25819
+ } catch {
25820
+ return false;
25821
+ }
25822
+ }
25823
+ function markSynced() {
25824
+ try {
25825
+ localStorage.setItem(SYNCED_KEY, "1");
25826
+ } catch {
25827
+ }
25828
+ }
25829
+ async function pushSharedState({ migrate = false } = {}) {
25830
+ try {
25831
+ const response = await fetch(`${ENDPOINT}${migrate ? "?migrate=1" : ""}`, {
25832
+ method: "PUT",
25833
+ headers: { "content-type": "application/json" },
25834
+ body: JSON.stringify({ pinned: readLocalPinned(), usage: readLocalUsage() })
25835
+ });
25836
+ if (!response.ok) return false;
25837
+ const body = await response.json();
25838
+ if (body?.ok !== true) return false;
25839
+ if (migrate) writeLocal(body.state);
25840
+ return true;
25841
+ } catch {
25842
+ return false;
25843
+ }
25844
+ }
25845
+ async function pullSharedState() {
25846
+ try {
25847
+ const response = await fetch(ENDPOINT, { headers: { accept: "application/json" } });
25848
+ if (!response.ok) return false;
25849
+ const body = await response.json();
25850
+ if (body?.ok !== true) return false;
25851
+ const localHasData = !isEmptyState({ pinned: readLocalPinned(), usage: readLocalUsage() });
25852
+ if (body.state === null || body.state === void 0) {
25853
+ const pushed = await pushSharedState({ migrate: true });
25854
+ if (pushed) markSynced();
25855
+ return true;
25856
+ }
25857
+ if (!hasSynced() && localHasData) {
25858
+ const pushed = await pushSharedState({ migrate: true });
25859
+ if (pushed) {
25860
+ markSynced();
25861
+ return true;
25862
+ }
25863
+ }
25864
+ if (isEmptyState(body.state) && localHasData) {
25865
+ const pushed = await pushSharedState({ migrate: true });
25866
+ if (pushed) markSynced();
25867
+ return true;
25868
+ }
25869
+ writeLocal(body.state);
25870
+ markSynced();
25871
+ return true;
25872
+ } catch {
25873
+ return false;
25874
+ }
25875
+ }
25876
+ async function syncSharedState() {
25877
+ const pulled = await pullSharedState();
25878
+ if (pulled) {
25879
+ try {
25880
+ window.dispatchEvent(new Event(SHARED_STATE_EVENT));
25881
+ } catch {
25882
+ }
25883
+ }
25884
+ return pulled;
25885
+ }
25886
+
25781
25887
  // src/client/slash-source.js
25782
25888
  var WRAPPED = "__dshSkillPickerWrappedCandidates";
25783
25889
  var SKILL_SOURCE = { trigger: "/", name: "skill" };
@@ -26010,11 +26116,11 @@ function installSlashFuzzy(inputTriggers, hooks = {}, options = {}) {
26010
26116
  // src/client/index.jsx
26011
26117
  var import_jsx_runtime = require("react/jsx-runtime");
26012
26118
  var inject = ["slots", "connection", "sessions", "inputTriggers"];
26013
- var USAGE_KEY = "dsh-skill-picker:usage";
26014
- var PINNED_KEY = "dsh-skill-picker:pinned";
26119
+ var USAGE_KEY2 = "dsh-skill-picker:usage";
26120
+ var PINNED_KEY2 = "dsh-skill-picker:pinned";
26015
26121
  function loadPinned() {
26016
26122
  try {
26017
- const raw = localStorage.getItem(PINNED_KEY);
26123
+ const raw = localStorage.getItem(PINNED_KEY2);
26018
26124
  if (raw === null) return [];
26019
26125
  const parsed = JSON.parse(raw);
26020
26126
  return Array.isArray(parsed) ? parsed.filter((name) => typeof name === "string") : [];
@@ -26024,13 +26130,14 @@ function loadPinned() {
26024
26130
  }
26025
26131
  function savePinned(pinned) {
26026
26132
  try {
26027
- localStorage.setItem(PINNED_KEY, JSON.stringify(pinned));
26133
+ localStorage.setItem(PINNED_KEY2, JSON.stringify(pinned));
26028
26134
  } catch {
26029
26135
  }
26136
+ void pushSharedState();
26030
26137
  }
26031
26138
  function loadUsage() {
26032
26139
  try {
26033
- const raw = localStorage.getItem(USAGE_KEY);
26140
+ const raw = localStorage.getItem(USAGE_KEY2);
26034
26141
  if (raw === null) return {};
26035
26142
  const parsed = JSON.parse(raw);
26036
26143
  return parsed !== null && typeof parsed === "object" ? parsed : {};
@@ -26040,9 +26147,10 @@ function loadUsage() {
26040
26147
  }
26041
26148
  function saveUsage(usage) {
26042
26149
  try {
26043
- localStorage.setItem(USAGE_KEY, JSON.stringify(usage));
26150
+ localStorage.setItem(USAGE_KEY2, JSON.stringify(usage));
26044
26151
  } catch {
26045
26152
  }
26153
+ void pushSharedState();
26046
26154
  }
26047
26155
  function isUserFacingSkill(skill) {
26048
26156
  const flag = skill?.userInvocable ?? skill?.invocation?.userInvocable;
@@ -26263,6 +26371,15 @@ function SkillPickerButton(props) {
26263
26371
  window.addEventListener("dsh-skill-picker:usage-updated", onUsageUpdated);
26264
26372
  return () => window.removeEventListener("dsh-skill-picker:usage-updated", onUsageUpdated);
26265
26373
  }, []);
26374
+ (0, import_react.useEffect)(() => {
26375
+ const onSharedState = () => {
26376
+ setUsage(loadUsage());
26377
+ setPinned(loadPinned());
26378
+ };
26379
+ window.addEventListener(SHARED_STATE_EVENT, onSharedState);
26380
+ void syncSharedState();
26381
+ return () => window.removeEventListener(SHARED_STATE_EVENT, onSharedState);
26382
+ }, []);
26266
26383
  const activeSessionId = sessionIdOf(props);
26267
26384
  (0, import_react.useEffect)(() => {
26268
26385
  if (activeSessionId !== void 0) warmSlashSkill(activeSessionId);
@@ -26303,6 +26420,7 @@ function SkillPickerButton(props) {
26303
26420
  }, [skills, error, props.listSkills, props.sessionId, props.session, props.cwd]);
26304
26421
  const toggle = () => {
26305
26422
  if (!open) {
26423
+ void syncSharedState();
26306
26424
  setUsage(loadUsage());
26307
26425
  void load();
26308
26426
  }
@@ -26504,6 +26622,7 @@ function SkillPickerButton(props) {
26504
26622
  ] });
26505
26623
  }
26506
26624
  function apply(ctx) {
26625
+ void syncSharedState();
26507
26626
  const listSkills = async (sessionId) => {
26508
26627
  const remoteSkills = ctx.remote?.skills;
26509
26628
  const connectionSkills = ctx.connection?.api?.skills;