dsh-skill-picker 0.5.13 → 0.5.15

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,278 @@
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
+ /** Which mechanism upgrades the official `/` menu: 'runtime' once installed. */
49
+ export function slashEnhancementMode() {
50
+ return enhancementMode
51
+ }
52
+
53
+ /** Whether an object is the official skill source. */
54
+ function isSkillSource(source) {
55
+ return source !== null && typeof source === 'object'
56
+ && source.trigger === SKILL_SOURCE.trigger
57
+ && source.name === SKILL_SOURCE.name
58
+ }
59
+
60
+ /**
61
+ * Every place the live source registry has actually been observed, most precise
62
+ * first. **The service itself does NOT expose `sources()` / `all()`** — those
63
+ * belong to the per-session controller's `roster`, built as
64
+ * `{ sources: (trigger) => live.sources.filter(...), all: () => live.sources }`.
65
+ * The service (`ctx.inputTriggers`, an `InputTriggerService extends Service`) is
66
+ * exactly: `inject, live, constructor, registerSource, sessionOf, sessions` —
67
+ * so `live.sources` is the real registry and the one that matters. The other
68
+ * shapes are kept because reaching for a helper that does not exist is what
69
+ * made the first release of this module silently do nothing.
70
+ *
71
+ * @param service - the injected inputTriggers service.
72
+ * @returns arrays to search, in order.
73
+ */
74
+ function registryCandidates(service) {
75
+ const lists = []
76
+ const push = (value) => {
77
+ if (Array.isArray(value) && value.length > 0) lists.push(value)
78
+ }
79
+ try { push(service.live?.sources) } catch { /* getter threw */ }
80
+ try { if (typeof service.sources === 'function') push(service.sources(SKILL_SOURCE.trigger)) } catch { /* ignore */ }
81
+ try { if (typeof service.all === 'function') push(service.all()) } catch { /* ignore */ }
82
+ try { push(service.sources) } catch { /* ignore */ }
83
+ try { if (typeof service.roster?.sources === 'function') push(service.roster.sources(SKILL_SOURCE.trigger)) } catch { /* ignore */ }
84
+ try { if (typeof service.roster?.all === 'function') push(service.roster.all()) } catch { /* ignore */ }
85
+ return lists
86
+ }
87
+
88
+ /**
89
+ * Locate the official skill source through the injected `inputTriggers` service.
90
+ * Returns the **live object**, so the caller can take it over in place.
91
+ *
92
+ * @param service - the injected service, if the host provided one.
93
+ * @returns the source object, or undefined while it is not registered yet.
94
+ */
95
+ export function findSkillSource(service) {
96
+ if (service === null || service === undefined) return undefined
97
+ for (const list of registryCandidates(service)) {
98
+ const found = list.find(isSkillSource)
99
+ if (found !== undefined) return found
100
+ }
101
+ return undefined
102
+ }
103
+
104
+ /**
105
+ * Take the official source over at runtime: matching, group order and pick
106
+ * tracking — the same three upgrades the file patch used to apply, without
107
+ * writing a single byte to disk. Returns a disposer restoring every field it
108
+ * touched, or undefined when nothing could be taken over.
109
+ *
110
+ * 1. `candidates` → the picker's fuzzy + pinyin ranking. The official
111
+ * implementation is handed the live query and returns the **already mapped
112
+ * display items**, having filtered out everything its own matcher did not
113
+ * match — so a fuzzy query can never see the skills it needs. Asking it for
114
+ * an empty query returns the whole visible catalogue instead
115
+ * (`rankByName(items, "")` returns `items` verbatim), which is exactly the
116
+ * input the picker's ranking wants. The official visibility rules
117
+ * (userInvocable filtering, subagent-session exclusion, the "user only"
118
+ * label) are all applied before the list reaches us.
119
+ * 2. `order` → the skill group sorts above the command group (the file patch
120
+ * used to rewrite `order: 2` to `-1`).
121
+ * 3. `onPick` → records the pick, so a slash pick ranks as "recently used" in
122
+ * the ⚡ panel too.
123
+ *
124
+ * @param source - a live source object from the trigger registry.
125
+ * @param hooks - { rank(items, query), order(number), onPick(name) }.
126
+ * @returns a disposer, or undefined when nothing was taken over.
127
+ */
128
+ export function wrapSkillSource(source, hooks = {}) {
129
+ const { rank, order, onPick } = hooks
130
+ if (source === null || typeof source !== 'object') return undefined
131
+ if (isSkillSourceWrapped(source)) return undefined
132
+ // A frozen or sealed source cannot take the wrapper, and a failed assignment
133
+ // would throw out of `apply` and take the whole plugin (and the web shell)
134
+ // down with it. Decline quietly instead; the file patch remains as fallback.
135
+ if (Object.isFrozen(source)) return undefined
136
+
137
+ const original = {
138
+ candidates: source.candidates,
139
+ order: source.order,
140
+ hadOrder: Object.hasOwn(source, 'order'),
141
+ onPick: source.onPick,
142
+ hadOnPick: Object.hasOwn(source, 'onPick'),
143
+ }
144
+ const taken = []
145
+
146
+ const wrappedCandidates = typeof rank === 'function' && typeof original.candidates === 'function'
147
+ ? async function candidates(projection, args) {
148
+ const options = args ?? {}
149
+ const everything = await original.candidates.call(this, projection, { ...options, query: '' })
150
+ const query = String(options.query ?? '').trim()
151
+ if (query === '' || !Array.isArray(everything)) return everything
152
+ try {
153
+ return rank(everything, query)
154
+ } catch {
155
+ // Ranking is an enhancement: a picker bug must never break the menu.
156
+ return everything
157
+ }
158
+ }
159
+ : undefined
160
+
161
+ const wrappedPick = typeof onPick === 'function' && typeof original.onPick === 'function'
162
+ ? function onPickWrapped(args) {
163
+ try {
164
+ const name = args?.candidate?.name
165
+ if (typeof name === 'string') onPick(name)
166
+ } catch {
167
+ /* usage bookkeeping is best-effort */
168
+ }
169
+ return original.onPick.call(this, args)
170
+ }
171
+ : undefined
172
+
173
+ try {
174
+ Object.defineProperty(source, WRAPPED, { value: true, enumerable: false, configurable: true })
175
+ if (wrappedCandidates !== undefined) {
176
+ source.candidates = wrappedCandidates
177
+ taken.push('candidates')
178
+ }
179
+ if (typeof order === 'number' && source.order !== order) {
180
+ source.order = order
181
+ taken.push('order')
182
+ }
183
+ if (wrappedPick !== undefined) {
184
+ source.onPick = wrappedPick
185
+ taken.push('onPick')
186
+ }
187
+ } catch {
188
+ try {
189
+ delete source[WRAPPED]
190
+ } catch {
191
+ /* nothing left to undo */
192
+ }
193
+ return undefined
194
+ }
195
+
196
+ if (taken.length === 0) {
197
+ try {
198
+ delete source[WRAPPED]
199
+ } catch {
200
+ /* nothing left to undo */
201
+ }
202
+ return undefined
203
+ }
204
+
205
+ enhancementMode = 'runtime'
206
+
207
+ return () => {
208
+ try {
209
+ if (taken.includes('candidates')) source.candidates = original.candidates
210
+ if (taken.includes('order')) {
211
+ if (original.hadOrder) source.order = original.order
212
+ else delete source.order
213
+ }
214
+ if (taken.includes('onPick')) {
215
+ if (original.hadOnPick) source.onPick = original.onPick
216
+ else delete source.onPick
217
+ }
218
+ delete source[WRAPPED]
219
+ enhancementMode = undefined
220
+ } catch {
221
+ /* a source that became read-only keeps the wrapper; not fatal */
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Install the takeover, retrying while the official plugin has not registered
228
+ * its source yet (both halves load during the same client boot, in an order the
229
+ * picker does not control).
230
+ *
231
+ * @param inputTriggers - the injected service.
232
+ * @param hooks - { rank, order, onPick } passed through to wrapSkillSource.
233
+ * @param options - attempts (default 40) / intervalMs (default 250).
234
+ * @returns a disposer that stops retrying and unwraps.
235
+ */
236
+ export function installSlashFuzzy(inputTriggers, hooks = {}, options = {}) {
237
+ const maxAttempts = options.attempts ?? 40
238
+ const intervalMs = options.intervalMs ?? 250
239
+ let restore
240
+ let timer
241
+ let attempts = 0
242
+ let stopped = false
243
+
244
+ const attempt = () => {
245
+ if (stopped || restore !== undefined) return
246
+ try {
247
+ const source = findSkillSource(inputTriggers)
248
+ if (source === undefined) {
249
+ if (attempts++ < maxAttempts) {
250
+ timer = setTimeout(attempt, intervalMs)
251
+ } else {
252
+ // Loud on the give-up case. The first release of this module reached
253
+ // for a helper the service does not have and then quietly did nothing
254
+ // for ten seconds; nobody could tell it had never run.
255
+ console.warn('[dsh-skill-picker] / takeover: the official "skill" trigger source was not found'
256
+ + ` after ${maxAttempts} attempts — the / menu keeps the official matcher.`
257
+ + ' See https://github.com/a735624258/dsh-skill-picker/issues/14')
258
+ }
259
+ return
260
+ }
261
+ // Already wrapped (a previous install, or an HMR re-apply): stop retrying
262
+ // and leave the existing wrapper in place.
263
+ restore = wrapSkillSource(source, hooks) ?? (() => {})
264
+ } catch {
265
+ // Never let the takeover attempt escape into the client boot.
266
+ restore = () => {}
267
+ }
268
+ }
269
+
270
+ attempt()
271
+
272
+ return () => {
273
+ stopped = true
274
+ if (timer !== undefined) clearTimeout(timer)
275
+ restore?.()
276
+ restore = undefined
277
+ }
278
+ }
@@ -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"