dsh-skill-picker 0.5.21 → 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.
- package/README.md +1 -0
- package/lib/client.js +125 -6
- package/lib/client.js.map +4 -4
- package/lib/index.js +84 -2
- package/lib/index.js.map +3 -3
- package/package.json +1 -1
- package/src/client/index.jsx +29 -2
- package/src/client/shared-state.js +189 -0
- package/src/index.js +119 -3
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, revertUiSkillPatches } 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 =
|
|
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 }))
|