dsh-skill-picker 0.5.15 → 0.5.19

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,14 @@ source.onPick = (args) => { track(args?.candidate?.name); return onPick.call(sou
141
141
 
142
142
  ## 更新日志
143
143
 
144
+ - **v0.5.18**:**修掉 v0.5.17 里"预热走错了路"的 bug** —— 上一版已经把胜负手找对了(技能组要 **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**
145
+ - **v0.5.17**:**修复「打 `/` 时菜单开到下面去、高亮不在第一条技能」**——症状是输入一个 `/`,菜单**滚到底部**、高亮停在命令组(`添加 / 文件` 或 `指令 / compact`)上,而不是停在最上面第一条技能。**根因**:官方斜杠菜单的高亮是一条「**谁先就绪谁拿高亮、拿到就粘住**」的规则 —— `highlight = validHighlight(旧高亮, groups) ?? firstHighlight(groups)`,`firstHighlight` 取的是**第一个「已就绪」分组**的第一条;随后 `scrollIntoView({ block: "nearest" })` 把高亮滚进视野。本插件为了让技能组排在最上面把它的 `order` 从 2 改成 `-1`,于是**命令组一旦先就绪,高亮就落在它身上、而它现在被压在技能组下面** → 菜单往下滚去够高亮。**关键:这是 microtask 跳数的比赛,不是网络快慢** —— 「已就绪的 promise」`await` 一次就是一个 microtask;实测三种写法的胜负(技能组在 roster 里排第一,命令源至少一层 `await`):| 写法 | 先就绪 |
146
+ |---|---|
147
+ | 包装层 `await` 官方 candidates(2 跳) | 命令组 ❌ |
148
+ | 不 `await`,但返回官方那个 promise(adoption 仍要多跳) | 命令组 ❌ |
149
+ | **命中自己的缓存,0 个 `await` 直接返回** | **技能组 ✅** |
150
+ 所以**光"提前预热"没用**(0.5.16 就是这么改的、也是这么失败的:即使目录已热,我们仍比命令组多一跳)。**修法**:插件自己按 Session 缓存一份**完整展示列表**(`itemCache`),`candidates` 命中缓存时**不 `await` 任何东西**直接返回 —— 它返回的 promise 在调用点就已 resolve,`.then` 的 microtask 排在命令组之前,**从构造上稳赢**;未命中才走官方取数并写缓存。缓存由 `warmSlashSkill(sessionId)`(⚡ 组件挂载 / 会话切换)预热,且每次命中都在**后台刷新**一次,所以最多"落后一次打开"。**同时修掉 0.5.16 引入的三个缺陷**:① 预热在组件挂载时执行,而那时**接管还没装好**(接管带重试),依赖项又不变 → **预热从未真正跑过**;现在把请求存起来,由 `wrapSkillSource()` 落地时**一次性消费**。② `pendingSessionId` **粘住不消**,会让下一次安装去预热一个过期会话;现在改为"消费即清",并在撤销接管时清除。③ 缓存最初**存的是官方返回数组的引用**(测试里 `list.push()` 直接把缓存改了)→ 现在存 **`slice()` 快照**。**注意**:`order: -1`(技能组置顶)**保留** —— 它是这个 bug 的「放大器」而不是原因,且是早先明确要的特性。**另记一笔查证过程**:截图里那个「添加 / 文件」一开始认不出是谁,后来在 `dsh-client-ui-commands` 的词条里翻到 `"section.add": "添加"` —— 它和「指令」是**同一个命令源的两个分区**,不是第二个来源。**回归测试**:`test/slash-source.test.mjs` 增至 25 例(新增:命中缓存不再回官方取数、后台刷新会在下次打开补齐新技能、缓存命中仍走排序、会话之间缓存不串、接管落地前提出的预热请求不丢、缓存存快照而非引用;另加 `beforeEach` 清模块级状态,避免用例互相污染)。全量 `npm test`:**65 tests / 64 pass / 0 fail**(1 skip 是 Windows 建文件符号链接需权限那条)
151
+ - **v0.5.16**(仅推 GitHub、从未发 npm):**同一条 bug 的第一次尝试,未修好** —— 只加了「提前预热技能目录」,没有解决"我们比命令组多一跳"这个真正的胜负手,因此现象照旧。保留这条记录是因为它的失败本身是结论:**在 microtask 竞争里,"数据取好了"和"我们的 promise 更早 settle"是两件事。**
144
152
  - **v0.5.15**:**`/` 菜单增强改为「运行时接管」,彻底不碰文件(对应 issue #14)**——这一版把插件最脆的那根线拔掉了。**背景**:让 `/` 支持模糊 + 拼音,v0.4.0 起的做法是**改写官方 `@deepseek-ai/dsh-client-ui-skill/lib/client.js` 这个文件**。但"那个文件是不是一个可写的普通文件"完全不由插件决定:打包桌面端把它放在 `resources/app.asar` 里(**只能读、不能就地写**)、pnpm 把它硬链接进共享 store、`Desktop → NEXT` 迁移后共享根的软链整批悬空——**任一情况都让补丁静默失效**(更糟的是它还会"成功"地给一份没人在用的副本打上补丁,日志看着一切正常)。**新做法**:官方 `@deepseek-ai/dsh-client-ui-input-trigger` 把源注册表放在**服务实例字段 `live.sources`** 里,里面就是**活源对象**,而斜杠菜单是**每次调用时现取** `source.candidates`(不是注册时抓一份引用)——所以插件可以在运行时把官方那个源的 `candidates` **接管**,自己接管匹配,**一个文件都不用动**,`app.asar` / 硬链接 / 软链三种形态一次全解决。**两个必须讲清的技术点**:① **不能注册同名源**——`registerSource` 对 `(trigger, name)` 有唯一性硬检查(重名抛 `slash source "/skill" is already registered`),这正是 v0.2.0–0.3.4 那条"平行源"路线被官方堵死的原因,也是后来才被迫去改文件的由来;**接管已有源**才是门开的地方。② **必须用空查询向官方要全量**——官方 `candidates` 返回的是**已映射的展示项**,而且已被官方匹配器**过滤**过,模糊查询要匹配的技能根本不在里面;用空查询调用则 `rankByName(items, "")` **原样返回全部**,官方可见性规则(`userInvocable` 过滤、子智能体会话排除、「仅用户可调用」文案)也都已由官方代码先执行过。**本版修正(0.5.14 是个只推了 GitHub、从未发到 npm 的中间版)**:① 0.5.14 把源注册表当成 `ctx.inputTriggers.sources('/')` —— **那个 `{sources, all}` 实际属于每个会话 controller 内部的 `roster`,不在服务上**,于是查找永远 `undefined`、接管**静默地什么都没做**;现在按 `live.sources` → `sources()` → `all()` → `roster.*` 依次尝试,并用回归测试锁死真实形状(`InputTriggerService` 的成员只有 `inject, live, constructor, registerSource, sessionOf, sessions`)。② **接管失败不再是静默的**:放弃时打 `console.warn` 指向 #14,**接管成功时 ⚡ 面板底部显示「/ 增强:运行时接管」徽标** —— 一眼就能看出到底有没有生效。**顺带**:⚡ 面板与 `/` 菜单现在共用同一个 `rankPickerItems()`,两处匹配结果与排序**不可能再不一致**;文件补丁**保留为旧内核兜底**(两条路不打架:运行时接管始终以空查询取全量);启动告警里指向已关闭 issue #9 的链接改为指向 #14。**回归测试**:`test/slash-source.test.mjs` 共 15 例(真实 `live.sources` 形状、roster 形状、无关源跳过、只取 `(/,skill)`、服务缺失/getter 抛错时降级、空查询取全量、空查询不排序、排序抛错仍返回官方列表、顺序接管与还原、`onPick` 接管且保留官方返回值、tracker 抛错不吞官方返回、dispose 还原且清标记、重复接管为 no-op、非源对象忽略、冻结源不抛错、模式徽标状态、迟到注册的重试安装、永不出现时**告警**放弃)。全量 `npm test`:**58 tests / 57 pass / 0 fail**(1 skip 是 Windows 建文件符号链接需权限那条)
145
153
  - **v0.5.13**:**适配 DSH 0.2.0 线(对应 issue #13、#11、#9)**——这一版修的是三件不同的事,但它们有一个共同的教训:**插件"没坏"和"没在跑"长得一模一样**。
146
154
  - **① 内核加载门不再拒载(issue #13)**:`peerDependencies` 里六项 `@deepseek-ai/dsh-*` 全钉在 `^0.1.0-rc.6`,semver 上界是 `<0.2.0-0`,于是 DSH **0.2.0-rc.1 / rc.2 启动时直接 skip 掉整个插件**(`dsh: skipping profile bundle "dsh-skill-picker"`),⚡ 按钮**凭空消失**、没有任何其它症状。关键点是:**这不是"真不兼容",是"声明过度"**——`dsh-app-boot` 的 `evaluatePluginCompatibility()` 是拿**内核版本**去逐项比对 `@deepseek-ai/dsh*` 的 peer 范围,而这些包与内核**锁步同版本**(实测 `dsh` / `dsh-app-boot` / `dsh-client-locale` / `dsh-client-ui-slots` / `dsh-host-webserver` / `dsh-skill` / `dsh-system-prompt` / `dsh-client-ui-skill` 全是同一个号),所以钉住一个小版本线 = **内核每跳一版就必然误拒一次**。现在改为 `^0.1.0-rc.6 || >=0.2.0-rc.1 <1.0.0-0`:0.1.x / 0.2.x 及以后所有 0.x 都能加载,到 `1.0.0` 才需要重新确认。真正的"已验证"信号交还给 `dsh.compatibility.dshReleases` 兼容表(本版补上 `0.1.7-rc.1`、`0.2.0-rc.1`、`0.2.0-rc.2`)
package/lib/client.js CHANGED
@@ -25785,9 +25785,47 @@ function isSkillSourceWrapped(source) {
25785
25785
  return source !== null && typeof source === "object" && source[WRAPPED] === true;
25786
25786
  }
25787
25787
  var enhancementMode;
25788
+ var takenOverSource;
25789
+ var pendingSessionId;
25790
+ var itemCache = /* @__PURE__ */ new Map();
25791
+ function noteDiag(patch) {
25792
+ try {
25793
+ const key = "dsh-skill-picker:diag";
25794
+ const previous = JSON.parse(localStorage.getItem(key) ?? "{}");
25795
+ localStorage.setItem(key, JSON.stringify({ ...previous, ...patch, at: (/* @__PURE__ */ new Date()).toISOString() }));
25796
+ } catch {
25797
+ }
25798
+ }
25799
+ function refreshCache(cacheKey, originalCandidates, self, projection, options) {
25800
+ try {
25801
+ const signal = typeof AbortController === "function" ? new AbortController().signal : void 0;
25802
+ Promise.resolve().then(() => originalCandidates.call(self, projection, { ...options, query: "", signal })).then((items) => {
25803
+ if (Array.isArray(items)) itemCache.set(cacheKey, items.slice());
25804
+ }).catch(() => {
25805
+ });
25806
+ } catch {
25807
+ }
25808
+ }
25788
25809
  function slashEnhancementMode() {
25789
25810
  return enhancementMode;
25790
25811
  }
25812
+ function warmSlashSkill(sessionId) {
25813
+ if (typeof sessionId !== "string" || sessionId === "") return;
25814
+ if (primeNow(sessionId)) pendingSessionId = void 0;
25815
+ else pendingSessionId = sessionId;
25816
+ }
25817
+ function primeNow(sessionId) {
25818
+ const source = takenOverSource;
25819
+ if (source === null || source === void 0) return false;
25820
+ if (itemCache.has(sessionId)) return true;
25821
+ try {
25822
+ const signal = typeof AbortController === "function" ? new AbortController().signal : void 0;
25823
+ Promise.resolve(source.candidates({ sessionId }, { query: "", signal })).catch(() => {
25824
+ });
25825
+ } catch {
25826
+ }
25827
+ return true;
25828
+ }
25791
25829
  function isSkillSource(source) {
25792
25830
  return source !== null && typeof source === "object" && source.trigger === SKILL_SOURCE.trigger && source.name === SKILL_SOURCE.name;
25793
25831
  }
@@ -25845,8 +25883,25 @@ function wrapSkillSource(source, hooks = {}) {
25845
25883
  const taken = [];
25846
25884
  const wrappedCandidates = typeof rank === "function" && typeof original.candidates === "function" ? async function candidates(projection, args) {
25847
25885
  const options = args ?? {};
25848
- const everything = await original.candidates.call(this, projection, { ...options, query: "" });
25849
25886
  const query = String(options.query ?? "").trim();
25887
+ const sessionId = projection?.sessionId;
25888
+ const cacheKey = typeof sessionId === "string" ? sessionId : void 0;
25889
+ const cached = cacheKey === void 0 ? void 0 : itemCache.get(cacheKey);
25890
+ if (cached !== void 0) {
25891
+ noteDiag({ mode: "runtime", servedFromCache: true, sessionId: cacheKey, query });
25892
+ refreshCache(cacheKey, original.candidates, this, projection, options);
25893
+ if (query === "") return cached;
25894
+ try {
25895
+ return rank(cached, query);
25896
+ } catch {
25897
+ return cached;
25898
+ }
25899
+ }
25900
+ const everything = await original.candidates.call(this, projection, { ...options, query: "" });
25901
+ if (cacheKey !== void 0 && Array.isArray(everything)) {
25902
+ itemCache.set(cacheKey, everything.slice());
25903
+ noteDiag({ mode: "runtime", servedFromCache: false, sessionId: cacheKey, primed: true, count: everything.length });
25904
+ }
25850
25905
  if (query === "" || !Array.isArray(everything)) return everything;
25851
25906
  try {
25852
25907
  return rank(everything, query);
@@ -25891,6 +25946,13 @@ function wrapSkillSource(source, hooks = {}) {
25891
25946
  return void 0;
25892
25947
  }
25893
25948
  enhancementMode = "runtime";
25949
+ takenOverSource = source;
25950
+ noteDiag({ mode: "runtime", installed: true, took: taken.join("+") });
25951
+ if (pendingSessionId !== void 0) {
25952
+ const sessionId = pendingSessionId;
25953
+ pendingSessionId = void 0;
25954
+ primeNow(sessionId);
25955
+ }
25894
25956
  return () => {
25895
25957
  try {
25896
25958
  if (taken.includes("candidates")) source.candidates = original.candidates;
@@ -25904,6 +25966,8 @@ function wrapSkillSource(source, hooks = {}) {
25904
25966
  }
25905
25967
  delete source[WRAPPED];
25906
25968
  enhancementMode = void 0;
25969
+ if (takenOverSource === source) takenOverSource = void 0;
25970
+ pendingSessionId = void 0;
25907
25971
  } catch {
25908
25972
  }
25909
25973
  };
@@ -25923,6 +25987,7 @@ function installSlashFuzzy(inputTriggers, hooks = {}, options = {}) {
25923
25987
  if (attempts++ < maxAttempts) {
25924
25988
  timer = setTimeout(attempt, intervalMs);
25925
25989
  } else {
25990
+ noteDiag({ mode: "none", gaveUp: true, attempts: maxAttempts });
25926
25991
  console.warn(`[dsh-skill-picker] / takeover: the official "skill" trigger source was not found after ${maxAttempts} attempts \u2014 the / menu keeps the official matcher. See https://github.com/a735624258/dsh-skill-picker/issues/14`);
25927
25992
  }
25928
25993
  return;
@@ -26199,6 +26264,10 @@ function SkillPickerButton(props) {
26199
26264
  window.addEventListener("dsh-skill-picker:usage-updated", onUsageUpdated);
26200
26265
  return () => window.removeEventListener("dsh-skill-picker:usage-updated", onUsageUpdated);
26201
26266
  }, []);
26267
+ const activeSessionId = sessionIdOf(props);
26268
+ (0, import_react.useEffect)(() => {
26269
+ if (activeSessionId !== void 0) warmSlashSkill(activeSessionId);
26270
+ }, [activeSessionId]);
26202
26271
  const draftRef = (0, import_react.useRef)("");
26203
26272
  if (typeof props.useInput === "function") {
26204
26273
  try {