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.
- package/README.md +234 -214
- package/lib/client.js +216 -24
- package/lib/client.js.map +3 -3
- package/lib/index.js +1 -1
- package/lib/index.js.map +2 -2
- package/package.json +76 -76
- package/src/client/index.jsx +94 -46
- package/src/client/slash-source.js +278 -0
- package/src/patch-ui-skill.js +1 -1
|
@@ -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
|
+
}
|
package/src/patch-ui-skill.js
CHANGED
|
@@ -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/
|
|
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"
|