dsh-skill-picker 0.5.20 → 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.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Shared picker state — the pinned list and the usage history, kept in ONE place
3
+ * instead of per browser origin.
4
+ *
5
+ * `localStorage` is scoped to an origin, so the desktop app (`dsh-app://app`),
6
+ * the web UI (`http://127.0.0.1:3080`) and a phone each kept a private copy: pin
7
+ * a skill on the desktop and the web UI never saw it. The host stores the list
8
+ * once per DSH home (`$DSH_HOME/dsh-skill-picker-state.json`) and this module
9
+ * mirrors it into `localStorage`.
10
+ *
11
+ * Why mirror instead of replacing `localStorage`: the ranking runs during render
12
+ * and reads its inputs SYNCHRONOUSLY, so the data path has to stay synchronous.
13
+ * The mirror keeps that true — the host copy is the source of truth, and
14
+ * `localStorage` is the synchronous cache of it. Every local write is pushed
15
+ * back, so both ends converge.
16
+ *
17
+ * Every function here is best-effort: a missing route or an offline host must
18
+ * leave the picker working exactly as it did before, on local data alone.
19
+ */
20
+
21
+ /** Same keys the panel reads synchronously. */
22
+ export const PINNED_KEY = 'dsh-skill-picker:pinned'
23
+ export const USAGE_KEY = 'dsh-skill-picker:usage'
24
+
25
+ /**
26
+ * Set once this browser has completed a sync against the shared file.
27
+ *
28
+ * The first sync of a browser that already had pins MUST union instead of
29
+ * adopting: whichever client happens to boot first would otherwise overwrite
30
+ * the file, and the next client would adopt that and silently lose its own
31
+ * pins. After the first sync this browser follows the file, so a pin removed
32
+ * on one client really disappears from the others.
33
+ */
34
+ const SYNCED_KEY = 'dsh-skill-picker:shared-synced'
35
+
36
+ /** Host route (same origin as the GUI in both the desktop app and the web UI). */
37
+ const ENDPOINT = '/dsh-skill-picker/state'
38
+
39
+ /** Fired after a pull changes the mirror, so open surfaces re-read it. */
40
+ export const SHARED_STATE_EVENT = 'dsh-skill-picker:shared-state'
41
+
42
+ /** Read the mirrored pinned list; never throws. */
43
+ function readLocalPinned() {
44
+ try {
45
+ const parsed = JSON.parse(localStorage.getItem(PINNED_KEY) ?? '[]')
46
+ return Array.isArray(parsed) ? parsed.filter((name) => typeof name === 'string') : []
47
+ } catch {
48
+ return []
49
+ }
50
+ }
51
+
52
+ /** Read the mirrored usage history; never throws. */
53
+ function readLocalUsage() {
54
+ try {
55
+ const parsed = JSON.parse(localStorage.getItem(USAGE_KEY) ?? '{}')
56
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
57
+ } catch {
58
+ return {}
59
+ }
60
+ }
61
+
62
+ /** Write the mirror; never throws. */
63
+ function writeLocal(state) {
64
+ try {
65
+ localStorage.setItem(PINNED_KEY, JSON.stringify(state?.pinned ?? []))
66
+ localStorage.setItem(USAGE_KEY, JSON.stringify(state?.usage ?? {}))
67
+ } catch {
68
+ /* storage unavailable — the host copy is still authoritative */
69
+ }
70
+ }
71
+
72
+ /** Whether a state object carries anything worth adopting. */
73
+ function isEmptyState(state) {
74
+ if (state === null || state === undefined || typeof state !== 'object') return true
75
+ const pinned = Array.isArray(state.pinned) ? state.pinned : []
76
+ const usage = state.usage !== null && typeof state.usage === 'object' ? state.usage : {}
77
+ return pinned.length === 0 && Object.keys(usage).length === 0
78
+ }
79
+
80
+ /** Whether this browser has ever completed a sync. */
81
+ function hasSynced() {
82
+ try {
83
+ return localStorage.getItem(SYNCED_KEY) === '1'
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ /** Remember that this browser is now following the shared file. */
90
+ function markSynced() {
91
+ try {
92
+ localStorage.setItem(SYNCED_KEY, '1')
93
+ } catch {
94
+ /* private mode: it simply unions again next time, which is the safe side */
95
+ }
96
+ }
97
+
98
+ /** Push the local mirror to the shared file.
99
+ *
100
+ * @param options.migrate - first-run mode: the host unions the incoming state
101
+ * with whatever is already stored (instead of overwriting it) and returns the
102
+ * result, which is then adopted locally. Use it only when the shared file is
103
+ * known to be absent.
104
+ * @returns whether the host accepted the write.
105
+ */
106
+ export async function pushSharedState({ migrate = false } = {}) {
107
+ try {
108
+ const response = await fetch(`${ENDPOINT}${migrate ? '?migrate=1' : ''}`, {
109
+ method: 'PUT',
110
+ headers: { 'content-type': 'application/json' },
111
+ body: JSON.stringify({ pinned: readLocalPinned(), usage: readLocalUsage() }),
112
+ })
113
+ if (!response.ok) return false
114
+ const body = await response.json()
115
+ if (body?.ok !== true) return false
116
+ if (migrate) writeLocal(body.state)
117
+ return true
118
+ } catch {
119
+ return false
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Pull the shared file into the local mirror.
125
+ *
126
+ * When this browser has never synced before and already holds pins/usage, the
127
+ * pull goes through the UNIONING migration instead of a plain adopt — otherwise
128
+ * whichever client boots first overwrites the file and the next one silently
129
+ * loses its own pins. Once a browser has synced, it follows the file, so a pin
130
+ * removed on another client really disappears here too.
131
+ *
132
+ * @returns whether the mirror was refreshed from the host.
133
+ */
134
+ export async function pullSharedState() {
135
+ try {
136
+ const response = await fetch(ENDPOINT, { headers: { accept: 'application/json' } })
137
+ if (!response.ok) return false
138
+ const body = await response.json()
139
+ if (body?.ok !== true) return false
140
+
141
+ const localHasData = !isEmptyState({ pinned: readLocalPinned(), usage: readLocalUsage() })
142
+
143
+ // Nothing shared yet: this browser seeds it.
144
+ if (body.state === null || body.state === undefined) {
145
+ const pushed = await pushSharedState({ migrate: true })
146
+ if (pushed) markSynced()
147
+ return true
148
+ }
149
+
150
+ // First sync on a browser that has its own data: union, never overwrite.
151
+ if (!hasSynced() && localHasData) {
152
+ const pushed = await pushSharedState({ migrate: true })
153
+ if (pushed) {
154
+ markSynced()
155
+ return true
156
+ }
157
+ }
158
+
159
+ // The shared file exists but is empty, and this browser is the only one
160
+ // still holding data: push it rather than blanking the mirror.
161
+ if (isEmptyState(body.state) && localHasData) {
162
+ const pushed = await pushSharedState({ migrate: true })
163
+ if (pushed) markSynced()
164
+ return true
165
+ }
166
+
167
+ writeLocal(body.state)
168
+ markSynced()
169
+ return true
170
+ } catch {
171
+ return false
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Pull, then tell open surfaces to re-read the mirror.
177
+ * @returns whether anything was pulled.
178
+ */
179
+ export async function syncSharedState() {
180
+ const pulled = await pullSharedState()
181
+ if (pulled) {
182
+ try {
183
+ window.dispatchEvent(new Event(SHARED_STATE_EVENT))
184
+ } catch {
185
+ /* an environment without window cannot have listeners either */
186
+ }
187
+ }
188
+ return pulled
189
+ }
package/src/index.js CHANGED
@@ -18,12 +18,12 @@
18
18
  * @module dsh-skill-picker
19
19
  */
20
20
 
21
- import { readFile, readdir } from 'node:fs/promises'
21
+ import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
22
22
  import os from 'node:os'
23
23
  import path from 'node:path'
24
24
 
25
25
  import { isDirectoryEntry } from './dir-entry.js'
26
- import { healUiSkillPatches } from './patch-ui-skill.js'
26
+ import { dshHome, healUiSkillPatches, revertUiSkillPatches } from './patch-ui-skill.js'
27
27
 
28
28
  /** Required services: the route registry and the prompt band. */
29
29
  export const inject = ['webServer', 'systemPrompt']
@@ -253,6 +253,117 @@ export function reportUiSkillPatches(report, io = console) {
253
253
  }
254
254
  }
255
255
 
256
+ /**
257
+ * Shared picker state: the pinned list and the usage history, stored ONCE per
258
+ * DSH home instead of per browser origin.
259
+ *
260
+ * Why it exists: `localStorage` is scoped to an origin, so the desktop app
261
+ * (`dsh-app://app`), the web UI (`http://127.0.0.1:3080`) and a phone all kept
262
+ * their own copy — pin a skill on the desktop and the web UI never sees it.
263
+ * Both halves now read and write this one file.
264
+ *
265
+ * @returns the absolute path of the shared state file.
266
+ */
267
+ function sharedStateFile() {
268
+ return path.join(dshHome(), 'dsh-skill-picker-state.json')
269
+ }
270
+
271
+ /** Coerce anything into `{ pinned: string[], usage: {name: {count, lastUsed}} }`. */
272
+ function normalizeSharedState(value) {
273
+ const source = value !== null && typeof value === 'object' ? value : {}
274
+ const pinned = Array.isArray(source.pinned)
275
+ ? [...new Set(source.pinned.filter((name) => typeof name === 'string' && name !== ''))]
276
+ : []
277
+ const usage = {}
278
+ const rawUsage = source.usage !== null && typeof source.usage === 'object' ? source.usage : {}
279
+ for (const [name, entry] of Object.entries(rawUsage)) {
280
+ if (typeof name !== 'string' || name === '') continue
281
+ const record = entry !== null && typeof entry === 'object' ? entry : {}
282
+ const count = Number.isFinite(record.count) ? Math.max(0, Math.trunc(record.count)) : 0
283
+ const lastUsed = Number.isFinite(record.lastUsed) ? Math.max(0, Math.trunc(record.lastUsed)) : 0
284
+ if (count === 0 && lastUsed === 0) continue
285
+ usage[name] = { count, lastUsed }
286
+ }
287
+ return { pinned, usage }
288
+ }
289
+
290
+ /** Read the shared state, or null when the file does not exist yet. */
291
+ async function readSharedState() {
292
+ try {
293
+ return normalizeSharedState(JSON.parse(await readFile(sharedStateFile(), 'utf8')))
294
+ } catch {
295
+ return null
296
+ }
297
+ }
298
+
299
+ /** Write the shared state atomically (temp + rename), creating the home if needed. */
300
+ async function writeSharedState(state) {
301
+ const normal = normalizeSharedState(state)
302
+ const file = sharedStateFile()
303
+ await mkdir(path.dirname(file), { recursive: true })
304
+ const tmp = `${file}.tmp`
305
+ await writeFile(tmp, `${JSON.stringify(normal, null, 2)}\n`, 'utf8')
306
+ await rename(tmp, file)
307
+ return normal
308
+ }
309
+
310
+ /**
311
+ * First-run migration: union both ends instead of letting whichever browser
312
+ * happened to write first win, so nobody silently loses their pins.
313
+ */
314
+ function mergeSharedState(a, b) {
315
+ const left = normalizeSharedState(a)
316
+ const right = normalizeSharedState(b)
317
+ const pinned = [...left.pinned]
318
+ for (const name of right.pinned) if (!pinned.includes(name)) pinned.push(name)
319
+ const usage = { ...left.usage }
320
+ for (const [name, entry] of Object.entries(right.usage)) {
321
+ const previous = usage[name]
322
+ usage[name] = previous === undefined
323
+ ? entry
324
+ : { count: Math.max(previous.count, entry.count), lastUsed: Math.max(previous.lastUsed, entry.lastUsed) }
325
+ }
326
+ return normalizeSharedState({ pinned, usage })
327
+ }
328
+
329
+ /** Read a JSON request body with a hard cap, so a bad client cannot grow memory. */
330
+ async function readJsonBody(req) {
331
+ const chunks = []
332
+ let size = 0
333
+ for await (const chunk of req) {
334
+ size += chunk.length
335
+ if (size > 1_000_000) throw new Error('shared state body too large')
336
+ chunks.push(chunk)
337
+ }
338
+ if (chunks.length === 0) return {}
339
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
340
+ }
341
+
342
+ /**
343
+ * `GET /dsh-skill-picker/state` → `{ ok, state }` (`state: null` before the
344
+ * first write). `PUT` with `{ pinned, usage }` → the stored state; `?migrate=1`
345
+ * unions the incoming state with the stored one and returns the result.
346
+ */
347
+ async function handleSharedState(req, res, url) {
348
+ const json = (code, payload) => {
349
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
350
+ res.end(JSON.stringify(payload))
351
+ }
352
+ try {
353
+ if (req.method === 'PUT' || req.method === 'POST') {
354
+ const incoming = normalizeSharedState(await readJsonBody(req))
355
+ const migrate = url.searchParams.get('migrate') === '1'
356
+ const stored = migrate ? await readSharedState() : null
357
+ const next = stored === null ? await writeSharedState(incoming) : await writeSharedState(mergeSharedState(stored, incoming))
358
+ json(200, { ok: true, state: next })
359
+ return
360
+ }
361
+ json(200, { ok: true, state: await readSharedState() })
362
+ } catch (error) {
363
+ json(500, { ok: false, error: String(error?.message ?? error) })
364
+ }
365
+ }
366
+
256
367
  /**
257
368
  * Mount the skills route and the prompt section.
258
369
  * @param ctx - context carrying webServer and systemPrompt.
@@ -261,8 +372,13 @@ export function apply(ctx) {
261
372
  ctx.effect(() => {
262
373
  const handler = async (req, res) => {
263
374
  try {
375
+ const url = new URL(req.url ?? '/', 'http://dsh')
376
+ if (url.pathname === '/dsh-skill-picker/state') {
377
+ await handleSharedState(req, res, url)
378
+ return
379
+ }
264
380
  // cwd query carries the active session's workspace root from the client.
265
- const cwd = req.url !== undefined ? new URL(req.url, 'http://dsh').searchParams.get('cwd') ?? undefined : undefined
381
+ const cwd = url.searchParams.get('cwd') ?? undefined
266
382
  const skills = await scanSkills(cwd)
267
383
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
268
384
  res.end(JSON.stringify({ ok: true, complete: true, skills }))
@@ -280,17 +396,32 @@ export function apply(ctx) {
280
396
  text: SKILL_PICKER_GUIDANCE,
281
397
  }), 'dsh-skill-picker: prompt section')
282
398
 
283
- // Self-healing patch for the official ui-skill package: keeps the `/`
284
- // completion's skill group ordered above commands and its matching fuzzy
285
- // across DSH upgrades. Runs once per boot; idempotent, backed up, and
286
- // never allowed to take the host down.
399
+ // The `/` enhancement no longer edits official files.
400
+ //
401
+ // Since v0.5.15 it takes over the LIVE trigger source at runtime, which does
402
+ // everything the old file patch did (skill group first, fuzzy+pinyin matching,
403
+ // usage tracking) without touching a single file. The patch is therefore
404
+ // redundant — and a modified copy of an official package is confusing in its
405
+ // own right: it made the desktop and web profiles disagree about one shared
406
+ // file, and it is what made "is this the original?" impossible to answer.
287
407
  //
288
- // Logging is gated on "did this boot change anything / fail" rather than
289
- // "was a target found" — see `reportUiSkillPatches` (issue #8).
408
+ // So the default is now to RESTORE our own patch (from the `.bak` it made).
409
+ // Set DSH_SKILL_PICKER_FILE_PATCH=1 to fall back to the old behaviour on a
410
+ // kernel whose trigger registry has no live sources to take over.
290
411
  ctx.effect(() => {
291
- healUiSkillPatches().then((report) => {
292
- reportUiSkillPatches(report)
293
- }).catch((error) => {
412
+ const enabled = process.env.DSH_SKILL_PICKER_FILE_PATCH === '1'
413
+ const task = enabled
414
+ ? healUiSkillPatches().then((report) => reportUiSkillPatches(report))
415
+ : revertUiSkillPatches().then((report) => {
416
+ if (report.restored.length > 0) {
417
+ console.log('[dsh-skill-picker] ui-skill patch retired; restored original file(s):',
418
+ report.restored.join(', '))
419
+ }
420
+ if (report.errors.length > 0) {
421
+ console.warn('[dsh-skill-picker] ui-skill restore errors:', JSON.stringify(report.errors))
422
+ }
423
+ })
424
+ task.catch((error) => {
294
425
  console.warn('[dsh-skill-picker] ui-skill patch failed:', error)
295
426
  })
296
427
  return () => {}
@@ -326,6 +326,53 @@ export async function patchUiSkillFile(file) {
326
326
  * into `errors` so the host boot is never taken down by a broken patch.
327
327
  * @returns {Promise<{files: Array, errors: string[]}>}
328
328
  */
329
+ /**
330
+ * Undo our own file patch: restore every `<file>.dsh-skill-picker.bak` we made.
331
+ *
332
+ * The `/` enhancement has not needed this patch since v0.5.15, which takes over
333
+ * the LIVE trigger source at runtime instead. Keeping the patch meant shipping a
334
+ * modified copy of an official package — confusing on its own, and it made the
335
+ * desktop and web profiles disagree about the same file.
336
+ *
337
+ * Restores only a file that still carries OUR marker, so a file edited by
338
+ * something else since is left alone. Idempotent: after one pass there is
339
+ * nothing left to restore.
340
+ *
341
+ * @returns {Promise<{restored: string[], errors: string[]}>}
342
+ */
343
+ export async function revertUiSkillPatches() {
344
+ const files = await uiSkillClientPaths()
345
+ const restored = []
346
+ const errors = []
347
+ for (const file of files) {
348
+ const backup = `${file}.dsh-skill-picker.bak`
349
+ try {
350
+ let text
351
+ try {
352
+ text = await readFile(file, 'utf8')
353
+ } catch {
354
+ continue
355
+ }
356
+ if (!PATCHES.some((patch) => patch.isApplied(text))) continue
357
+ let original
358
+ try {
359
+ original = await readFile(backup, 'utf8')
360
+ } catch {
361
+ continue
362
+ }
363
+ // Same temp+rename dance as the patch itself: never write a hardlinked
364
+ // package file in place (that would mutate the shared pnpm store).
365
+ const tmp = `${file}.dsh-skill-picker.tmp`
366
+ await writeFile(tmp, original, 'utf8')
367
+ await rename(tmp, file)
368
+ restored.push(file)
369
+ } catch (error) {
370
+ errors.push(`${file}: ${String(error?.message ?? error)}`)
371
+ }
372
+ }
373
+ return { restored, errors }
374
+ }
375
+
329
376
  export async function healUiSkillPatches() {
330
377
  const files = await uiSkillClientPaths()
331
378
  const filesReport = []