dsh-skill-picker 0.5.13 → 0.5.18

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.
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Runtime takeover of the official `/` skill source (issue #14).
3
+ *
4
+ * The picker upgrades the official `/` completion from prefix matching to
5
+ * fuzzy + pinyin matching. Until 0.5.14 that was done by rewriting the official
6
+ * `@deepseek-ai/dsh-client-ui-skill/lib/client.js` **file** on disk — which
7
+ * silently stops working whenever that file is not an ordinary writable file:
8
+ * a packaged desktop build keeps it inside `resources/app.asar`, pnpm
9
+ * hardlinks it into a shared store, or a profile's copy is a dangling symlink
10
+ * after the app moves. Every one of those cases produced the same invisible
11
+ * failure: the patch "succeeded" on a copy nothing was serving.
12
+ *
13
+ * This module takes the other road. `@deepseek-ai/dsh-client-ui-input-trigger`
14
+ * publishes its trigger-source registry through the public `inputTriggers`
15
+ * service:
16
+ *
17
+ * sources: (trigger) => live.sources.filter(s => s.trigger === trigger)...
18
+ * all: () => live.sources
19
+ *
20
+ * Those hand back the **live source objects**, and the slash menu reads
21
+ * `source.candidates` at invocation time rather than capturing it at
22
+ * registration. So the picker can wrap that one method and own the matching
23
+ * behaviour without touching a single file, on every install shape at once.
24
+ *
25
+ * What this module deliberately does NOT do: register a second source for
26
+ * `/skill`. The registry rejects duplicate `(trigger, name)` pairs outright
27
+ * (`slash source "/skill" is already registered`), which is why an earlier
28
+ * version of this plugin tried — and abandoned — the parallel-source approach.
29
+ * Wrapping the existing source is the supported way in.
30
+ *
31
+ * @module dsh-skill-picker/client/slash-source
32
+ */
33
+
34
+ /** Marker recorded on a wrapped source, so re-installing is a no-op. */
35
+ const WRAPPED = '__dshSkillPickerWrappedCandidates'
36
+
37
+ /** Identity of the official skill source inside the trigger registry. */
38
+ export const SKILL_SOURCE = { trigger: '/', name: 'skill' }
39
+
40
+ /** Whether this source already carries the picker's wrapper. */
41
+ export function isSkillSourceWrapped(source) {
42
+ return source !== null && typeof source === 'object' && source[WRAPPED] === true
43
+ }
44
+
45
+ /** Set once the runtime takeover is live; read by the ⚡ panel footer. */
46
+ let enhancementMode
47
+
48
+ /** The source the takeover wrapped, so the catalogue can be primed ahead of time. */
49
+ let takenOverSource
50
+
51
+ /** The Session the picker last asked us to prime; consumed when the takeover lands. */
52
+ let pendingSessionId
53
+
54
+ /**
55
+ * Our own copy of the full display list, per Session.
56
+ *
57
+ * Why it exists: the menu highlights **the first group whose `candidates()`
58
+ * promise settles**, and our wrapper used to add an `await` hop of its own on
59
+ * top of the official one — two hops against the command source's chain. A
60
+ * settled-promise `await` is a microtask, so hop count decides, and the menu
61
+ * scrolled down to the command group. Serving the cached list returns without
62
+ * awaiting anything, so this group wins the settle race by construction.
63
+ *
64
+ * Kept fresh by the priming call and by a background refresh on every serve, so
65
+ * the worst case is one menu open behind a very recent skill install.
66
+ */
67
+ const itemCache = new Map()
68
+
69
+ /** Write a small diagnostic record the maintainer can read off disk. */
70
+ function noteDiag(patch) {
71
+ try {
72
+ const key = 'dsh-skill-picker:diag'
73
+ const previous = JSON.parse(localStorage.getItem(key) ?? '{}')
74
+ localStorage.setItem(key, JSON.stringify({ ...previous, ...patch, at: new Date().toISOString() }))
75
+ } catch {
76
+ /* diagnostics must never break anything */
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Refresh our cached display list in the background for the next open.
82
+ * Never awaited, never throws, and uses its own abort signal so the menu's own
83
+ * controller cannot cancel the refresh when it closes.
84
+ */
85
+ function refreshCache(cacheKey, originalCandidates, self, projection, options) {
86
+ try {
87
+ const signal = typeof AbortController === 'function' ? new AbortController().signal : undefined
88
+ Promise.resolve()
89
+ .then(() => originalCandidates.call(self, projection, { ...options, query: '', signal }))
90
+ .then((items) => {
91
+ if (Array.isArray(items)) itemCache.set(cacheKey, items.slice())
92
+ })
93
+ .catch(() => {})
94
+ } catch {
95
+ /* a refresh that cannot start is not worth reporting */
96
+ }
97
+ }
98
+
99
+ /** Which mechanism upgrades the official `/` menu: 'runtime' once installed. */
100
+ export function slashEnhancementMode() {
101
+ return enhancementMode
102
+ }
103
+
104
+ /**
105
+ * Test seam: drop every piece of module-level state.
106
+ *
107
+ * This module deliberately keeps state across calls (the wrapped source, the
108
+ * priming request, the per-Session cache), which makes tests order-dependent
109
+ * unless they start from a clean slate. Not used by the runtime.
110
+ * @internal
111
+ */
112
+ export function __resetSlashStateForTests() {
113
+ itemCache.clear()
114
+ pendingSessionId = undefined
115
+ takenOverSource = undefined
116
+ enhancementMode = undefined
117
+ }
118
+
119
+ /**
120
+ * Prime the official skill catalogue for a Session, and fill our own cache.
121
+ *
122
+ * The slash menu highlights **the first group that settles** and then scrolls it
123
+ * into view (`scrollIntoView({ block: "nearest" })`), and the highlight sticks
124
+ * once set. The picker keeps the skill group first (`order: -1`), so if the
125
+ * command group settles first the menu opens scrolled down to it — the "menu
126
+ * opens at the bottom" report.
127
+ *
128
+ * Nothing warms the skill catalogue on a normal boot: `input-trigger` calls
129
+ * `source.warm?.()` only from `sourceAdded`, which fires just for a source
130
+ * registered *after* a session controller already exists.
131
+ *
132
+ * Called as soon as the picker knows its Session — which can happen BEFORE the
133
+ * takeover installs, since that retries while the official source registers. The
134
+ * Session id is therefore remembered and the warming is retried from
135
+ * `wrapSkillSource()`, otherwise a mount-time call would be silently lost.
136
+ *
137
+ * Best-effort by design: a failed warm changes nothing.
138
+ *
139
+ * @param sessionId - the active Session's id.
140
+ */
141
+ export function warmSlashSkill(sessionId) {
142
+ if (typeof sessionId !== 'string' || sessionId === '') return
143
+ // Ready now: prime directly and keep nothing pending. Not ready (the takeover
144
+ // has not installed yet): remember it, and wrapSkillSource() will consume it.
145
+ if (primeNow(sessionId)) pendingSessionId = undefined
146
+ else pendingSessionId = sessionId
147
+ }
148
+
149
+ /**
150
+ * Do the priming work once the takeover can actually serve it.
151
+ * @returns true when the source exists and was asked (or the cache already had it).
152
+ */
153
+ function primeNow(sessionId) {
154
+ const source = takenOverSource
155
+ if (source === null || source === undefined) return false
156
+ if (itemCache.has(sessionId)) return true
157
+ try {
158
+ // Go through the WRAPPER's own path, never the official `warm()` alone:
159
+ // `warm()` primes only the official module's cache, leaving OUR cache empty,
160
+ // so the menu's own call still misses and still awaits — which is exactly
161
+ // how v0.5.16/v0.5.17 kept losing the settle race. The wrapper stores a
162
+ // snapshot on a miss, so this single call fills both layers.
163
+ const signal = typeof AbortController === 'function' ? new AbortController().signal : undefined
164
+ Promise.resolve(source.candidates({ sessionId }, { query: '', signal })).catch(() => {})
165
+ } catch {
166
+ /* priming is best-effort */
167
+ }
168
+ return true
169
+ }
170
+
171
+
172
+ /** Whether an object is the official skill source. */
173
+ function isSkillSource(source) {
174
+ return source !== null && typeof source === 'object'
175
+ && source.trigger === SKILL_SOURCE.trigger
176
+ && source.name === SKILL_SOURCE.name
177
+ }
178
+
179
+ /**
180
+ * Every place the live source registry has actually been observed, most precise
181
+ * first. **The service itself does NOT expose `sources()` / `all()`** — those
182
+ * belong to the per-session controller's `roster`, built as
183
+ * `{ sources: (trigger) => live.sources.filter(...), all: () => live.sources }`.
184
+ * The service (`ctx.inputTriggers`, an `InputTriggerService extends Service`) is
185
+ * exactly: `inject, live, constructor, registerSource, sessionOf, sessions` —
186
+ * so `live.sources` is the real registry and the one that matters. The other
187
+ * shapes are kept because reaching for a helper that does not exist is what
188
+ * made the first release of this module silently do nothing.
189
+ *
190
+ * @param service - the injected inputTriggers service.
191
+ * @returns arrays to search, in order.
192
+ */
193
+ function registryCandidates(service) {
194
+ const lists = []
195
+ const push = (value) => {
196
+ if (Array.isArray(value) && value.length > 0) lists.push(value)
197
+ }
198
+ try { push(service.live?.sources) } catch { /* getter threw */ }
199
+ try { if (typeof service.sources === 'function') push(service.sources(SKILL_SOURCE.trigger)) } catch { /* ignore */ }
200
+ try { if (typeof service.all === 'function') push(service.all()) } catch { /* ignore */ }
201
+ try { push(service.sources) } catch { /* ignore */ }
202
+ try { if (typeof service.roster?.sources === 'function') push(service.roster.sources(SKILL_SOURCE.trigger)) } catch { /* ignore */ }
203
+ try { if (typeof service.roster?.all === 'function') push(service.roster.all()) } catch { /* ignore */ }
204
+ return lists
205
+ }
206
+
207
+ /**
208
+ * Locate the official skill source through the injected `inputTriggers` service.
209
+ * Returns the **live object**, so the caller can take it over in place.
210
+ *
211
+ * @param service - the injected service, if the host provided one.
212
+ * @returns the source object, or undefined while it is not registered yet.
213
+ */
214
+ export function findSkillSource(service) {
215
+ if (service === null || service === undefined) return undefined
216
+ for (const list of registryCandidates(service)) {
217
+ const found = list.find(isSkillSource)
218
+ if (found !== undefined) return found
219
+ }
220
+ return undefined
221
+ }
222
+
223
+ /**
224
+ * Take the official source over at runtime: matching, group order and pick
225
+ * tracking — the same three upgrades the file patch used to apply, without
226
+ * writing a single byte to disk. Returns a disposer restoring every field it
227
+ * touched, or undefined when nothing could be taken over.
228
+ *
229
+ * 1. `candidates` → the picker's fuzzy + pinyin ranking. The official
230
+ * implementation is handed the live query and returns the **already mapped
231
+ * display items**, having filtered out everything its own matcher did not
232
+ * match — so a fuzzy query can never see the skills it needs. Asking it for
233
+ * an empty query returns the whole visible catalogue instead
234
+ * (`rankByName(items, "")` returns `items` verbatim), which is exactly the
235
+ * input the picker's ranking wants. The official visibility rules
236
+ * (userInvocable filtering, subagent-session exclusion, the "user only"
237
+ * label) are all applied before the list reaches us.
238
+ * 2. `order` → the skill group sorts above the command group (the file patch
239
+ * used to rewrite `order: 2` to `-1`).
240
+ * 3. `onPick` → records the pick, so a slash pick ranks as "recently used" in
241
+ * the ⚡ panel too.
242
+ *
243
+ * @param source - a live source object from the trigger registry.
244
+ * @param hooks - { rank(items, query), order(number), onPick(name) }.
245
+ * @returns a disposer, or undefined when nothing was taken over.
246
+ */
247
+ export function wrapSkillSource(source, hooks = {}) {
248
+ const { rank, order, onPick } = hooks
249
+ if (source === null || typeof source !== 'object') return undefined
250
+ if (isSkillSourceWrapped(source)) return undefined
251
+ // A frozen or sealed source cannot take the wrapper, and a failed assignment
252
+ // would throw out of `apply` and take the whole plugin (and the web shell)
253
+ // down with it. Decline quietly instead; the file patch remains as fallback.
254
+ if (Object.isFrozen(source)) return undefined
255
+
256
+ const original = {
257
+ candidates: source.candidates,
258
+ order: source.order,
259
+ hadOrder: Object.hasOwn(source, 'order'),
260
+ onPick: source.onPick,
261
+ hadOnPick: Object.hasOwn(source, 'onPick'),
262
+ }
263
+ const taken = []
264
+
265
+ const wrappedCandidates = typeof rank === 'function' && typeof original.candidates === 'function'
266
+ ? async function candidates(projection, args) {
267
+ const options = args ?? {}
268
+ const query = String(options.query ?? '').trim()
269
+ const sessionId = projection?.sessionId
270
+ const cacheKey = typeof sessionId === 'string' ? sessionId : undefined
271
+ const cached = cacheKey === undefined ? undefined : itemCache.get(cacheKey)
272
+
273
+ // Cache hit: return WITHOUT awaiting anything. The returned promise is
274
+ // already resolved, so this group settles on the first microtask — ahead
275
+ // of any group whose candidates awaits even a settled promise.
276
+ if (cached !== undefined) {
277
+ noteDiag({ mode: 'runtime', servedFromCache: true, sessionId: cacheKey, query })
278
+ refreshCache(cacheKey, original.candidates, this, projection, options)
279
+ if (query === '') return cached
280
+ try {
281
+ return rank(cached, query)
282
+ } catch {
283
+ return cached
284
+ }
285
+ }
286
+
287
+ const everything = await original.candidates.call(this, projection, { ...options, query: '' })
288
+ if (cacheKey !== undefined && Array.isArray(everything)) {
289
+ // Store a SNAPSHOT, never the caller's array: the official source hands
290
+ // back a container it may keep and grow, and aliasing it would make the
291
+ // cache mutate underneath us.
292
+ itemCache.set(cacheKey, everything.slice())
293
+ noteDiag({ mode: 'runtime', servedFromCache: false, sessionId: cacheKey, primed: true, count: everything.length })
294
+ }
295
+ if (query === '' || !Array.isArray(everything)) return everything
296
+ try {
297
+ return rank(everything, query)
298
+ } catch {
299
+ // Ranking is an enhancement: a picker bug must never break the menu.
300
+ return everything
301
+ }
302
+ }
303
+ : undefined
304
+
305
+ const wrappedPick = typeof onPick === 'function' && typeof original.onPick === 'function'
306
+ ? function onPickWrapped(args) {
307
+ try {
308
+ const name = args?.candidate?.name
309
+ if (typeof name === 'string') onPick(name)
310
+ } catch {
311
+ /* usage bookkeeping is best-effort */
312
+ }
313
+ return original.onPick.call(this, args)
314
+ }
315
+ : undefined
316
+
317
+ try {
318
+ Object.defineProperty(source, WRAPPED, { value: true, enumerable: false, configurable: true })
319
+ if (wrappedCandidates !== undefined) {
320
+ source.candidates = wrappedCandidates
321
+ taken.push('candidates')
322
+ }
323
+ if (typeof order === 'number' && source.order !== order) {
324
+ source.order = order
325
+ taken.push('order')
326
+ }
327
+ if (wrappedPick !== undefined) {
328
+ source.onPick = wrappedPick
329
+ taken.push('onPick')
330
+ }
331
+ } catch {
332
+ try {
333
+ delete source[WRAPPED]
334
+ } catch {
335
+ /* nothing left to undo */
336
+ }
337
+ return undefined
338
+ }
339
+
340
+ if (taken.length === 0) {
341
+ try {
342
+ delete source[WRAPPED]
343
+ } catch {
344
+ /* nothing left to undo */
345
+ }
346
+ return undefined
347
+ }
348
+
349
+ enhancementMode = 'runtime'
350
+ takenOverSource = source
351
+ noteDiag({ mode: 'runtime', installed: true, took: taken.join('+') })
352
+ // A Session may have asked to be primed before the takeover landed (the
353
+ // picker mounts first, the takeover retries until the official source
354
+ // registers); consume that request exactly once.
355
+ if (pendingSessionId !== undefined) {
356
+ const sessionId = pendingSessionId
357
+ pendingSessionId = undefined
358
+ primeNow(sessionId)
359
+ }
360
+
361
+ return () => {
362
+ try {
363
+ if (taken.includes('candidates')) source.candidates = original.candidates
364
+ if (taken.includes('order')) {
365
+ if (original.hadOrder) source.order = original.order
366
+ else delete source.order
367
+ }
368
+ if (taken.includes('onPick')) {
369
+ if (original.hadOnPick) source.onPick = original.onPick
370
+ else delete source.onPick
371
+ }
372
+ delete source[WRAPPED]
373
+ enhancementMode = undefined
374
+ if (takenOverSource === source) takenOverSource = undefined
375
+ // The cache deliberately SURVIVES a re-install: after a hot reload the
376
+ // first menu open should still be served instantly instead of losing the
377
+ // settle race once more.
378
+ // A disposed takeover must not keep a priming request for a Session that
379
+ // may be gone by the time it is installed again.
380
+ pendingSessionId = undefined
381
+ } catch {
382
+ /* a source that became read-only keeps the wrapper; not fatal */
383
+ }
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Install the takeover, retrying while the official plugin has not registered
389
+ * its source yet (both halves load during the same client boot, in an order the
390
+ * picker does not control).
391
+ *
392
+ * @param inputTriggers - the injected service.
393
+ * @param hooks - { rank, order, onPick } passed through to wrapSkillSource.
394
+ * @param options - attempts (default 40) / intervalMs (default 250).
395
+ * @returns a disposer that stops retrying and unwraps.
396
+ */
397
+ export function installSlashFuzzy(inputTriggers, hooks = {}, options = {}) {
398
+ const maxAttempts = options.attempts ?? 40
399
+ const intervalMs = options.intervalMs ?? 250
400
+ let restore
401
+ let timer
402
+ let attempts = 0
403
+ let stopped = false
404
+
405
+ const attempt = () => {
406
+ if (stopped || restore !== undefined) return
407
+ try {
408
+ const source = findSkillSource(inputTriggers)
409
+ if (source === undefined) {
410
+ if (attempts++ < maxAttempts) {
411
+ timer = setTimeout(attempt, intervalMs)
412
+ } else {
413
+ // Loud on the give-up case. The first release of this module reached
414
+ // for a helper the service does not have and then quietly did nothing
415
+ // for ten seconds; nobody could tell it had never run.
416
+ noteDiag({ mode: 'none', gaveUp: true, attempts: maxAttempts })
417
+ console.warn('[dsh-skill-picker] / takeover: the official "skill" trigger source was not found'
418
+ + ` after ${maxAttempts} attempts — the / menu keeps the official matcher.`
419
+ + ' See https://github.com/a735624258/dsh-skill-picker/issues/14')
420
+ }
421
+ return
422
+ }
423
+ // Already wrapped (a previous install, or an HMR re-apply): stop retrying
424
+ // and leave the existing wrapper in place.
425
+ restore = wrapSkillSource(source, hooks) ?? (() => {})
426
+ } catch {
427
+ // Never let the takeover attempt escape into the client boot.
428
+ restore = () => {}
429
+ }
430
+ }
431
+
432
+ attempt()
433
+
434
+ return () => {
435
+ stopped = true
436
+ if (timer !== undefined) clearTimeout(timer)
437
+ restore?.()
438
+ restore = undefined
439
+ }
440
+ }
@@ -344,7 +344,7 @@ export async function healUiSkillPatches() {
344
344
  if (files.length === 0) {
345
345
  console.warn('[dsh-skill-picker] ui-skill patch: 0 target client.js found under '
346
346
  + `${path.join(dshHome(), 'profiles')} — fuzzy+pinyin matching will NOT be applied.`
347
- + `${note} See https://github.com/a735624258/dsh-skill-picker/issues/9`)
347
+ + `${note} See https://github.com/a735624258/dsh-skill-picker/issues/14`)
348
348
  } else if (note !== '') {
349
349
  // Targets existed, so the old code stayed silent — but none of them is the
350
350
  // copy the host serves. That is issue #9's silent failure: a "successful"